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

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

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