PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / engine / ContentMatchingEngine.php

ContentMatchingEngine.php in 404 Solution 4.3.0, at includes/engine/ContentMatchingEngine.php

295 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Matching engine that searches post content (body text) for URL keywords.
9 *
10 * Last resort before Levenshtein spelling — catches cases where URL keywords
11 * appear in the post body but not the title or slug. Queries published posts
12 * via DataAccess::getPublishedPagesAndPostsIDs() with a LIKE-based WHERE clause
13 * against post_content, then scores candidates by title keyword overlap to
14 * avoid matching random keyword appearances in body text.
15 */
16 class ABJ_404_Solution_ContentMatchingEngine implements ABJ_404_Solution_MatchingEngine {
17
18 /** Minimum number of keywords required to run (single keyword too ambiguous). */
19 const MIN_KEYWORDS = 2;
20
21 /** Maximum keywords to use from the slug (caps query complexity). */
22 const MAX_KEYWORDS = 5;
23
24 /** Minimum word length to keep after splitting (filters articles, prepositions). */
25 const MIN_WORD_LENGTH = 3;
26
27 /** Maximum rows to fetch from the DB query. */
28 const QUERY_LIMIT = 100;
29
30 /** Maximum Levenshtein distance to accept as a fuzzy keyword match. */
31 const MAX_FUZZY_DISTANCE = 2;
32
33 /** Weight assigned to a fuzzy (non-exact) keyword match (0.0–1.0). */
34 const FUZZY_MATCH_WEIGHT = 0.75;
35
36 /** @var array<int, string> Common English stop words filtered from URL slugs and post content. */
37 public static $stopWords = [
38 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'your', 'have',
39 'will', 'been', 'they', 'their', 'what', 'when', 'where', 'which',
40 'there', 'about', 'would', 'could', 'should', 'into', 'than',
41 'then', 'them', 'these', 'those', 'does', 'done', 'also', 'just',
42 'more', 'most', 'much', 'very', 'some', 'only', 'over', 'such',
43 'each', 'were', 'here', 'after', 'before', 'between', 'under',
44 'through', 'being', 'other', 'like', 'not', 'but', 'are', 'was',
45 'all', 'any', 'can', 'had', 'her', 'his', 'how', 'its', 'may',
46 'our', 'own', 'who', 'you',
47 ];
48
49 /** @var ABJ_404_Solution_ContentRepositoryInterface */
50 private $contentRepo;
51
52 /** @var ABJ_404_Solution_Functions */
53 private $f;
54
55 /** @var ABJ_404_Solution_Logging */
56 private $logger;
57
58 /**
59 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo
60 * @param ABJ_404_Solution_Functions $f
61 * @param ABJ_404_Solution_Logging $logger
62 */
63 public function __construct(
64 ABJ_404_Solution_ContentRepositoryInterface $contentRepo,
65 ABJ_404_Solution_Functions $f,
66 ABJ_404_Solution_Logging $logger
67 ) {
68 $this->contentRepo = $contentRepo;
69 $this->f = $f;
70 $this->logger = $logger;
71 }
72
73 /** @return string */
74 public function getName(): string {
75 return __('content keywords', '404-solution');
76 }
77
78 /** @param ABJ_404_Solution_MatchRequest $request */
79 public function shouldRun(ABJ_404_Solution_MatchRequest $request): bool {
80 $slug = $request->getUrlSlugOnly();
81
82 if ($slug === '') {
83 return false;
84 }
85
86 // Skip date/tracking patterns (4+ consecutive digits)
87 if (preg_match('/\d{4,}/', $slug)) {
88 return false;
89 }
90
91 $keywords = $this->extractKeywords($slug);
92
93 return count($keywords) >= self::MIN_KEYWORDS;
94 }
95
96 /** @param ABJ_404_Solution_MatchRequest $request */
97 public function match(ABJ_404_Solution_MatchRequest $request): ?ABJ_404_Solution_MatchResult {
98 $keywords = $this->extractKeywords($request->getUrlSlugOnly());
99
100 if (count($keywords) < self::MIN_KEYWORDS) {
101 return null;
102 }
103
104 $extraWhere = $this->buildWhereClause($keywords);
105 $rows = $this->contentRepo->getPublishedPagesAndPostsIDs('', '', '0,' . self::QUERY_LIMIT, '', $extraWhere);
106
107 if (empty($rows)) {
108 $this->logger->debugMessage("Content engine: no candidates for keywords [" .
109 implode(', ', $keywords) . "]");
110 return null;
111 }
112
113 $bestScore = 0.0;
114 $bestRow = null;
115
116 foreach ($rows as $row) {
117 $title = isset($row->post_title) && is_string($row->post_title) ? $row->post_title : '';
118 if ($title === '') {
119 continue;
120 }
121
122 $score = $this->scoreText($keywords, $title);
123 if ($score > $bestScore) {
124 $bestScore = $score;
125 $bestRow = $row;
126 }
127 }
128
129 if ($bestRow === null) {
130 return null;
131 }
132
133 $minScore = $request->getMinScore('auto_score_content');
134
135 if ($bestScore < $minScore) {
136 $this->logger->debugMessage("Content engine: best score " . $bestScore .
137 " below threshold " . $minScore);
138 return null;
139 }
140
141 $id = isset($bestRow->id) && is_scalar($bestRow->id) ? (string)$bestRow->id : '';
142 $title = isset($bestRow->post_title) && is_string($bestRow->post_title) ? $bestRow->post_title : '';
143 $link = isset($bestRow->url) && is_string($bestRow->url) ? $bestRow->url : '';
144 $type = (string)ABJ404_TYPE_POST;
145
146 return new ABJ_404_Solution_MatchResult($id, $type, $link, $title, $bestScore, $this->getName());
147 }
148
149 /**
150 * Extract meaningful keywords from a URL slug.
151 *
152 * 1. Replace separators (hyphens, underscores, tildes, dots, slashes, %20) with spaces
153 * 2. Split on whitespace, lowercase
154 * 3. Filter words shorter than MIN_WORD_LENGTH
155 * 4. Filter common English stop words
156 * 5. Return unique words, max MAX_KEYWORDS
157 *
158 * @param string $slug
159 * @return array<int, string>
160 */
161 private function extractKeywords(string $slug): array {
162 // Decode percent-encoded characters first
163 $decoded = rawurldecode($slug);
164
165 // Replace separators with spaces (including slashes like CategoryTagMatchingEngine)
166 $normalized = preg_replace('/[-_~.\/]+/', ' ', $decoded);
167 if (!is_string($normalized)) {
168 return [];
169 }
170
171 // Split on whitespace, lowercase
172 $words = preg_split('/\s+/', $this->f->strtolower(trim($normalized)));
173 if (!is_array($words)) {
174 return [];
175 }
176
177 $stopLookup = array_flip(self::$stopWords);
178 $keywords = [];
179
180 foreach ($words as $word) {
181 if (!is_string($word)) {
182 continue;
183 }
184 if ($this->f->strlen($word) < self::MIN_WORD_LENGTH) {
185 continue;
186 }
187 if (isset($stopLookup[$word])) {
188 continue;
189 }
190 if (!isset($keywords[$word])) {
191 $keywords[$word] = true;
192 }
193 }
194
195 return array_slice(array_keys($keywords), 0, self::MAX_KEYWORDS);
196 }
197
198 /**
199 * Build a SQL WHERE clause that matches any keyword in the cached content keywords.
200 *
201 * Uses the pre-extracted content_keywords column from the permalink cache table
202 * instead of searching the full post_content. Keywords are stored lowercase,
203 * so no lower() wrapping needed.
204 *
205 * When content_keywords is NULL (cache not yet populated), NULL LIKE '%x%'
206 * evaluates to NULL (falsy), so no rows match — graceful degradation.
207 *
208 * @param array<int, string> $keywords
209 * @return string
210 */
211 private function buildWhereClause(array $keywords): string {
212 $conditions = [];
213 foreach ($keywords as $kw) {
214 // Strip invalid UTF-8 before SQL — keywords originate from
215 // rawurldecode'd URL slugs and can carry scanner-attack bytes
216 // (Pattern 10 — esc_sql does not validate UTF-8).
217 $cleanKw = $this->f->sanitizeInvalidUTF8($this->f->strtolower($kw));
218 $escaped = esc_sql($cleanKw);
219 $conditions[] = "plc.content_keywords LIKE '%" . $escaped . "%'";
220 }
221
222 return ' and (' . implode(' OR ', $conditions) . ')';
223 }
224
225 /**
226 * Score a text string by keyword overlap ratio with fuzzy matching.
227 *
228 * Splits the text into words and checks how many URL keywords match.
229 * Exact whole-word matches score 1.0. If no exact match, Levenshtein
230 * distance is checked against each text word; a close match (distance
231 * <= MAX_FUZZY_DISTANCE) scores FUZZY_MATCH_WEIGHT. Returns
232 * (weightedMatches / totalKeywords) * 100.
233 *
234 * @param array<int, string> $keywords
235 * @param string $text
236 * @return float Score from 0.0 to 100.0
237 */
238 private function scoreText(array $keywords, string $text): float {
239 if (empty($keywords)) {
240 return 0.0;
241 }
242
243 $textLower = $this->f->strtolower($text);
244 $textWords = preg_split('/[\s\-_:;,!?.()\/\[\]]+/', $textLower);
245 if (!is_array($textWords)) {
246 return 0.0;
247 }
248
249 $filteredTextWords = array_values(array_filter($textWords, function ($w) {
250 return is_string($w) && $w !== '';
251 }));
252 $textWordSet = array_flip($filteredTextWords);
253
254 $weightedMatches = 0.0;
255 foreach ($keywords as $kw) {
256 if (isset($textWordSet[$kw])) {
257 $weightedMatches += 1.0;
258 } else {
259 $weightedMatches += $this->bestFuzzyMatch($kw, $filteredTextWords);
260 }
261 }
262
263 return ($weightedMatches / count($keywords)) * 100.0;
264 }
265
266 /**
267 * Find the best fuzzy match for a keyword among text words.
268 *
269 * Returns FUZZY_MATCH_WEIGHT if any text word is within MAX_FUZZY_DISTANCE,
270 * 0.0 otherwise. Only compares words of similar length to avoid nonsense matches.
271 *
272 * @param string $keyword
273 * @param array<int, string> $textWords
274 * @return float 0.0 or FUZZY_MATCH_WEIGHT
275 */
276 private function bestFuzzyMatch(string $keyword, array $textWords): float {
277 $kwLen = $this->f->strlen($keyword);
278
279 foreach ($textWords as $tw) {
280 $twLen = $this->f->strlen($tw);
281
282 // Skip if length difference alone exceeds max distance
283 if (abs($kwLen - $twLen) > self::MAX_FUZZY_DISTANCE) {
284 continue;
285 }
286
287 if (levenshtein($keyword, $tw) <= self::MAX_FUZZY_DISTANCE) {
288 return self::FUZZY_MATCH_WEIGHT;
289 }
290 }
291
292 return 0.0;
293 }
294 }
295