PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/engine/ContentMatchingEngine.php

305 lines 10.7 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(array(
106 'limit_results' => '0,' . self::QUERY_LIMIT,
107 'extra_where_clause' => $extraWhere,
108 ));
109
110 if (empty($rows)) {
111 $this->logger->debugMessage("Content engine: no candidates for keywords [" .
112 implode(', ', $keywords) . "]");
113 return null;
114 }
115
116 $bestScore = 0.0;
117 $bestRow = null;
118
119 foreach ($rows as $row) {
120 $title = isset($row->post_title) && is_string($row->post_title) ? $row->post_title : '';
121 if ($title === '') {
122 continue;
123 }
124
125 $score = $this->scoreText($keywords, $title);
126 if ($score > $bestScore) {
127 $bestScore = $score;
128 $bestRow = $row;
129 }
130 }
131
132 if ($bestRow === null) {
133 return null;
134 }
135
136 $minScore = $request->getMinScore('auto_score_content');
137
138 if ($bestScore < $minScore) {
139 $this->logger->debugMessage("Content engine: best score " . $bestScore .
140 " below threshold " . $minScore);
141 // Sibling of the spelling near miss (SpellChecker::getPermalinkUsingSpelling):
142 // the best candidate lost only on the threshold, and that score is
143 // what tells the admin why the URL was captured instead of
144 // redirected. Record it before the reject branch discards it.
145 abj_service('near_miss_recorder')->record(array(
146 'requestedURL' => $request->getRequestedURL(), 'score' => (float)$bestScore,
147 'engineName' => $this->getName()));
148 return null;
149 }
150
151 $id = isset($bestRow->id) && is_scalar($bestRow->id) ? (string)$bestRow->id : '';
152 $title = isset($bestRow->post_title) && is_string($bestRow->post_title) ? $bestRow->post_title : '';
153 $link = isset($bestRow->url) && is_string($bestRow->url) ? $bestRow->url : '';
154 $type = (string)ABJ404_TYPE_POST;
155
156 return new ABJ_404_Solution_MatchResult($id, $type, $link, $title, $bestScore, $this->getName());
157 }
158
159 /**
160 * Extract meaningful keywords from a URL slug.
161 *
162 * 1. Replace separators (hyphens, underscores, tildes, dots, slashes, %20) with spaces
163 * 2. Split on whitespace, lowercase
164 * 3. Filter words shorter than MIN_WORD_LENGTH
165 * 4. Filter common English stop words
166 * 5. Return unique words, max MAX_KEYWORDS
167 *
168 * @param string $slug
169 * @return array<int, string>
170 */
171 private function extractKeywords(string $slug): array {
172 // Decode percent-encoded characters first
173 $decoded = rawurldecode($slug);
174
175 // Replace separators with spaces (including slashes like CategoryTagMatchingEngine)
176 $normalized = preg_replace('/[-_~.\/]+/', ' ', $decoded);
177 if (!is_string($normalized)) {
178 return [];
179 }
180
181 // Split on whitespace, lowercase
182 $words = preg_split('/\s+/', $this->f->strtolower(trim($normalized)));
183 if (!is_array($words)) {
184 return [];
185 }
186
187 $stopLookup = array_flip(self::$stopWords);
188 $keywords = [];
189
190 foreach ($words as $word) {
191 if (!is_string($word)) {
192 continue;
193 }
194 if ($this->f->strlen($word) < self::MIN_WORD_LENGTH) {
195 continue;
196 }
197 if (isset($stopLookup[$word])) {
198 continue;
199 }
200 if (!isset($keywords[$word])) {
201 $keywords[$word] = true;
202 }
203 }
204
205 return array_slice(array_keys($keywords), 0, self::MAX_KEYWORDS);
206 }
207
208 /**
209 * Build a SQL WHERE clause that matches any keyword in the cached content keywords.
210 *
211 * Uses the pre-extracted content_keywords column from the permalink cache table
212 * instead of searching the full post_content. Keywords are stored lowercase,
213 * so no lower() wrapping needed.
214 *
215 * When content_keywords is NULL (cache not yet populated), NULL LIKE '%x%'
216 * evaluates to NULL (falsy), so no rows match — graceful degradation.
217 *
218 * @param array<int, string> $keywords
219 * @return string
220 */
221 private function buildWhereClause(array $keywords): string {
222 $conditions = [];
223 foreach ($keywords as $kw) {
224 // Strip invalid UTF-8 before SQL — keywords originate from
225 // rawurldecode'd URL slugs and can carry scanner-attack bytes
226 // (Pattern 10 — esc_sql does not validate UTF-8).
227 $cleanKw = $this->f->sanitizeInvalidUTF8($this->f->strtolower($kw));
228 $escaped = esc_sql($cleanKw);
229 $conditions[] = "plc.content_keywords LIKE '%" . $escaped . "%'";
230 }
231
232 return ' and (' . implode(' OR ', $conditions) . ')';
233 }
234
235 /**
236 * Score a text string by keyword overlap ratio with fuzzy matching.
237 *
238 * Splits the text into words and checks how many URL keywords match.
239 * Exact whole-word matches score 1.0. If no exact match, Levenshtein
240 * distance is checked against each text word; a close match (distance
241 * <= MAX_FUZZY_DISTANCE) scores FUZZY_MATCH_WEIGHT. Returns
242 * (weightedMatches / totalKeywords) * 100.
243 *
244 * @param array<int, string> $keywords
245 * @param string $text
246 * @return float Score from 0.0 to 100.0
247 */
248 private function scoreText(array $keywords, string $text): float {
249 if (empty($keywords)) {
250 return 0.0;
251 }
252
253 $textLower = $this->f->strtolower($text);
254 $textWords = preg_split('/[\s\-_:;,!?.()\/\[\]]+/', $textLower);
255 if (!is_array($textWords)) {
256 return 0.0;
257 }
258
259 $filteredTextWords = array_values(array_filter($textWords, function ($w) {
260 return is_string($w) && $w !== '';
261 }));
262 $textWordSet = array_flip($filteredTextWords);
263
264 $weightedMatches = 0.0;
265 foreach ($keywords as $kw) {
266 if (isset($textWordSet[$kw])) {
267 $weightedMatches += 1.0;
268 } else {
269 $weightedMatches += $this->bestFuzzyMatch($kw, $filteredTextWords);
270 }
271 }
272
273 return ($weightedMatches / count($keywords)) * 100.0;
274 }
275
276 /**
277 * Find the best fuzzy match for a keyword among text words.
278 *
279 * Returns FUZZY_MATCH_WEIGHT if any text word is within MAX_FUZZY_DISTANCE,
280 * 0.0 otherwise. Only compares words of similar length to avoid nonsense matches.
281 *
282 * @param string $keyword
283 * @param array<int, string> $textWords
284 * @return float 0.0 or FUZZY_MATCH_WEIGHT
285 */
286 private function bestFuzzyMatch(string $keyword, array $textWords): float {
287 $kwLen = $this->f->strlen($keyword);
288
289 foreach ($textWords as $tw) {
290 $twLen = $this->f->strlen($tw);
291
292 // Skip if length difference alone exceeds max distance
293 if (abs($kwLen - $twLen) > self::MAX_FUZZY_DISTANCE) {
294 continue;
295 }
296
297 if (levenshtein($keyword, $tw) <= self::MAX_FUZZY_DISTANCE) {
298 return self::FUZZY_MATCH_WEIGHT;
299 }
300 }
301
302 return 0.0;
303 }
304 }
305