import type { PageClassification, PageType } from "@src/shared"; import type { DomSnapshot } from "./dom/PageSummarizer.ts"; /** A URL-pattern hint supplied by a platform adapter (§58). */ export interface PageTypeHint { pattern: RegExp; // tested against pathname + search page_type: PageType; confidence: number; } /** * Generic page classifier combining URL hints, DOM landmarks and content density (§58–§60). * Returns UNKNOWN with low confidence rather than throwing — unknown pages are expected. */ export function classifyPage(snapshot: DomSnapshot, hints: PageTypeHint[] = []): PageClassification { const signals: string[] = []; let url: URL | undefined; try { url = new URL(snapshot.url); } catch { /* ignore */ } const scores = new Map(); const bump = (t: PageType, c: number, why: string) => { scores.set(t, Math.max(scores.get(t) ?? 0, c)); signals.push(`${why}→${t}`); }; if (snapshot.has_login_form) bump("LOGIN", 0.9, "password field"); if (url) { const target = url.pathname + url.search; for (const h of hints) if (h.pattern.test(target)) bump(h.page_type, h.confidence, `url:${h.pattern.source.slice(0, 30)}`); if (target === "/" || target === "") bump("HOME_FEED", 0.6, "root path"); if (/search|results|\?q=|\?search_query=/i.test(target)) bump("SEARCH_RESULTS", 0.7, "search in url"); } const links = snapshot.candidates.filter((c) => c.kind === "link"); const articles = snapshot.candidates.filter((c) => c.kind === "article"); const mainLinks = links.filter((c) => c.region === "main" || c.region === "feed").length; const hasVideo = snapshot.video_elements.some((v) => v.width > 0 || v.duration); const commentsRegion = snapshot.candidates.some((c) => c.region === "comments"); if (hasVideo && snapshot.video_elements.length === 1 && snapshot.landmarks.length > 2) bump("VIDEO_DETAIL", 0.6, "single video element"); if (commentsRegion && (hasVideo || articles.length <= 2)) bump(hasVideo ? "VIDEO_DETAIL" : "POST_DETAIL", 0.55, "comments region"); if (articles.length >= 5 && !hasVideo) bump("HOME_FEED", 0.5, `${articles.length} articles`); if (mainLinks >= 15 && articles.length >= 5 && scores.get("SEARCH_RESULTS") === undefined) bump("HOME_FEED", 0.45, "dense feed"); if (snapshot.candidates.some((c) => c.kind === "search" && c.visible) && snapshot.candidates.some((c) => c.kind === "heading" && /result|résultat/i.test(c.text))) bump("SEARCH_RESULTS", 0.65, "results heading"); if (scores.size === 0) return { page_type: "UNKNOWN", confidence: 0.2, signals: ["no signal"] }; const [best, conf] = [...scores.entries()].sort((a, b) => b[1] - a[1])[0]!; return { page_type: best, confidence: Math.min(0.99, conf), signals: signals.slice(0, 12) }; }