PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / engine / UrlFixEngine.php

UrlFixEngine.php in 404 Solution 4.3.0, at includes/engine/UrlFixEngine.php

156 lines 5.6 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 applies common URL fixes and checks if the corrected URL
9 * resolves to a real page.
10 *
11 * Handles structural URL issues that add too much edit distance for the Spelling
12 * engine's Levenshtein matching:
13 * - File extensions: /about.html → /about
14 * - Trailing punctuation: /about. → /about (from copy-paste in emails)
15 *
16 * Runs early in the pipeline (after exact Slug, before Title/Spelling) since each
17 * fix is an O(1) slug lookup — much cheaper than keyword or Levenshtein matching.
18 */
19 class ABJ_404_Solution_UrlFixEngine implements ABJ_404_Solution_MatchingEngine {
20
21 /**
22 * Common file extensions to strip, ordered by frequency.
23 * @var array<int, string>
24 */
25 private static $extensionsToStrip = [
26 '.html',
27 '.htm',
28 '.php',
29 '.asp',
30 '.aspx',
31 '.shtml',
32 '.jsp',
33 '.cfm',
34 ];
35
36 /**
37 * Trailing punctuation characters commonly appended by email clients
38 * and word processors when URLs appear at the end of sentences.
39 * @var string
40 */
41 private static $trailingPunctuation = '.,;:!?)>';
42
43 /** @var ABJ_404_Solution_SpellChecker */
44 private $spellChecker;
45
46 /** @var ABJ_404_Solution_Functions */
47 private $f;
48
49 /** @var ABJ_404_Solution_Logging */
50 private $logger;
51
52 /**
53 * @param ABJ_404_Solution_SpellChecker $spellChecker
54 * @param ABJ_404_Solution_Functions $f
55 * @param ABJ_404_Solution_Logging $logger
56 */
57 public function __construct(
58 ABJ_404_Solution_SpellChecker $spellChecker,
59 ABJ_404_Solution_Functions $f,
60 ABJ_404_Solution_Logging $logger
61 ) {
62 $this->spellChecker = $spellChecker;
63 $this->f = $f;
64 $this->logger = $logger;
65 }
66
67 /** @return string */
68 public function getName(): string {
69 return __('url fix', '404-solution');
70 }
71
72 /** @param ABJ_404_Solution_MatchRequest $request */
73 public function shouldRun(ABJ_404_Solution_MatchRequest $request): bool {
74 return $request->getUrlSlugOnly() !== '';
75 }
76
77 /** @param ABJ_404_Solution_MatchRequest $request */
78 public function match(ABJ_404_Solution_MatchRequest $request): ?ABJ_404_Solution_MatchResult {
79 $slug = $request->getUrlSlugOnly();
80 $candidates = $this->generateFixedCandidates($slug);
81
82 foreach ($candidates as $fixDescription => $fixedSlug) {
83 $permalink = $this->spellChecker->getPermalinkUsingSlug($fixedSlug);
84
85 if (!empty($permalink)) {
86 $id = isset($permalink['id']) && is_scalar($permalink['id']) ? (string)$permalink['id'] : '';
87 $type = isset($permalink['type']) && is_scalar($permalink['type']) ? (string)$permalink['type'] : '';
88 $link = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
89 $title = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
90 $score = isset($permalink['score']) && is_scalar($permalink['score']) ? (float)$permalink['score'] : 0.0;
91
92 $this->logger->debugMessage("URL fix engine: matched via " . $fixDescription .
93 " ('" . $slug . "' → '" . $fixedSlug . "')");
94
95 return new ABJ_404_Solution_MatchResult($id, $type, $link, $title, $score, $this->getName());
96 }
97 }
98
99 return null;
100 }
101
102 /**
103 * Generate fixed URL slug candidates by applying common transformations.
104 *
105 * Each candidate is keyed by a human-readable description of the fix applied.
106 * Only candidates that differ from the original slug are included.
107 *
108 * @param string $slug
109 * @return array<string, string> fix description => fixed slug
110 */
111 private function generateFixedCandidates(string $slug): array {
112 $candidates = [];
113
114 // 1. Strip file extensions
115 $slugLower = $this->f->strtolower($slug);
116 foreach (self::$extensionsToStrip as $ext) {
117 $extLen = strlen($ext);
118 if (strlen($slugLower) > $extLen && substr($slugLower, -$extLen) === $ext) {
119 $fixed = substr($slug, 0, -$extLen);
120 if ($fixed !== '' && $fixed !== $slug) {
121 $candidates['strip extension ' . $ext] = $fixed;
122 }
123 break; // Only strip one extension
124 }
125 }
126
127 // 2. Strip trailing punctuation (one character at a time, up to 3 chars)
128 $stripped = $slug;
129 $puncCount = 0;
130 while ($stripped !== '' && $puncCount < 3 && strpos(self::$trailingPunctuation, substr($stripped, -1)) !== false) {
131 $stripped = substr($stripped, 0, -1);
132 $puncCount++;
133 }
134 if ($stripped !== '' && $stripped !== $slug) {
135 $candidates['strip trailing punctuation'] = $stripped;
136 }
137
138 // 3. Combined: extension + trailing punctuation (e.g., "/about.html.")
139 if (isset($candidates['strip trailing punctuation'])) {
140 $strippedLower = $this->f->strtolower($stripped);
141 foreach (self::$extensionsToStrip as $ext) {
142 $extLen = strlen($ext);
143 if (strlen($strippedLower) > $extLen && substr($strippedLower, -$extLen) === $ext) {
144 $combined = substr($stripped, 0, -$extLen);
145 if ($combined !== '' && $combined !== $slug && !in_array($combined, $candidates, true)) {
146 $candidates['strip punctuation + extension ' . $ext] = $combined;
147 }
148 break;
149 }
150 }
151 }
152
153 return $candidates;
154 }
155 }
156