PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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.3, at includes/engine/TitleMatchingEngine.php

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