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

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

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