PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.13, at includes/SpellChecker.php

411 lines 15.6 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 set_transient($transientKey, array(
300 'status' => 'complete',
301 'suggestions' => $permalinksPacket,
302 'url' => $normalizedURL,
303 'completed' => time()
304 ), 300); // 5 minute TTL
305
306 $this->logger->debugMessage("Cached spell-check suggestions for shortcode: " .
307 esc_html($normalizedURL));
308 }
309
310 /**
311 * Trigger asynchronous suggestion computation via non-blocking HTTP request.
312 * Uses the requested URL (MD5 hashed) as the transient key.
313 *
314 * @param string $requestedURL The full requested URL that caused the 404
315 * @return bool True if computation was triggered, false if already pending/complete
316 */
317 public function triggerAsyncSuggestionComputation($requestedURL) {
318 $f = abj_service('functions');
319
320 // Normalize URL using centralized function for consistency
321 $normalizedURL = $f->normalizeURLForCacheKey($requestedURL);
322
323 $urlKey = md5($normalizedURL);
324 $transientKey = 'abj404_suggest_' . $urlKey;
325
326 // Check if already computing or complete - prevent duplicate work
327 $existing = get_transient($transientKey);
328 if ($existing !== false) {
329 $existingStatus = (is_array($existing) && isset($existing['status']) && is_string($existing['status'])) ? $existing['status'] : 'unknown';
330 $this->logger->debugMessage("Async suggestions: skipping, transient already exists for " .
331 esc_html($normalizedURL) . " (status: " . esc_html($existingStatus) . ")");
332 return false;
333 }
334
335 // Generate a unique token for this computation request
336 // This prevents unauthorized direct calls to the AJAX endpoint (DoS protection)
337 $token = wp_generate_password(32, false);
338
339 // Mark as pending BEFORE firing request (race condition protection)
340 // TTL of 120 seconds: gives slow hosts enough time to start the worker
341 // Note: started=0 means no worker has claimed the work yet. The first worker
342 // will set started=time() when it claims the work. This prevents the bug where
343 // the first worker skips itself thinking another worker is already computing.
344 set_transient($transientKey, array(
345 'status' => 'pending',
346 'url' => $normalizedURL,
347 'started' => 0, // 0 = no worker has claimed yet; worker sets time() when claiming
348 'created' => time(), // track creation time to detect worker no-show
349 'token' => $token
350 ), 120); // 2 minute TTL (allows slow wp_remote_post)
351
352 $this->logger->debugMessage("Async suggestions: triggering background computation for " .
353 esc_html($normalizedURL));
354
355 // Fire non-blocking request to compute suggestions
356 // Note: timeout of 5s is needed for connection establishment (TLS handshake, etc.)
357 // even with blocking=false, a too-short timeout can prevent the request from being sent
358 $response = wp_remote_post(admin_url('admin-ajax.php'), array(
359 'blocking' => false,
360 'timeout' => 5, // 5 seconds for connection establishment
361 'sslverify' => apply_filters('https_local_ssl_verify', false),
362 'body' => array(
363 'action' => 'abj404_compute_suggestions',
364 'url' => $normalizedURL,
365 'token' => $token
366 )
367 ));
368
369 // If dispatch failed, delete the pending transient so caller can compute synchronously
370 if (is_wp_error($response)) {
371 $this->logger->debugMessage("Async suggestions: dispatch failed for " .
372 esc_html($normalizedURL) . " - " . $response->get_error_message());
373 delete_transient($transientKey);
374 return false;
375 }
376
377 return true;
378 }
379
380 /**
381 * Check if the configured 404 page contains the suggestions shortcode.
382 *
383 * @return bool True if 404 page has the shortcode
384 */
385 public function does404PageHaveSuggestionsShortcode() {
386 $options = $this->logic->getOptions();
387 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
388 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : null;
389
390 if (!$this->logic->thereIsAUserSpecified404Page($dest404page)) {
391 return false;
392 }
393
394 // Extract page ID from dest404page (format: "123|1")
395 $parts = explode('|', $dest404page ?? '');
396 $page404Id = isset($parts[0]) ? intval($parts[0]) : 0;
397
398 if ($page404Id <= 0) {
399 return false;
400 }
401
402 $page = get_post($page404Id);
403 if (!$page) {
404 return false;
405 }
406
407 return has_shortcode($page->post_content, ABJ404_SHORTCODE_NAME);
408 }
409
410 }
411