PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / SpellCandidateFilter.php

SpellCandidateFilter.php in 404 Solution 4.2.0, at includes/SpellCandidateFilter.php

502 lines 18.0 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 * Candidate filtering, scoring, and matching on posts/tags/categories
9 * for the spell-checking subsystem.
10 *
11 * Extracted from SpellCheckerTrait_CandidateFiltering as a standalone class
12 * with explicit dependency injection.
13 */
14 class ABJ_404_Solution_SpellCandidateFilter {
15
16 /** @var ABJ_404_Solution_Functions */
17 private $f;
18
19 /** @var ABJ_404_Solution_PluginLogic */
20 private $logic;
21
22 /** @var ABJ_404_Solution_Logging */
23 private $logger;
24
25 /** @var ABJ_404_Solution_ContentRepository */
26 private $contentRepository;
27
28 /** @var ABJ_404_Solution_SpellURLMatcher */
29 private $urlMatcher;
30
31 /** @var ABJ_404_Solution_SpellLevenshteinEngine */
32 private $levenshteinEngine;
33
34 /** @var ABJ_404_Solution_SpellPostListeners */
35 private $postListeners;
36
37 /** @var string|int|null */
38 private $custom404PageID;
39
40 /** @var array<int, string> */
41 private array $separatingCharacters;
42
43 /** @var array<int, string> */
44 private array $separatingCharactersForImages;
45
46 /**
47 * @param ABJ_404_Solution_Functions $functions
48 * @param ABJ_404_Solution_PluginLogic $logic
49 * @param ABJ_404_Solution_Logging $logger
50 * @param ABJ_404_Solution_ContentRepository $contentRepository
51 * @param ABJ_404_Solution_SpellURLMatcher $urlMatcher
52 * @param ABJ_404_Solution_SpellLevenshteinEngine $levenshteinEngine
53 * @param ABJ_404_Solution_SpellPostListeners $postListeners
54 * @param string|int|null $custom404PageID
55 * @param array<int, string> $separatingCharacters
56 * @param array<int, string> $separatingCharactersForImages
57 */
58 public function __construct(
59 $functions, $logic, $logger, $contentRepository,
60 $urlMatcher, $levenshteinEngine, $postListeners,
61 $custom404PageID, array $separatingCharacters, array $separatingCharactersForImages
62 ) {
63 $this->f = $functions;
64 $this->logic = $logic;
65 $this->logger = $logger;
66 $this->contentRepository = $contentRepository;
67 $this->urlMatcher = $urlMatcher;
68 $this->levenshteinEngine = $levenshteinEngine;
69 $this->postListeners = $postListeners;
70 $this->custom404PageID = $custom404PageID;
71 $this->separatingCharacters = $separatingCharacters;
72 $this->separatingCharactersForImages = $separatingCharactersForImages;
73 }
74
75 /**
76 * @param string $requestedURLRaw
77 * @param string $includeCats
78 * @param string $includeTags
79 * @return array<int, mixed>
80 */
81 function findMatchingPosts(string $requestedURLRaw, string $includeCats = '1', string $includeTags = '1') {
82
83 $options = $this->logic->getOptions();
84 $excludePagesCount = 0;
85 $excludePagesRaw = isset($options['excludePages[]']) && is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
86 if (trim($excludePagesRaw) !== '') {
87 $jsonResult = json_decode($excludePagesRaw);
88 if (!is_array($jsonResult)) {
89 $jsonResult = array($jsonResult);
90 }
91 $excludePagesCount = count($jsonResult);
92 }
93 $suggestMaxRaw = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
94 $maxCacheCount = absint($suggestMaxRaw) + $excludePagesCount;
95
96 $requestedURLSpaces = $this->f->str_replace($this->separatingCharacters, " ", $requestedURLRaw);
97 $requestedURLCleaned = $this->urlMatcher->getLastURLPart($requestedURLSpaces);
98 $fullURLspacesCleaned = $this->f->str_replace('/', " ", $requestedURLSpaces);
99 if ($fullURLspacesCleaned == $requestedURLCleaned) {
100 $fullURLspacesCleaned = '';
101 }
102
103 $this->postListeners->initializePublishedPostsProvider();
104 $this->levenshteinEngine->setPublishedPostsProvider(
105 $this->postListeners->getPublishedPostsProvider()
106 );
107
108 $rowType = 'pages';
109 $permalinks = array();
110 $permalinks = $this->matchOnPosts($permalinks, $requestedURLRaw, $requestedURLCleaned,
111 $fullURLspacesCleaned, $rowType);
112
113 if ($includeTags == "1") {
114 $permalinks = $this->matchOnTags($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, 'tags');
115 }
116
117 if ($includeCats == "1") {
118 $permalinks = $this->matchOnCats($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, 'categories');
119 }
120
121 $permalinks = $this->removeExcludedPages($options, $permalinks);
122
123 arsort($permalinks);
124
125 $permalinks = $this->removeExcludedPagesWithRegex($options, $permalinks, $maxCacheCount);
126
127 $permalinks = array_splice($permalinks, 0, $maxCacheCount);
128
129 $returnValue = array($permalinks,$rowType);
130 $this->contentRepository->storeSpellingPermalinksToCache($requestedURLRaw, $returnValue);
131 $ctx = abj_service('request_context');
132 $ctx->permalinks_found = (string)json_encode($returnValue);
133 $ctx->permalinks_kept = (string)json_encode($permalinks);
134
135 return $returnValue;
136 }
137
138 /**
139 * @param array<string, mixed> $options
140 * @param array<string, string> $permalinks
141 * @return array<string, string>
142 */
143 function removeExcludedPages(array $options, array $permalinks): array {
144 $excludePagesJsonRaw = isset($options['excludePages[]']) ? $options['excludePages[]'] : '';
145 $excludePagesJson = is_string($excludePagesJsonRaw) ? $excludePagesJsonRaw : '';
146 if (trim($excludePagesJson) == '' && $this->custom404PageID == null) {
147 return $permalinks;
148 }
149
150 $excludePages = json_decode($excludePagesJson);
151 if (!is_array($excludePages)) {
152 $excludePages = array($excludePages);
153 }
154
155 if ($this->custom404PageID != null) {
156 array_push($excludePages, $this->custom404PageID);
157 }
158
159 for ($i = 0; $i < count($excludePages); $i++) {
160 $excludePage = $excludePages[$i];
161 if ($excludePage == null || trim($excludePage) == '') {
162 continue;
163 }
164 unset($permalinks[(string)$excludePage]);
165 }
166
167 return $permalinks;
168 }
169
170 /**
171 * @param array<string, mixed> $options
172 * @param array<string, string> $permalinks
173 * @param int $maxCacheCount
174 * @return array<string, string>
175 */
176 function removeExcludedPagesWithRegex(array $options, array $permalinks, int $maxCacheCount): array {
177 if (!isset($options['suggest_regex_exclusions_usable']) ||
178 !is_array($options['suggest_regex_exclusions_usable']) ||
179 empty($options['suggest_regex_exclusions_usable'])) {
180 return $permalinks;
181 }
182
183 $suggestionsKeptSoFar = 0;
184 $regexExclusions = $options['suggest_regex_exclusions_usable'];
185
186 $keys_to_check = array_keys($permalinks);
187
188 foreach ($keys_to_check as $key) {
189 if (!array_key_exists($key, $permalinks)) {
190 continue;
191 }
192
193 $keyParts = explode('|', $key);
194 if (count($keyParts) !== 2 || !is_numeric($keyParts[0])) {
195 $this->logger->debugMessage("Skipping invalid key format in removeExcludedPagesWithRegex: " . $key);
196 continue;
197 }
198
199 $id = (int)$keyParts[0];
200 $typeConstant = $keyParts[1];
201
202 $rowTypeString = $this->mapTypeConstantToString($typeConstant);
203 if ($rowTypeString === null) {
204 $this->logger->debugMessage("Skipping unknown type constant in removeExcludedPagesWithRegex: " . $typeConstant . " for key: " . $key);
205 continue;
206 }
207
208 $urlOfPage = $this->urlMatcher->getPermalink($id, $rowTypeString);
209 if ($urlOfPage === null || trim($urlOfPage) === '') {
210 $this->logger->debugMessage("Skipping null/empty URL for key in removeExcludedPagesWithRegex: " . $key);
211 continue;
212 }
213
214 $urlParts = parse_url($urlOfPage);
215 if (!is_array($urlParts) || !isset($urlParts['path'])) {
216 $this->logger->debugMessage("Skipping URL that failed parse_url for key in removeExcludedPagesWithRegex: " . $key . ", URL: " . esc_url($urlOfPage));
217 continue;
218 }
219 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
220 if ( $pathOnly !== '' && substr($pathOnly, 0, 1) !== '/' ) {
221 $pathOnly = '/' . $pathOnly;
222 }
223 if ( $pathOnly === '' ) {
224 $pathOnly = '/';
225 }
226
227 $stringToMatch = $pathOnly;
228
229 $kept = true;
230 foreach ($regexExclusions as $pattern) {
231 $patternToExcludeNoSlashes = stripslashes($pattern);
232 $matches = array();
233
234 if ($this->f->regexMatch($patternToExcludeNoSlashes, $stringToMatch, $matches)) {
235 unset($permalinks[$key]);
236 $this->logger->debugMessage("Regex excluded suggestion. Key: " . $key .
237 ", Path: '" . esc_html($stringToMatch) . "', Pattern: '" . esc_html($patternToExcludeNoSlashes) . "'");
238 $kept = false;
239 break;
240 }
241 }
242
243 if ($kept) {
244 $suggestionsKeptSoFar++;
245 }
246 if ($suggestionsKeptSoFar >= $maxCacheCount) {
247 break;
248 }
249 }
250
251 return $permalinks;
252 }
253
254 /**
255 * @param mixed $typeConstant
256 * @return string|null
257 */
258 private function mapTypeConstantToString($typeConstant) {
259 if (!defined('ABJ404_TYPE_POST')) define('ABJ404_TYPE_POST', 1);
260 if (!defined('ABJ404_TYPE_CAT')) define('ABJ404_TYPE_CAT', 2);
261 if (!defined('ABJ404_TYPE_TAG')) define('ABJ404_TYPE_TAG', 3);
262
263 $typeConstantStr = is_scalar($typeConstant) ? (string)$typeConstant : '';
264 switch ($typeConstantStr) {
265 case ABJ404_TYPE_POST:
266 return 'pages';
267 case ABJ404_TYPE_TAG:
268 return 'tags';
269 case ABJ404_TYPE_CAT:
270 return 'categories';
271 default:
272 return null;
273 }
274 }
275
276 /**
277 * @param array<string, string> $permalinks
278 * @param string $requestedURLCleaned
279 * @param string $fullURLspacesCleaned
280 * @param string $rowType
281 * @return array<string, string>
282 */
283 function matchOnCats(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
284
285 $rows = $this->contentRepository->getPublishedCategories();
286 $rows = $this->urlMatcher->getOnlyIDandTermID($rows);
287
288 $likelyMatchIDsAndPermalinks = $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, 'categories', $rows);
289 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
290
291 $options = $this->logic->getOptions();
292 $suggestMaxRaw = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
293 $suggestMax = absint($suggestMaxRaw);
294 $topKScores = new SplMinHeap();
295 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
296
297 foreach ($likelyMatchIDs as $id) {
298 $the_permalink = $this->urlMatcher->getPermalink((int)$id, 'categories');
299 $urlParts = parse_url(is_string($the_permalink) ? $the_permalink : '');
300 if (!is_array($urlParts) || !isset($urlParts['path'])) {
301 continue;
302 }
303 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
304 $scoreBasis = $this->f->strlen($pathOnly);
305 if ($scoreBasis == 0) {
306 continue;
307 }
308
309 if ($topKScores->count() >= $suggestMax) {
310 $worstAcceptableScore = $topKScores->top();
311
312 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
313 $pathOnlyLength = $this->f->strlen($pathOnly);
314 $minPossibleDistance = abs($requestedURLCleanedLength - $pathOnlyLength);
315
316 if ($minPossibleDistance > $maxAllowedLevenshtein) {
317 continue;
318 }
319 }
320
321 $levscore = $this->levenshteinEngine->customLevenshtein($requestedURLCleaned, $pathOnly);
322
323 if ($fullURLspacesCleaned != '') {
324 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
325 if ($tentativeScore < 95) {
326 $pathOnlySpaces = $this->f->str_replace($this->separatingCharacters, " ", $pathOnly);
327 $pathOnlySpaces = trim($this->f->str_replace('/', " ", $pathOnlySpaces));
328 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($fullURLspacesCleaned, $pathOnlySpaces));
329 }
330 }
331
332 $onlyLastPart = $this->urlMatcher->getLastURLPart($pathOnly);
333 if ($onlyLastPart != '' && $onlyLastPart != $pathOnly) {
334 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($requestedURLCleaned, $onlyLastPart));
335 }
336
337 $score = 100 - (($levscore / $scoreBasis) * 100);
338 $permalinks[$id . "|" . ABJ404_TYPE_CAT] = number_format($score, 4, '.', '');
339
340 $topKScores->insert($score);
341 if ($topKScores->count() > $suggestMax) {
342 $topKScores->extract();
343 }
344 }
345
346 return $permalinks;
347 }
348
349 /**
350 * @param array<string, string> $permalinks
351 * @param string $requestedURLCleaned
352 * @param string $fullURLspacesCleaned
353 * @param string $rowType
354 * @return array<string, string>
355 */
356 function matchOnTags(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
357
358 $rows = $this->contentRepository->getPublishedTags();
359 $rows = $this->urlMatcher->getOnlyIDandTermID($rows);
360
361 $likelyMatchIDsAndPermalinks = $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, 'tags', $rows);
362 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
363
364 $options = $this->logic->getOptions();
365 $suggestMaxRawT = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
366 $suggestMax = absint($suggestMaxRawT);
367 $topKScores = new SplMinHeap();
368 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
369
370 foreach ($likelyMatchIDs as $id) {
371 $the_permalink = $this->urlMatcher->getPermalink((int)$id, 'tags');
372 $urlParts = parse_url(is_string($the_permalink) ? $the_permalink : '');
373 if (!is_array($urlParts) || !isset($urlParts['path'])) {
374 continue;
375 }
376 $pathOnly = $this->logic->removeHomeDirectory($urlParts['path']);
377 $scoreBasis = $this->f->strlen($pathOnly);
378 if ($scoreBasis == 0) {
379 continue;
380 }
381
382 if ($topKScores->count() >= $suggestMax) {
383 $worstAcceptableScore = $topKScores->top();
384
385 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
386 $pathOnlyLength = $this->f->strlen($pathOnly);
387 $minPossibleDistance = abs($requestedURLCleanedLength - $pathOnlyLength);
388
389 if ($minPossibleDistance > $maxAllowedLevenshtein) {
390 continue;
391 }
392 }
393
394 $levscore = $this->levenshteinEngine->customLevenshtein($requestedURLCleaned, $pathOnly);
395
396 if ($fullURLspacesCleaned != '') {
397 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
398 if ($tentativeScore < 95) {
399 $pathOnlySpaces = $this->f->str_replace($this->separatingCharacters, " ", $pathOnly);
400 $pathOnlySpaces = trim($this->f->str_replace('/', " ", $pathOnlySpaces));
401 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($fullURLspacesCleaned, $pathOnlySpaces));
402 }
403 }
404 $score = 100 - (($levscore / $scoreBasis) * 100);
405 $permalinks[$id . "|" . ABJ404_TYPE_TAG] = number_format($score, 4, '.', '');
406
407 $topKScores->insert($score);
408 if ($topKScores->count() > $suggestMax) {
409 $topKScores->extract();
410 }
411 }
412
413 return $permalinks;
414 }
415
416 /**
417 * @param array<string, string> $permalinks
418 * @param string $requestedURLRaw
419 * @param string $requestedURLCleaned
420 * @param string $fullURLspacesCleaned
421 * @param string $rowType
422 * @return array<string, string>
423 */
424 function matchOnPosts(array $permalinks, string $requestedURLRaw, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
425
426 $likelyMatchIDsAndPermalinks = $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspacesCleaned, $rowType);
427 $likelyMatchIDs = array_keys($likelyMatchIDsAndPermalinks);
428
429 $this->logger->debugMessage("Found " . count($likelyMatchIDs) . " likely match IDs.");
430
431 $options = $this->logic->getOptions();
432 $suggestMaxRawP = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
433 $suggestMax = absint($suggestMaxRawP);
434 $topKScores = new SplMinHeap();
435 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
436
437 while (count($likelyMatchIDs) > 0) {
438 $id = array_shift($likelyMatchIDs);
439
440 $the_permalink = $likelyMatchIDsAndPermalinks[$id];
441 $thePermalinkStr = is_string($the_permalink) ? $the_permalink : '';
442 $urlParts = parse_url($thePermalinkStr);
443 if (!is_array($urlParts) || !isset($urlParts['path'])) {
444 continue;
445 }
446 $existingPageURL = $this->logic->removeHomeDirectory($urlParts['path']);
447 $existingPageURLSpaces = $this->f->str_replace($this->separatingCharacters, " ", $existingPageURL);
448
449 $existingPageURLCleaned = $this->urlMatcher->getLastURLPart($existingPageURLSpaces);
450 $scoreBasis = $this->f->strlen($existingPageURLCleaned) * 3;
451 if ($scoreBasis == 0) {
452 continue;
453 }
454
455 if ($topKScores->count() >= $suggestMax) {
456 $worstAcceptableScore = $topKScores->top();
457
458 $maxAllowedLevenshtein = ((100 - $worstAcceptableScore) * $scoreBasis) / 100;
459
460 $existingURLCleanedLength = $this->f->strlen($existingPageURLCleaned);
461 $minPossibleDistance = abs($requestedURLCleanedLength - $existingURLCleanedLength);
462
463 if ($minPossibleDistance > $maxAllowedLevenshtein) {
464 continue;
465 }
466 }
467
468 $levscore = $this->levenshteinEngine->customLevenshtein($requestedURLCleaned, $existingPageURLCleaned);
469
470 if ($fullURLspacesCleaned != '') {
471 $tentativeScore = 100 - (($levscore / $scoreBasis) * 100);
472 if ($tentativeScore < 95) {
473 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($fullURLspacesCleaned, $existingPageURLCleaned));
474 }
475 }
476
477 if ($rowType == 'image') {
478 $strippedImageName = $this->f->regexReplace('(.+)([-]\d{1,5}[x]\d{1,5})([.].+)',
479 '\\1\\3', $requestedURLRaw);
480
481 if (($strippedImageName != null) && ($strippedImageName != $requestedURLRaw)) {
482 $strippedImageName = $this->f->str_replace($this->separatingCharactersForImages, " ", $strippedImageName);
483 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($strippedImageName, $existingPageURL));
484
485 $strippedImageName = $this->urlMatcher->getLastURLPart($strippedImageName);
486 $levscore = min($levscore, $this->levenshteinEngine->customLevenshtein($strippedImageName, $existingPageURLCleaned));
487 }
488 }
489 $score = 100 - (($levscore / $scoreBasis) * 100);
490 $permalinks[$id . "|" . ABJ404_TYPE_POST] = number_format($score, 4, '.', '');
491
492 $topKScores->insert($score);
493 if ($topKScores->count() > $suggestMax) {
494 $topKScores->extract();
495 }
496 }
497
498 return $permalinks;
499 }
500
501 }
502