import * as cheerio from "cheerio"; import { sha256, simhash } from "./hash"; /** * Canonical content extraction. Before diffing we strip rendering noise (scripts, styles, * nav/footer chrome, tracking params, timestamps generated at render time, random ids, * CSRF tokens…) so `canonical_hash` only moves when the content moves. `semantic_hash` * is a simhash of the canonical text and is robust to small reorderings. */ export interface CanonicalResult { /** Canonical text (one block per line). */ text: string; title: string | null; /** Headings (h1–h3) in order — used for section-level diffs. */ headings: string[]; /** Absolute links found in the main content. */ links: { href: string; text: string }[]; rawHash: string; canonicalHash: string; semanticHash: string; /** Structural signature: element counts by tag (used for DOM-level diff summaries). */ structure: Record; meta: Record; } const NOISE_SELECTORS = [ "script", "style", "noscript", "template", "svg", "iframe", "canvas", "video", "audio", "link", "meta", "[aria-hidden='true']", "[hidden]", ".cookie-banner", ".cookie-consent", "#cookie-banner", "#onetrust-consent-sdk", ".advertisement", ".ad-slot", "[class*='cookie']", "[id*='cookie']", "[class*='banner-consent']", "[class*='newsletter']", "[class*='social-share']", "[class*='skip-link']", ]; const CHROME_SELECTORS = ["header", "nav", "footer", "aside", "[role='navigation']", "[role='banner']", "[role='contentinfo']", ".sidebar", ".breadcrumb", ".breadcrumbs"]; const TRACKING_PARAMS = /^(utm_|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|ref|ref_src|igshid|_hs|hsa_|vero_|yclid|wickedid|oly_|s_kwcid|ncid|cmpid|_ga|_gl|spm)/i; export function stripTrackingParams(href: string, base?: string): string { try { const u = new URL(href, base); const keys = [...u.searchParams.keys()]; for (const k of keys) if (TRACKING_PARAMS.test(k)) u.searchParams.delete(k); u.hash = ""; return u.toString(); } catch { return href; } } /** Patterns that change on every render and carry no information. */ const VOLATILE_PATTERNS: RegExp[] = [ /\b\d{1,2}:\d{2}(:\d{2})?\s?(am|pm|utc|gmt|est|pst|cet)?\b/gi, // clock times /\b(generated|rendered|last updated|page last modified)\s*(on|at)?[:\s]+[^\n]{4,40}/gi, /\b[0-9a-f]{32,64}\b/gi, // hashes / tokens /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, // uuids /\b(csrf|nonce|token|session)[=:]\s*[\w-]+/gi, /\b\d{1,3}(,\d{3})*\s+(views|visitors|online|reading now|comments?)\b/gi, /\b(\d+|a few|several)\s+(seconds?|minutes?|hours?)\s+ago\b/gi, ]; export function normalizeText(text: string): string { return text .replace(/\r\n?/g, "\n") .replace(/ /g, " ") .replace(/[ \t\f\v]+/g, " ") .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0) .join("\n"); } export function scrubVolatile(text: string): string { let out = text; for (const re of VOLATILE_PATTERNS) out = out.replace(re, " "); return normalizeText(out); } const BLOCK_TAGS = new Set(["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "td", "th", "table", "blockquote", "pre", "dd", "dt", "dl", "figcaption", "main", "br", "hr", "details", "summary"]); export function canonicalizeHtml(html: string, baseUrl?: string, opts: { keepChrome?: boolean } = {}): CanonicalResult { const rawHash = sha256(html); const $ = cheerio.load(html, { xml: false }); const meta: Record = {}; $("meta").each((_, el) => { const name = $(el).attr("name") ?? $(el).attr("property"); const content = $(el).attr("content"); if (name && content && /^(description|og:title|og:description|og:type|article:published_time|article:modified_time|last-modified|generator)$/i.test(name)) meta[name.toLowerCase()] = content.trim(); }); const title = normalizeText($("title").first().text() || $("h1").first().text() || "") || null; $(NOISE_SELECTORS.join(",")).remove(); if (!opts.keepChrome) $(CHROME_SELECTORS.join(",")).remove(); // Comments $("*") .contents() .each((_, node) => { if (node.type === "comment") $(node).remove(); }); const root: cheerio.Cheerio = $("main").length ? $("main").first() : $("article").length && $("article").text().trim().length > 200 ? $("article").first() : $("body").length ? $("body") : $.root(); const headings: string[] = []; root.find("h1,h2,h3").each((_, el) => { const t = normalizeText($(el).text()); if (t) headings.push(t); }); const links: { href: string; text: string }[] = []; const seen = new Set(); root.find("a[href]").each((_, el) => { const hrefRaw = $(el).attr("href") ?? ""; if (!hrefRaw || hrefRaw.startsWith("#") || /^(javascript|mailto|tel):/i.test(hrefRaw)) return; const href = stripTrackingParams(hrefRaw, baseUrl); if (!/^https?:/i.test(href) || seen.has(href)) return; seen.add(href); links.push({ href, text: normalizeText($(el).text()).slice(0, 200) }); }); const structure: Record = {}; root.find("*").each((_, el) => { if (el.type !== "tag") return; structure[el.name] = (structure[el.name] ?? 0) + 1; }); // Text extraction with block boundaries const parts: string[] = []; const walk = (node: cheerio.Cheerio): void => { node.contents().each((_, child) => { if (child.type === "text") { const t = (child as { data?: string }).data ?? ""; if (t.trim()) parts.push(t); } else if (child.type === "tag") { const tag = (child as { name: string }).name; const isBlock = BLOCK_TAGS.has(tag); if (isBlock) parts.push("\n"); walk($(child)); if (isBlock) parts.push("\n"); } }); }; walk(root); const text = scrubVolatile(normalizeText(parts.join(""))); return { text, title, headings, links, rawHash, canonicalHash: sha256(text), semanticHash: simhash(text), structure, meta, }; } /** Deterministic JSON serialization (sorted keys) for structured feeds. */ export function canonicalJson(value: unknown): string { return JSON.stringify(sortKeys(value)); } function sortKeys(v: unknown): unknown { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === "object") { const out: Record = {}; for (const k of Object.keys(v as Record).sort()) out[k] = sortKeys((v as Record)[k]); return out; } return v; } export function canonicalizeText(text: string): { text: string; rawHash: string; canonicalHash: string; semanticHash: string } { const t = scrubVolatile(normalizeText(text)); return { text: t, rawHash: sha256(text), canonicalHash: sha256(t), semanticHash: simhash(t) }; }