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 / TitleMatchingEngine.php

TitleMatchingEngine.php in 404 Solution trunk, at includes/engine/TitleMatchingEngine.php

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