Forked from bakit/crawler
Forked from bakit/crawler
src / utils / relevance.ts
import { BLOCKED_DOMAINS, BLOCKED_EXTENSIONS, STOP_WORDS } from "./constants";
const NOISE_PATH_PATTERNS = [
/\/(login|signup|register|cart|checkout|account|profile)\b/i,
/\/(wp-admin|wp-login|xmlrpc|wp-content|wp-includes)\//i,
/\/(cgi-bin|admin|administrator)\//i,
];
export function isNoiseUrl(url: string, query: string): boolean {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
if (BLOCKED_DOMAINS.has(host)) return true;
if (BLOCKED_EXTENSIONS.some(ext => parsed.pathname.toLowerCase().endsWith(ext))) return true;
if (NOISE_PATH_PATTERNS.some(p => p.test(parsed.pathname))) return true;
return false;
} catch {
return true;
}
}
export const MIN_RELEVANCE_SCORE = 8;
function buildWordRegex(word: string): RegExp {
return new RegExp(`\\b${word}\\b`, "i");
}
/**
* BM25-inspired relevance scoring with word-boundary matching and frequency awareness.
*/
export function pageRelevanceScore(query: string, title: string, content: string): number {
const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 1 && !STOP_WORDS.has(w));
if (queryWords.length === 0) return 100;
const firstWordRegex = buildWordRegex(queryWords[0]);
let score = 0;
// First query word scoring: title match = 50, content match = 20
if (firstWordRegex.test(title)) {
score += 50;
} else if (firstWordRegex.test(content)) {
score += 20;
}
// Subsequent words with diminishing returns
let extraMatched = 0;
for (let i = 1; i < queryWords.length; i++) {
const wordRegex = buildWordRegex(queryWords[i]);
if (!wordRegex.test(title) && !wordRegex.test(content)) continue;
let points = 0;
if (wordRegex.test(title)) {
// Title position bonus: earlier in title = more weight
const titleMatch = title.match(wordRegex);
if (titleMatch?.index !== undefined) {
const posBonus = Math.max(0, 1 - titleMatch.index / 100);
points = 15 + Math.round(posBonus * 10); // 15-25 range
} else {
points = 15;
}
} else if (wordRegex.test(content)) {
const freq = (content.match(wordRegex) || []).length;
points = 8 + Math.min(freq, 3); // 8-11 range based on frequency
}
score += points;
extraMatched++;
if (extraMatched >= 4) break; // cap at 4 additional words
}
return Math.min(Math.round(score), 100);
}
src / utils / relevance.ts
import { BLOCKED_DOMAINS, BLOCKED_EXTENSIONS, STOP_WORDS } from "./constants";
const NOISE_PATH_PATTERNS = [
/\/(login|signup|register|cart|checkout|account|profile)\b/i,
/\/(wp-admin|wp-login|xmlrpc|wp-content|wp-includes)\//i,
/\/(cgi-bin|admin|administrator)\//i,
];
export function isNoiseUrl(url: string, query: string): boolean {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
if (BLOCKED_DOMAINS.has(host)) return true;
if (BLOCKED_EXTENSIONS.some(ext => parsed.pathname.toLowerCase().endsWith(ext))) return true;
if (NOISE_PATH_PATTERNS.some(p => p.test(parsed.pathname))) return true;
return false;
} catch {
return true;
}
}
export const MIN_RELEVANCE_SCORE = 8;
function buildWordRegex(word: string): RegExp {
return new RegExp(`\\b${word}\\b`, "i");
}
/**
* BM25-inspired relevance scoring with word-boundary matching and frequency awareness.
*/
export function pageRelevanceScore(query: string, title: string, content: string): number {
const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 1 && !STOP_WORDS.has(w));
if (queryWords.length === 0) return 100;
const firstWordRegex = buildWordRegex(queryWords[0]);
let score = 0;
// First query word scoring: title match = 50, content match = 20
if (firstWordRegex.test(title)) {
score += 50;
} else if (firstWordRegex.test(content)) {
score += 20;
}
// Subsequent words with diminishing returns
let extraMatched = 0;
for (let i = 1; i < queryWords.length; i++) {
const wordRegex = buildWordRegex(queryWords[i]);
if (!wordRegex.test(title) && !wordRegex.test(content)) continue;
let points = 0;
if (wordRegex.test(title)) {
// Title position bonus: earlier in title = more weight
const titleMatch = title.match(wordRegex);
if (titleMatch?.index !== undefined) {
const posBonus = Math.max(0, 1 - titleMatch.index / 100);
points = 15 + Math.round(posBonus * 10); // 15-25 range
} else {
points = 15;
}
} else if (wordRegex.test(content)) {
const freq = (content.match(wordRegex) || []).length;
points = 8 + Math.min(freq, 3); // 8-11 range based on frequency
}
score += points;
extraMatched++;
if (extraMatched >= 4) break; // cap at 4 additional words
}
return Math.min(Math.round(score), 100);
}