Common English stop words filtered from URL slugs. */ private static $stopWords = [ 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'your', 'have', 'will', 'been', 'they', 'their', 'what', 'when', 'where', 'which', 'there', 'about', 'would', 'could', 'should', 'into', 'than', 'then', 'them', 'these', 'those', 'does', 'done', 'also', 'just', 'more', 'most', 'much', 'very', 'some', 'only', 'over', 'such', 'each', 'were', 'here', 'after', 'before', 'between', 'under', 'through', 'being', 'other', 'like', 'not', 'but', 'are', 'was', 'all', 'any', 'can', 'had', 'her', 'his', 'how', 'its', 'may', 'our', 'own', 'who', 'you', ]; /** @var ABJ_404_Solution_ContentRepositoryInterface */ private $contentRepo; /** @var ABJ_404_Solution_Functions */ private $f; /** @var ABJ_404_Solution_Logging */ private $logger; /** @var ABJ_404_Solution_TermCandidateSource Sources cats/tags candidate rows (bounded prefilter or full scan). */ private $termCandidateSource; /** * $termCandidateSource is optional so legacy/unit construction (content * repo + functions + logger only) still works; when absent, a default * full-scan-only source is built from the content repo. Production injects * a source wired with the n-gram prefilter via the service factory. * * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo * @param ABJ_404_Solution_Functions $f * @param ABJ_404_Solution_Logging $logger * @param ABJ_404_Solution_TermCandidateSource|null $termCandidateSource */ public function __construct( ABJ_404_Solution_ContentRepositoryInterface $contentRepo, ABJ_404_Solution_Functions $f, ABJ_404_Solution_Logging $logger, $termCandidateSource = null ) { $this->contentRepo = $contentRepo; $this->f = $f; $this->logger = $logger; $this->termCandidateSource = $termCandidateSource instanceof ABJ_404_Solution_TermCandidateSource ? $termCandidateSource : new ABJ_404_Solution_TermCandidateSource($contentRepo); } /** @return string */ public function getName(): string { return __('category/tag keywords', '404-solution'); } /** @param ABJ_404_Solution_MatchRequest $request */ public function shouldRun(ABJ_404_Solution_MatchRequest $request): bool { $slug = $request->getUrlSlugOnly(); if ($slug === '') { return false; } $options = $request->getOptions(); $autoCats = isset($options['auto_cats']) ? $options['auto_cats'] : '0'; $autoTags = isset($options['auto_tags']) ? $options['auto_tags'] : '0'; if ($autoCats !== '1' && $autoTags !== '1') { return false; } // Skip date/tracking patterns (4+ consecutive digits) if (preg_match('/\d{4,}/', $slug)) { return false; } $keywords = $this->extractKeywords($slug); return count($keywords) >= self::MIN_KEYWORDS; } /** @param ABJ_404_Solution_MatchRequest $request */ public function match(ABJ_404_Solution_MatchRequest $request): ?ABJ_404_Solution_MatchResult { $options = $request->getOptions(); $autoCats = isset($options['auto_cats']) && is_string($options['auto_cats']) ? $options['auto_cats'] : '0'; $autoTags = isset($options['auto_tags']) && is_string($options['auto_tags']) ? $options['auto_tags'] : '0'; $minScore = $request->getMinScore('auto_score_category_tag'); // Phase 1: Hierarchical path match $phase1Result = $this->matchHierarchical($request, $autoCats, $minScore); if ($phase1Result !== null) { return $phase1Result; } // Phase 2: Category/tag name keyword match return $this->matchTermKeywords($request, $autoCats, $autoTags, $minScore); } /** * Phase 1: Hierarchical path resolution. * * For URLs with 2+ path segments, tries each non-last segment as a category slug. * If a category matches, searches for posts within that category whose titles * match the last segment's keywords. * * @param ABJ_404_Solution_MatchRequest $request * @param string $autoCats * @param float $minScore * @return ABJ_404_Solution_MatchResult|null */ private function matchHierarchical( ABJ_404_Solution_MatchRequest $request, string $autoCats, float $minScore ): ?ABJ_404_Solution_MatchResult { if ($autoCats !== '1') { return null; } $slug = $request->getUrlSlugOnly(); $segments = array_values(array_filter(explode('/', $slug), function ($s) { return $s !== ''; })); if (count($segments) < 2) { return null; } $lastSegment = $segments[count($segments) - 1]; $keywords = $this->extractKeywords($lastSegment); if (count($keywords) < self::MIN_KEYWORDS) { return null; } // Try each non-last segment as a category slug for ($i = 0; $i < count($segments) - 1; $i++) { $categorySlug = $this->f->strtolower($segments[$i]); $categories = $this->contentRepo->getPublishedCategories(null, $categorySlug, 1); if (empty($categories)) { continue; } $category = $categories[0]; $termId = isset($category->term_id) ? (int)$category->term_id : 0; if ($termId <= 0) { continue; } $extraWhere = $this->buildCategoryPostWhereClause($termId, $keywords); $rows = $this->contentRepo->getPublishedPagesAndPostsIDs(array( 'limit_results' => '0,' . self::QUERY_LIMIT, 'extra_where_clause' => $extraWhere['sql'], 'extra_where_params' => $extraWhere['params'], )); if (empty($rows)) { continue; } $bestScore = 0.0; $bestRow = null; foreach ($rows as $row) { $title = isset($row->post_title) && is_string($row->post_title) ? $row->post_title : ''; if ($title === '') { continue; } $score = $this->scoreText($keywords, $title); if ($score > $bestScore) { $bestScore = $score; $bestRow = $row; } } if ($bestRow !== null) { if ($bestScore >= $minScore) { $id = isset($bestRow->id) && is_scalar($bestRow->id) ? (string)$bestRow->id : ''; $title = isset($bestRow->post_title) && is_string($bestRow->post_title) ? $bestRow->post_title : ''; $link = isset($bestRow->url) && is_string($bestRow->url) ? $bestRow->url : ''; $this->logger->debugMessage("Category/tag engine Phase 1: matched post " . $id . " via category " . $categorySlug . " (score " . $bestScore . ")"); return new ABJ_404_Solution_MatchResult( $id, (string)ABJ404_TYPE_POST, $link, $title, $bestScore, $this->getName() ); } // Sibling of the spelling near miss (SpellChecker::getPermalinkUsingSpelling): // the best candidate lost only on the threshold, and that score is // what tells the admin why the URL was captured instead of // redirected. Record it before phase 1 falls through and drops it. abj_service('near_miss_recorder')->record(array( 'requestedURL' => $request->getRequestedURL(), 'score' => (float)$bestScore, 'engineName' => $this->getName())); } } return null; } /** * Phase 2: Category/tag name keyword matching. * * Extracts keywords from the full slug and scores them against all published * category and tag names. Returns the best match above the score threshold. * * @param ABJ_404_Solution_MatchRequest $request * @param string $autoCats * @param string $autoTags * @param float $minScore * @return ABJ_404_Solution_MatchResult|null */ private function matchTermKeywords( ABJ_404_Solution_MatchRequest $request, string $autoCats, string $autoTags, float $minScore ): ?ABJ_404_Solution_MatchResult { $keywords = $this->extractKeywords($request->getUrlSlugOnly()); if (count($keywords) < self::MIN_KEYWORDS) { return null; } $bestScore = 0.0; $bestTerm = null; /** @var string|null $bestType */ $bestType = null; $prefilterQuery = $request->getUrlSlugOnly(); if ($autoCats === '1') { $categories = $this->termCandidateSource->getCandidateTermRows('category', $prefilterQuery); foreach ($categories as $cat) { $name = isset($cat->name) && is_string($cat->name) ? $cat->name : ''; if ($name === '') { continue; } $score = $this->scoreText($keywords, $name); if ($score > $bestScore) { $bestScore = $score; $bestTerm = $cat; $bestType = (string)ABJ404_TYPE_CAT; } } } if ($autoTags === '1') { $tags = $this->termCandidateSource->getCandidateTermRows('tag', $prefilterQuery); foreach ($tags as $tag) { $name = isset($tag->name) && is_string($tag->name) ? $tag->name : ''; if ($name === '') { continue; } $score = $this->scoreText($keywords, $name); if ($score > $bestScore) { $bestScore = $score; $bestTerm = $tag; $bestType = (string)ABJ404_TYPE_TAG; } } } if ($bestTerm === null || $bestType === null || $bestScore < $minScore) { $this->logger->debugMessage("Category/tag engine Phase 2: no match above threshold " . $minScore . " (best score: " . $bestScore . ")"); $this->recordNearMissIfBudgetAllows($request, $bestScore); return null; } $termId = isset($bestTerm->term_id) && is_scalar($bestTerm->term_id) ? (string)$bestTerm->term_id : ''; $termName = isset($bestTerm->name) && is_string($bestTerm->name) ? $bestTerm->name : ''; $termUrl = isset($bestTerm->url) && is_string($bestTerm->url) ? $bestTerm->url : ''; $this->logger->debugMessage("Category/tag engine Phase 2: matched term " . $termId . " '" . $termName . "' (score " . $bestScore . ")"); return new ABJ_404_Solution_MatchResult( $termId, $bestType, $termUrl, $termName, $bestScore, $this->getName() ); } /** * Sibling of the spelling near miss (SpellChecker::getPermalinkUsingSpelling): * the best candidate lost only on the threshold, and that score is what * tells the admin why the URL was captured instead of redirected. Record * it before the reject branch discards it -- unless the request time * budget tripped, in which case the score is a partial-corpus best that * must not masquerade as the corpus best: skip the record. * * @param ABJ_404_Solution_MatchRequest $request * @param float $bestScore * @return void */ private function recordNearMissIfBudgetAllows(ABJ_404_Solution_MatchRequest $request, float $bestScore): void { $nearMissBudget = ABJ_404_Solution_MatchingTimeBudget::current(); if ($nearMissBudget !== null && $nearMissBudget->isExhausted()) { $this->logger->debugMessage("Category/tag engine Phase 2: time budget exhausted, skipping near-miss record"); return; } abj_service('near_miss_recorder')->record(array( 'requestedURL' => $request->getRequestedURL(), 'score' => (float)$bestScore, 'engineName' => $this->getName())); } /** * Extract meaningful keywords from a URL slug. * * @param string $slug * @return array */ private function extractKeywords(string $slug): array { $decoded = rawurldecode($slug); $normalized = preg_replace('/[-_~.\/]+/', ' ', $decoded); if (!is_string($normalized)) { return []; } $words = preg_split('/\s+/', $this->f->strtolower(trim($normalized))); if (!is_array($words)) { return []; } $stopLookup = array_flip(self::$stopWords); $keywords = []; foreach ($words as $word) { if (!is_string($word)) { continue; } if ($this->f->strlen($word) < self::MIN_WORD_LENGTH) { continue; } if (isset($stopLookup[$word])) { continue; } if (!isset($keywords[$word])) { $keywords[$word] = true; } } return array_slice(array_keys($keywords), 0, self::MAX_KEYWORDS); } /** * Score a text string by keyword overlap ratio with fuzzy matching. * * @param array $keywords * @param string $text * @return float Score from 0.0 to 100.0 */ private function scoreText(array $keywords, string $text): float { if (empty($keywords)) { return 0.0; } $textLower = $this->f->strtolower($text); $textWords = preg_split('/[\s\-_:;,!?.()\/\[\]]+/', $textLower); if (!is_array($textWords)) { return 0.0; } $filteredTextWords = array_values(array_filter($textWords, function ($w) { return is_string($w) && $w !== ''; })); $textWordSet = array_flip($filteredTextWords); $weightedMatches = 0.0; foreach ($keywords as $kw) { if (isset($textWordSet[$kw])) { $weightedMatches += 1.0; } else { $weightedMatches += $this->bestFuzzyMatch($kw, $filteredTextWords); } } return ($weightedMatches / count($keywords)) * 100.0; } /** * Find the best fuzzy match for a keyword among text words. * * @param string $keyword * @param array $textWords * @return float 0.0 or FUZZY_MATCH_WEIGHT */ private function bestFuzzyMatch(string $keyword, array $textWords): float { $kwLen = $this->f->strlen($keyword); foreach ($textWords as $tw) { $twLen = $this->f->strlen($tw); if (abs($kwLen - $twLen) > self::MAX_FUZZY_DISTANCE) { continue; } if (levenshtein($keyword, $tw) <= self::MAX_FUZZY_DISTANCE) { return self::FUZZY_MATCH_WEIGHT; } } return 0.0; } /** * Build a SQL WHERE clause that filters posts in a specific category * and matches keywords in post titles. The term id and the keywords are bound values, * not SQL text: the query executor rewrites `{wp_...}` tokens across the statement * before it binds parameters, so a value spliced into the text would be rewritten with it. * * @param int $termId * @param array $keywords * @return SqlFragment Text with `%d` / `%s` placeholders and the values for them, in order. */ private function buildCategoryPostWhereClause(int $termId, array $keywords): array { global $wpdb; $prefix = isset($wpdb->prefix) ? $wpdb->prefix : 'wp_'; $trTable = $prefix . 'term_relationships'; $ttTable = $prefix . 'term_taxonomy'; $categoryFilter = array( 'sql' => ' and wp_posts.ID IN (' . 'SELECT wtr.object_id FROM ' . $trTable . ' wtr ' . 'INNER JOIN ' . $ttTable . ' wtt ON wtt.term_taxonomy_id = wtr.term_taxonomy_id ' . 'WHERE wtt.term_id = %d' . ')', 'params' => array($termId), ); return ABJ_404_Solution_SqlFragmentTemplate::concat(array( $categoryFilter, array('sql' => ' and ', 'params' => array()), $this->buildTitleWhereClause($keywords), )); } /** * Build a SQL WHERE clause that matches any keyword in the post title. * * @param array $keywords * @return SqlFragment `(lower(wp_posts.post_title) LIKE %s OR ...)` plus the `%keyword%` patterns. */ private function buildTitleWhereClause(array $keywords): array { $needles = []; foreach ($keywords as $kw) { // Strip invalid UTF-8 before SQL. Keywords originate from // rawurldecode'd URL slugs and can carry scanner-attack bytes // (Pattern 10: binding a value does not validate its UTF-8). $needles[] = $this->f->sanitizeInvalidUTF8($this->f->strtolower($kw)); } return ABJ_404_Solution_SqlFragmentTemplate::anyLike('lower(wp_posts.post_title)', $needles); } }