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 / spelling / SpellChecker.php

SpellChecker.php in 404 Solution trunk, at includes/spelling/SpellChecker.php

434 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Finds similar pages.
9 * Finds search suggestions. */
10
11 class ABJ_404_Solution_SpellChecker {
12
13 /** @var array<int, string> */
14 private array $separatingCharacters = array("-","_",".","~",'%20');
15
16 /** Same as above except without the period (.) because of the extension in the file name.
17 * @var array<int, string> */
18 private array $separatingCharactersForImages = array("-","_","~",'%20');
19
20 const MAX_DIST = 2083;
21
22 const MAX_LIKELY_DISTANCE = 300;
23
24 const NGRAM_PREFILTER_THRESHOLD = 0.3;
25
26 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
27
28 const NGRAM_MIN_CACHE_ENTRIES = 50;
29
30 const NGRAM_SECONDARY_THRESHOLD = 0.4;
31
32 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
33
34 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
35
36 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
37
38 private static ?self $instance = null;
39
40 /** @var ABJ_404_Solution_Functions */
41 private $f;
42
43 /** @var ABJ_404_Solution_PluginLogic */
44 private $logic;
45
46 /** @var ABJ_404_Solution_NotFoundResponseService|null */
47 private $notFoundResponse;
48
49 /** @var ABJ_404_Solution_ContentRepository */
50 private $contentRepository;
51
52 /** @var ABJ_404_Solution_Logging */
53 private $logger;
54
55 /** @var ABJ_404_Solution_SpellURLMatcher */
56 private $urlMatcher;
57
58 /** @var ABJ_404_Solution_SpellLevenshteinEngine */
59 private $levenshteinEngine;
60
61 /** @var ABJ_404_Solution_SpellCandidateFilter */
62 private $candidateFilter;
63
64 /** @var ABJ_404_Solution_SpellPostListeners */
65 private $postListeners;
66
67 /** @var ABJ_404_Solution_SpellSuggestionShortcodeDetector */
68 private $shortcodeDetector;
69
70 /** @var ABJ_404_Solution_SuggestionPublisher */
71 private $suggestionPublisher;
72
73 /**
74 * @param ABJ_404_Solution_SpellCheckerDependencies|null $deps
75 */
76 public function __construct(?ABJ_404_Solution_SpellCheckerDependencies $deps = null) {
77 $deps = $deps ?? new ABJ_404_Solution_SpellCheckerDependencies();
78 $contentRepository = $deps->contentRepository;
79 $this->f = $deps->functions !== null ? $deps->functions : abj_service('functions');
80 $this->logic = $deps->pluginLogic !== null ? $deps->pluginLogic : abj_service('plugin_logic');
81 $resolvedNotFoundResponse = function_exists('abj_service_optional')
82 ? abj_service_optional('not_found_response') : null;
83 $this->notFoundResponse = $resolvedNotFoundResponse instanceof ABJ_404_Solution_NotFoundResponseService
84 ? $resolvedNotFoundResponse : null;
85 $this->contentRepository = $contentRepository !== null ? $contentRepository : abj_service('content_repository');
86 $this->logger = $deps->logging !== null ? $deps->logging : abj_service('logging');
87 $permalinkCacheResolved = $deps->permalinkCache !== null ? $deps->permalinkCache : abj_service('permalink_cache');
88 $ngramFilterResolved = $deps->ngramFilter !== null ? $deps->ngramFilter : abj_service('ngram_filter');
89 $viewReadServiceResolved = $deps->viewReadService !== null ? $deps->viewReadService :
90 (is_object($contentRepository) && method_exists($contentRepository, 'getRedirectsWithRegEx') ? $contentRepository : abj_service('view_read_service'));
91
92 $options = abj_service('options_repository')->getOptions(true);
93 $custom404PageIDRaw =
94 (is_array($options) && isset($options['dest404page']) ?
95 $options['dest404page'] : null);
96 $custom404PageID = is_string($custom404PageIDRaw) ? $custom404PageIDRaw : (is_int($custom404PageIDRaw) ? (string)$custom404PageIDRaw : null);
97 $custom404PageIDResolved = null;
98 if ($this->notFoundResponse instanceof ABJ_404_Solution_NotFoundResponseService
99 && $this->notFoundResponse->thereIsAUserSpecified404Page($custom404PageID)) {
100 $custom404PageIDResolved = $custom404PageID;
101 }
102
103 $this->urlMatcher = new ABJ_404_Solution_SpellURLMatcher(
104 $this->f, $this->logger, $this->contentRepository,
105 $viewReadServiceResolved, $custom404PageIDResolved
106 );
107
108 $this->postListeners = new ABJ_404_Solution_SpellPostListeners(
109 $this->f, $this->logger, $this->contentRepository,
110 $permalinkCacheResolved, $ngramFilterResolved
111 );
112
113 $this->levenshteinEngine = new ABJ_404_Solution_SpellLevenshteinEngine(
114 new ABJ_404_Solution_SpellLevenshteinEngineDependencies(
115 $this->f, $this->logic, $this->logger, $this->contentRepository,
116 $ngramFilterResolved, $this->urlMatcher, $this->separatingCharacters
117 )
118 );
119
120 $this->candidateFilter = new ABJ_404_Solution_SpellCandidateFilter(
121 $this->f, $this->logic, $this->logger, $this->contentRepository,
122 $this->urlMatcher, $this->levenshteinEngine, $this->postListeners,
123 $custom404PageIDResolved, $this->separatingCharacters, $this->separatingCharactersForImages
124 );
125
126 $this->shortcodeDetector = new ABJ_404_Solution_SpellSuggestionShortcodeDetector(
127 $this->notFoundResponse
128 );
129
130 $this->suggestionPublisher = new ABJ_404_Solution_SuggestionPublisher($this->logger);
131 }
132
133 /**
134 * Test seam (M105): install the cached singleton; pass null to clear it.
135 * @param self|null $instance
136 * @return void
137 */
138 public static function setInstance($instance) {
139 self::$instance = $instance;
140 }
141
142 /**
143 * Return the already-built singleton without resolving the container or
144 * building a new one, so the `spell_checker` factory can honor a
145 * test-installed override. Mirrors PluginLogic / Logging peekInstance().
146 * @return self|null
147 */
148 public static function peekInstance(): ?self {
149 return self::$instance;
150 }
151
152 public static function getInstance(): self {
153 if (self::$instance !== null) {
154 return self::$instance;
155 }
156
157 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
158 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('spell_checker');
159 if ($resolved instanceof self) {
160 self::$instance = $resolved;
161 return self::$instance;
162 }
163 }
164
165 self::$instance = new ABJ_404_Solution_SpellChecker();
166
167 return self::$instance;
168 }
169
170 public function enablePerformanceCounters(bool $enable = true): void {
171 $this->levenshteinEngine->enablePerformanceCounters($enable);
172 }
173
174 public function setSkipNgramGate4(bool $skip = true): void {
175 $this->levenshteinEngine->setSkipNgramGate4($skip);
176 }
177
178 public function resetPerformanceCounters(): void {
179 $this->levenshteinEngine->resetPerformanceCounters();
180 }
181
182 /**
183 * @return array{levenshtein_calls: int, pages_considered: int, efficiency_percent: float}
184 */
185 public function getPerformanceCounters(): array {
186 return $this->levenshteinEngine->getPerformanceCounters();
187 }
188
189 /** @return array<string, mixed>|null */
190 function getPermalinkUsingRegEx(string $requestedURL, $options = null) {
191 return $this->urlMatcher->getPermalinkUsingRegEx($requestedURL, $options);
192 }
193
194 /** @return array<string, mixed>|null */
195 function getPermalinkUsingSlug(string $requestedURL) {
196 return $this->urlMatcher->getPermalinkUsingSlug($requestedURL);
197 }
198
199 function requestIsForAnImage(string $requestedURL): bool {
200 return $this->urlMatcher->requestIsForAnImage($requestedURL);
201 }
202
203 /** @return array<int, array<string, mixed>> */
204 function getOnlyIDandTermID(array $rowsAsObject): array {
205 return $this->urlMatcher->getOnlyIDandTermID($rowsAsObject);
206 }
207
208 /** @return array<int|string, mixed> */
209 function getFromPermalinkCache(string $requestedURL): array {
210 return $this->urlMatcher->getFromPermalinkCache($requestedURL);
211 }
212
213 /**
214 * @return string|null
215 * @throws Exception
216 */
217 function getPermalink($id, $rowType) {
218 return $this->urlMatcher->getPermalink($id, $rowType);
219 }
220
221 function getLastURLPart($url) {
222 return $this->urlMatcher->getLastURLPart($url);
223 }
224
225 /** @return array<int, mixed> */
226 function findMatchingPosts(string $requestedURLRaw, string $includeCats = '1', string $includeTags = '1') {
227 return $this->candidateFilter->findMatchingPosts($requestedURLRaw, $includeCats, $includeTags);
228 }
229
230 /** @return array<string, string> */
231 function removeExcludedPages(array $options, array $permalinks): array {
232 return $this->candidateFilter->removeExcludedPages($options, $permalinks);
233 }
234
235 /** @return array<string, string> */
236 function removeExcludedPagesWithRegex(array $options, array $permalinks, int $maxCacheCount): array {
237 return $this->candidateFilter->removeExcludedPagesWithRegex($options, $permalinks, $maxCacheCount);
238 }
239
240 /** @return array<string, string> */
241 function matchOnCats(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
242 return $this->candidateFilter->matchOnCats($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
243 }
244
245 /** @return array<string, string> */
246 function matchOnTags(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
247 return $this->candidateFilter->matchOnTags($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
248 }
249
250 /** @return array<string, string> */
251 function matchOnPosts(array $permalinks, string $requestedURLRaw, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
252 return $this->candidateFilter->matchOnPosts($permalinks, $requestedURLRaw, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
253 }
254
255 /** @return array<int|string, mixed> */
256 function getLikelyMatchIDs(string $requestedURLCleaned, string $fullURLspaces, string $rowType, ?array $rows = null) {
257 return $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspaces, $rowType, $rows);
258 }
259
260 function customLevenshtein($str1, $str2) {
261 return $this->levenshteinEngine->customLevenshtein($str1, $str2);
262 }
263
264 function save_postListener($post_id, $post = null, $update = null): void {
265 // @hook-lifecycle: opt-out - delegated SpellPostListeners::save_postListener owns request-level dedup.
266 $this->postListeners->save_postListener($post_id, $post, $update);
267 }
268
269 function delete_postListener($post_id, $post = null): void {
270 $this->postListeners->delete_postListener($post_id, $post);
271 }
272
273 function term_changedListener(int $term_id, int $tt_id = 0, string $taxonomy = ''): void {
274 $this->postListeners->term_changedListener($term_id, $tt_id, $taxonomy);
275 }
276
277 function savePostHandler($post_id, $post, $update, $saveOrDelete): void {
278 $this->postListeners->savePostHandler($post_id, $post, $update, $saveOrDelete);
279 }
280
281 function permalinkStructureChanged($var1, $newStructure): void {
282 $this->postListeners->permalinkStructureChanged($var1, $newStructure);
283 }
284
285 function initializePublishedPostsProvider(): void {
286 $this->postListeners->initializePublishedPostsProvider();
287 }
288
289 /**
290 * @return array<int, mixed>
291 */
292 public function findSuggestionsForURLUsingSmartCache($requestedURL, $includeCats = '1', $includeTags = true) {
293 $includeTagsStr = $includeTags ? '1' : '0';
294 return $this->findMatchingPosts($requestedURL, $includeCats, $includeTagsStr);
295 }
296
297 static function init(): void {
298 $me = abj_service('spell_checker');
299
300 add_action('updated_option', array($me,'permalinkStructureChanged'), 10, 2);
301 add_action('save_post', array($me,'save_postListener'), 10, 3);
302 add_action('delete_post', array($me,'delete_postListener'), 10, 2);
303 // A category/tag create/rename/delete changes spelling-match results, so
304 // invalidate the spelling cache (including memoized no-match entries).
305 add_action('created_term', array($me,'term_changedListener'), 10, 3);
306 add_action('edited_term', array($me,'term_changedListener'), 10, 3);
307 add_action('delete_term', array($me,'term_changedListener'), 10, 3);
308 }
309
310 /**
311 * True only for a cached confirmed no-match: a [permalinks, rowType] packet
312 * whose permalink list is an empty array. A cache miss (empty array()), a
313 * positive result, and any malformed payload all return false so the caller
314 * recomputes -- the negative short-circuit can never emit a destination.
315 *
316 * @param mixed $cachedPacket the getFromPermalinkCache() return value
317 * @return bool
318 */
319 private function isCachedNoMatchResult($cachedPacket): bool {
320 return is_array($cachedPacket) && count($cachedPacket) === 2
321 && array_key_exists(0, $cachedPacket) && is_array($cachedPacket[0])
322 && empty($cachedPacket[0]);
323 }
324
325 /**
326 * @return array<string, mixed>|null
327 */
328 function getPermalinkUsingSpelling(string $requestedURL, ?string $fullRequestedURL = null, $optionsOverride = null) {
329 $abj404spellChecker = abj_service('spell_checker');
330
331 $options = is_array($optionsOverride) ? $optionsOverride : abj_service('options_repository')->getOptions();
332
333 if (@$options['auto_redirects'] == '1') {
334 $autoCats = isset($options['auto_cats']) && is_string($options['auto_cats']) ? $options['auto_cats'] : '1';
335 $autoTags = isset($options['auto_tags']) && is_string($options['auto_tags']) ? $options['auto_tags'] : '1';
336
337 // Negative-result memoization. A repeated 404 to a URL with no
338 // spelling match would otherwise re-run the full Levenshtein scan
339 // on every hit: captured rows are excluded from the pre-match
340 // redirect lookup (status/type filter in getPermalinkFromURL.sql),
341 // so an already-seen no-match URL never short-circuits before this
342 // point. We short-circuit ONLY on a cached no-match (an empty
343 // permalink list). The outcome is identical to recomputing a
344 // no-match (return null), so a stale entry can at worst miss a
345 // redirect (graceful 404), never cause a wrong one. The spelling
346 // cache is invalidated on any content change (SpellPostListeners:
347 // save/delete post, created/edited/deleted category or tag), so a
348 // URL that becomes matchable is recomputed on its next hit. A
349 // cached POSITIVE result is intentionally ignored and recomputed
350 // fresh so a deleted or unpublished destination can never be
351 // served from cache. Positive matches do not recur here in any
352 // case: a successful match is promoted to a stored AUTO redirect,
353 // so the next hit short-circuits at getActiveRedirectForURL.
354 $cachedPacket = $abj404spellChecker->getFromPermalinkCache($requestedURL);
355 if ($this->isCachedNoMatchResult($cachedPacket)) {
356 return null;
357 }
358
359 $permalinksPacket = $abj404spellChecker->findMatchingPosts($requestedURL,
360 $autoCats, $autoTags);
361
362 $permalinks = $permalinksPacket[0];
363 $rowType = $permalinksPacket[1];
364
365 $minScore = $options['auto_score'];
366
367 if (!is_array($permalinks) || empty($permalinks)) {
368 return null;
369 }
370 $linkScore = reset($permalinks);
371 $idAndType = key($permalinks);
372 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
373 $linkScoreInt = is_scalar($linkScore) ? (int)$linkScore : 0;
374 $permalink = ABJ_404_Solution_PermalinkResolver::permalinkInfoToArray($idAndTypeStr, $linkScoreInt,
375 is_string($rowType) ? $rowType : null, $options);
376
377 if ($permalink['score'] >= $minScore) {
378 $redirectType = $permalink['type'];
379 if (('' . $redirectType != ABJ404_TYPE_404_DISPLAYED) && ('' . $redirectType != ABJ404_TYPE_HOME)) {
380 return $permalink;
381
382 } else {
383 $permalinkJson = json_encode($permalink);
384 $this->logger->errorMessage("Unhandled permalink type: " .
385 wp_kses_post(is_string($permalinkJson) ? $permalinkJson : '{}'));
386 return null;
387 }
388 }
389
390 // A match was found and rejected only for scoring under the
391 // admin's threshold. That score is the one number that explains why
392 // this URL is about to be captured instead of redirected, so hand
393 // it to the near-miss recorder before the losing branch discards
394 // the packet; the captured-404 insert reads it back and stores it
395 // on the row (NotFoundResponseService::sendTo404Page).
396 //
397 // Keyed by the full requested URL because that is the string the
398 // pipeline carries into sendTo404Page(). Callers that pass no full
399 // URL (nothing in the frontend pipeline does) fall back to the slug,
400 // which simply will not match at read time: no score, never a wrong
401 // one.
402 abj_service('near_miss_recorder')->record(array(
403 'requestedURL' => $fullRequestedURL !== null ? $fullRequestedURL : $requestedURL,
404 'score' => is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0,
405 'engineName' => ABJ_404_Solution_SpellingMatchingEngine::engineName(),
406 ));
407
408 if ($fullRequestedURL !== null) {
409 $this->suggestionPublisher->cacheComputedSuggestionsForShortcode(
410 $fullRequestedURL, $permalinksPacket);
411 }
412 }
413
414 return null;
415 }
416
417 /**
418 * Ask for background suggestions for this URL, rolling back the pending
419 * marker if the dispatch fails. Delegates to the suggestion publisher, which
420 * owns the transient and the loopback request.
421 *
422 * @param string $requestedURL
423 * @return bool True when a background computation was dispatched.
424 */
425 public function triggerAndCleanupOnFailure(string $requestedURL): bool {
426 return $this->suggestionPublisher->triggerAsyncSuggestions($requestedURL);
427 }
428
429 public function does404PageHaveSuggestionsShortcode() {
430 return $this->shortcodeDetector->does404PageHaveSuggestionsShortcode();
431 }
432
433 }
434