PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / SpellChecker.php

SpellChecker.php in 404 Solution 4.1.19, at includes/SpellChecker.php

423 lines 15.9 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 require_once __DIR__ . '/SpellCheckerTrait_PostListeners.php';
9 require_once __DIR__ . '/SpellCheckerTrait_URLMatching.php';
10 require_once __DIR__ . '/SpellCheckerTrait_CandidateFiltering.php';
11 require_once __DIR__ . '/SpellCheckerTrait_LevenshteinEngine.php';
12
13 /* Finds similar pages.
14 * Finds search suggestions. */
15
16 class ABJ_404_Solution_SpellChecker {
17
18 use SpellCheckerTrait_PostListeners,
19 SpellCheckerTrait_URLMatching,
20 SpellCheckerTrait_CandidateFiltering,
21 SpellCheckerTrait_LevenshteinEngine;
22
23 /** @var array<int, string> */
24 private array $separatingCharacters = array("-","_",".","~",'%20');
25
26 /** Same as above except without the period (.) because of the extension in the file name.
27 * @var array<int, string> */
28 private array $separatingCharactersForImages = array("-","_","~",'%20');
29
30 private ?ABJ_404_Solution_PublishedPostsProvider $publishedPostsProvider = null;
31
32 const MAX_DIST = 2083;
33
34 /** Upper bound for the length-based distance buckets used to pre-filter candidates. */
35 const MAX_LIKELY_DISTANCE = 300;
36
37 /** Similarity threshold for N-gram prefiltering (lower = more candidates, slower but safer). */
38 const NGRAM_PREFILTER_THRESHOLD = 0.3;
39
40 /** Maximum candidates to retrieve during N-gram prefiltering. */
41 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
42
43 /** Minimum N-gram cache entries required to enable prefiltering.
44 * Small sites don't need prefiltering; this also prevents use during partial cache builds. */
45 const NGRAM_MIN_CACHE_ENTRIES = 50;
46
47 /** Similarity threshold for secondary N-gram filtering (higher = stricter, fewer candidates).
48 * More conservative than prefilter since we're refining an already-filtered list. */
49 const NGRAM_SECONDARY_THRESHOLD = 0.4;
50
51 /** Maximum candidates for secondary N-gram filtering. */
52 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
53
54 /** Minimum cache coverage ratio (ngram entries / permalink entries) to trust prefiltering.
55 * 0.8 = require at least 80% of permalink cache entries to be in N-gram cache. */
56 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
57
58 /** Minimum candidate count to trigger secondary N-gram filtering.
59 * Below this threshold, Levenshtein on all candidates is fast enough. */
60 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
61
62 private static ?self $instance = null;
63
64 // Performance counters (for testing efficiency - disabled by default)
65 private bool $enablePerformanceCounters = false;
66
67 // When true, skip the N-gram gate 4 early return so the full Levenshtein
68 // scan runs. The async page-suggestions worker sets this because the
69 // 5-second scan is acceptable in a background process.
70 private bool $skipNgramGate4 = false;
71 private int $levenshteinCallCount = 0;
72 private int $totalPagesConsidered = 0;
73
74 /** @var string|int|null */
75 private $custom404PageID = null;
76
77 /** Prepared regex pattern cache for the current request lifecycle.
78 * @var array<string, string> */
79 private array $preparedRegexPatternCache = array();
80
81 /** @var ABJ_404_Solution_Functions */
82 private $f;
83
84 /** @var ABJ_404_Solution_PluginLogic */
85 private $logic;
86
87 /** @var ABJ_404_Solution_DataAccess */
88 private $dao;
89
90 /** @var ABJ_404_Solution_Logging */
91 private $logger;
92
93 /** @var ABJ_404_Solution_PermalinkCache */
94 private $permalinkCache;
95
96 /** @var ABJ_404_Solution_NGramFilter */
97 private $ngramFilter;
98
99 /**
100 * Constructor with dependency injection.
101 * Dependencies are now explicit and visible.
102 *
103 * @param ABJ_404_Solution_Functions|null $functions String manipulation utilities
104 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
105 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
106 * @param ABJ_404_Solution_Logging|null $logging Logging service
107 * @param ABJ_404_Solution_PermalinkCache|null $permalinkCache Permalink caching service
108 * @param ABJ_404_Solution_NGramFilter|null $ngramFilter N-gram filter for optimization
109 */
110 public function __construct($functions = null, $pluginLogic = null, $dataAccess = null, $logging = null, $permalinkCache = null, $ngramFilter = null) {
111 // Use injected dependencies or fall back to getInstance() for backward compatibility
112 $this->f = $functions !== null ? $functions : abj_service('functions');
113 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
114 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
115 $this->logger = $logging !== null ? $logging : abj_service('logging');
116 $this->permalinkCache = $permalinkCache !== null ? $permalinkCache : abj_service('permalink_cache');
117 $this->ngramFilter = $ngramFilter !== null ? $ngramFilter : abj_service('ngram_filter');
118
119 // Set the custom 404 page id if there is one
120 $options = $this->logic->getOptions();
121 $custom404PageIDRaw =
122 (is_array($options) && isset($options['dest404page']) ?
123 $options['dest404page'] : null);
124 $custom404PageID = is_string($custom404PageIDRaw) ? $custom404PageIDRaw : (is_int($custom404PageIDRaw) ? (string)$custom404PageIDRaw : null);
125 if ($this->logic->thereIsAUserSpecified404Page($custom404PageID)) {
126 $this->custom404PageID = $custom404PageID;
127 }
128 }
129
130 public static function getInstance(): self {
131 if (self::$instance !== null) {
132 return self::$instance;
133 }
134
135 // If the DI container is initialized, prefer it.
136 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
137 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('spell_checker');
138 if ($resolved instanceof self) {
139 self::$instance = $resolved;
140 return self::$instance;
141 }
142 }
143
144 self::$instance = new ABJ_404_Solution_SpellChecker();
145
146 return self::$instance;
147 }
148
149 /**
150 * Enable performance counters for testing efficiency (disabled by default for production)
151 */
152 public function enablePerformanceCounters(bool $enable = true): void {
153 $this->enablePerformanceCounters = $enable;
154 if ($enable) {
155 $this->resetPerformanceCounters();
156 }
157 }
158
159 /**
160 * Skip the N-gram gate 4 early return so the full Levenshtein scan runs.
161 * Used by the async page-suggestions worker where the scan time is acceptable.
162 */
163 public function setSkipNgramGate4(bool $skip = true): void {
164 $this->skipNgramGate4 = $skip;
165 }
166
167 /**
168 * Reset performance counters to zero
169 */
170 public function resetPerformanceCounters(): void {
171 $this->levenshteinCallCount = 0;
172 $this->totalPagesConsidered = 0;
173 }
174
175 /**
176 * Get current performance counter values
177 * @return array{levenshtein_calls: int, pages_considered: int, efficiency_percent: float}
178 */
179 public function getPerformanceCounters(): array {
180 $efficiency = 0;
181 if ($this->totalPagesConsidered > 0) {
182 $efficiency = ($this->levenshteinCallCount / $this->totalPagesConsidered) * 100;
183 }
184
185 return [
186 'levenshtein_calls' => $this->levenshteinCallCount,
187 'pages_considered' => $this->totalPagesConsidered,
188 'efficiency_percent' => round($efficiency, 2)
189 ];
190 }
191
192 /**
193 * Find URL suggestions using smart caching (N-gram filtering).
194 * This is a wrapper around findMatchingPosts() primarily for testing.
195 *
196 * @param string $requestedURL The 404 URL to find matches for
197 * @param string $includeCats Whether to include categories (default '1')
198 * @param bool $includeTags Whether to include tags (default true, converted to '1')
199 * @return array<int, mixed> Array of matching posts/pages
200 */
201 public function findSuggestionsForURLUsingSmartCache($requestedURL, $includeCats = '1', $includeTags = true) {
202 // Convert boolean to string for backward compatibility
203 $includeTagsStr = $includeTags ? '1' : '0';
204 return $this->findMatchingPosts($requestedURL, $includeCats, $includeTagsStr);
205 }
206
207 static function init(): void {
208 // any time a page is saved or updated, or the permalink structure changes, then we have to clear
209 // the spelling cache because the results may have changed.
210 $me = abj_service('spell_checker');
211
212 add_action('updated_option', array($me,'permalinkStructureChanged'), 10, 2);
213 add_action('save_post', array($me,'save_postListener'), 10, 3);
214 add_action('delete_post', array($me,'delete_postListener'), 10, 2);
215 }
216
217 /** Find a match using spell checking.
218 * Use spell checking to find the correct link. Return the permalink (map) if there is one, otherwise return null.
219 * @param string $requestedURL The URL slug to check for spelling matches
220 * @param string|null $fullRequestedURL Optional full URL path for caching results (e.g., '/site/bad-url')
221 * @param array<string, mixed>|null $optionsOverride
222 * @return array<string, mixed>|null
223 */
224 function getPermalinkUsingSpelling(string $requestedURL, ?string $fullRequestedURL = null, $optionsOverride = null) {
225 $abj404spellChecker = abj_service('spell_checker');
226
227 $options = is_array($optionsOverride) ? $optionsOverride : $this->logic->getOptions();
228
229 if (@$options['auto_redirects'] == '1') {
230 // Site owner wants automatic redirects.
231 $autoCats = isset($options['auto_cats']) && is_string($options['auto_cats']) ? $options['auto_cats'] : '1';
232 $autoTags = isset($options['auto_tags']) && is_string($options['auto_tags']) ? $options['auto_tags'] : '1';
233 $permalinksPacket = $abj404spellChecker->findMatchingPosts($requestedURL,
234 $autoCats, $autoTags);
235
236 $permalinks = $permalinksPacket[0];
237 $rowType = $permalinksPacket[1];
238
239 $minScore = $options['auto_score'];
240
241 // since the links were previously sorted so that the highest score would be first,
242 // we only use the first element of the array;
243 if (!is_array($permalinks) || empty($permalinks)) {
244 return null;
245 }
246 $linkScore = reset($permalinks);
247 $idAndType = key($permalinks);
248 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
249 $linkScoreInt = is_scalar($linkScore) ? (int)$linkScore : 0;
250 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndTypeStr, $linkScoreInt,
251 is_string($rowType) ? $rowType : null, $options);
252
253 if ($permalink['score'] >= $minScore) {
254 // We found a permalink that will work!
255 $redirectType = $permalink['type'];
256 if (('' . $redirectType != ABJ404_TYPE_404_DISPLAYED) && ('' . $redirectType != ABJ404_TYPE_HOME)) {
257 return $permalink;
258
259 } else {
260 $permalinkJson = json_encode($permalink);
261 $this->logger->errorMessage("Unhandled permalink type: " .
262 wp_kses_post(is_string($permalinkJson) ? $permalinkJson : '{}'));
263 return null;
264 }
265 }
266
267 // No match met the auto-redirect threshold - cache results for shortcode
268 // This avoids recomputing suggestions when the 404 page renders
269 if ($fullRequestedURL !== null) {
270 $this->cacheComputedSuggestionsForShortcode($fullRequestedURL, $permalinksPacket);
271 }
272 }
273
274 return null;
275 }
276
277 /**
278 * Cache computed suggestions in a transient for the shortcode to use.
279 * This avoids duplicate computation when getPermalinkUsingSpelling() runs
280 * but doesn't find a match above the auto-redirect threshold.
281 *
282 * @param string $fullRequestedURL The full URL path (e.g., '/site/bad-url')
283 * @param array<int, mixed> $permalinksPacket The computed suggestions [permalinks, rowType]
284 */
285 private function cacheComputedSuggestionsForShortcode(string $fullRequestedURL, array $permalinksPacket): void {
286 // Normalize URL using centralized function for consistency
287 $normalizedURL = $this->f->normalizeURLForCacheKey($fullRequestedURL);
288
289 $urlKey = md5($normalizedURL);
290 $transientKey = 'abj404_suggest_' . $urlKey;
291
292 // Don't overwrite if already set (e.g., by async trigger)
293 $existing = get_transient($transientKey);
294 if ($existing !== false) {
295 return;
296 }
297
298 // Store as 'complete' so shortcode renders immediately.
299 // No token here: this writer is the synchronous fallback path
300 // and the consumer never re-enters the worker token gate.
301 // allow-cache-empty: factory-built typed array; SuggestionTransient::completeArray
302 // always returns a non-empty associative array with at minimum a 'status' key.
303 set_transient(
304 $transientKey,
305 ABJ_404_Solution_SuggestionTransient::completeArray(
306 $normalizedURL,
307 $permalinksPacket,
308 time(),
309 ''
310 ),
311 300
312 ); // 5 minute TTL
313
314 $this->logger->debugMessage("Cached spell-check suggestions for shortcode: " .
315 esc_html($normalizedURL));
316 }
317
318 /**
319 * Trigger asynchronous suggestion computation via non-blocking HTTP request.
320 * Uses the requested URL (MD5 hashed) as the transient key.
321 *
322 * @param string $requestedURL The full requested URL that caused the 404
323 * @return bool True if computation was triggered, false if already pending/complete
324 */
325 public function triggerAsyncSuggestionComputation($requestedURL) {
326 $f = abj_service('functions');
327
328 // Normalize URL using centralized function for consistency
329 $normalizedURL = $f->normalizeURLForCacheKey($requestedURL);
330
331 $urlKey = md5($normalizedURL);
332 $transientKey = 'abj404_suggest_' . $urlKey;
333
334 // Check if already computing or complete, prevent duplicate work
335 $existing = ABJ_404_Solution_SuggestionTransient::fromRaw(get_transient($transientKey));
336 if ($existing !== null) {
337 $this->logger->debugMessage("Async suggestions: skipping, transient already exists for " .
338 esc_html($normalizedURL) . " (status: " . esc_html($existing->getStatus()) . ")");
339 return false;
340 }
341
342 // Generate a unique token for this computation request.
343 // This prevents unauthorized direct calls to the AJAX endpoint (DoS protection).
344 $token = wp_generate_password(32, false);
345
346 // Mark as pending BEFORE firing request (race condition protection).
347 // TTL of 120 seconds gives slow hosts enough time to start the worker.
348 // Note: started=0 means no worker has claimed the work yet. The first worker
349 // will set started=time() when it claims the work. This prevents the bug where
350 // the first worker skips itself thinking another worker is already computing.
351 // allow-cache-empty: factory-built typed array; pendingArray always returns a
352 // non-empty associative array with at minimum a 'status' key.
353 set_transient(
354 $transientKey,
355 ABJ_404_Solution_SuggestionTransient::pendingArray(
356 $normalizedURL,
357 $token,
358 0, // no worker has claimed yet
359 time() // track creation time to detect worker no-show
360 ),
361 120
362 ); // 2 minute TTL (allows slow wp_remote_post)
363
364 $this->logger->debugMessage("Async suggestions: triggering background computation for " .
365 esc_html($normalizedURL));
366
367 // Fire non-blocking request to compute suggestions
368 // Note: timeout of 5s is needed for connection establishment (TLS handshake, etc.)
369 // even with blocking=false, a too-short timeout can prevent the request from being sent
370 $response = wp_remote_post(admin_url('admin-ajax.php'), array(
371 'blocking' => false,
372 'timeout' => 5, // 5 seconds for connection establishment
373 'sslverify' => apply_filters('https_local_ssl_verify', false),
374 'body' => array(
375 'action' => 'abj404_compute_suggestions',
376 'url' => $normalizedURL,
377 'token' => $token
378 )
379 ));
380
381 // If dispatch failed, delete the pending transient so caller can compute synchronously
382 if (is_wp_error($response)) {
383 $this->logger->debugMessage("Async suggestions: dispatch failed for " .
384 esc_html($normalizedURL) . " - " . $response->get_error_message());
385 delete_transient($transientKey);
386 return false;
387 }
388
389 return true;
390 }
391
392 /**
393 * Check if the configured 404 page contains the suggestions shortcode.
394 *
395 * @return bool True if 404 page has the shortcode
396 */
397 public function does404PageHaveSuggestionsShortcode() {
398 $options = $this->logic->getOptions();
399 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
400 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : null;
401
402 if (!$this->logic->thereIsAUserSpecified404Page($dest404page)) {
403 return false;
404 }
405
406 // Extract page ID from dest404page (format: "123|1")
407 $parts = explode('|', $dest404page ?? '');
408 $page404Id = isset($parts[0]) ? intval($parts[0]) : 0;
409
410 if ($page404Id <= 0) {
411 return false;
412 }
413
414 $page = get_post($page404Id);
415 if (!$page) {
416 return false;
417 }
418
419 return has_shortcode($page->post_content, ABJ404_SHORTCODE_NAME);
420 }
421
422 }
423