| 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 |
|