import type { DiffResult } from "./diff"; import type { ChangeClass } from "./taxonomy"; /** * Semantic diff (spec §21–22, §55). Two jobs: * 1. `classifyChange` — decide what KIND of change this is (cosmetic / navigation / timestamp / * advertisement / boilerplate = noise; meaningful / pricing / policy / product / personnel = signal) * so that footer-year bumps and "3 min read" counters never become events. * 2. `extractFieldChanges` — turn a raw diff into field-level "before → after" pairs (price $20 → $25, * context window 128k → 256k, status beta → GA, CEO A → B) that the UI shows as WHAT changed. * Everything here is deterministic and dependency-free (browser-safe). */ export interface FieldChange { label: string; kind: "price" | "percent" | "number" | "date" | "version" | "status" | "text"; before: string | null; after: string | null; deltaPct?: number | null; } export interface SemanticResult { class: ChangeClass; /** 0–1 confidence in the class */ confidence: number; reasons: string[]; contentLines: number; noiseLines: number; /** share of changed lines that are noise */ noiseRatio: number; fieldChanges: FieldChange[]; } // ---- Line classifiers ----------------------------------------------------------------------- const TS_PREFIX = "(?:(?:updated|last updated|published|posted|modified|generated|as of|effective|revised|date)\\s*:?\\s*)?"; const TS_ISO = "\\d{4}-\\d{2}-\\d{2}(?:[t ]\\d{2}:\\d{2}(?::\\d{2})?(?:\\.\\d+)?(?:z|[+-]\\d{2}:?\\d{2})?)?"; const TS_MONTH = "(?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*,?\\s+)?(?:\\d{1,2}\\s+)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}(?:\\s+\\d{1,2}:\\d{2}(?::\\d{2})?\\s*(?:am|pm|utc|gmt|est|edt|pst|pdt|cet|cest)?)?"; const TS_CLOCK = "\\d{1,2}:\\d{2}(?::\\d{2})?\\s*(?:am|pm|utc|gmt)?"; const TS_REL = "\\d+\\s+(?:seconds?|minutes?|mins?|hours?|days?|weeks?|months?)\\s+ago"; const TS_WORD = "(?:today|yesterday|just now)"; const RE_TIMESTAMP_ONLY = new RegExp(`^\\s*${TS_PREFIX}(?:${TS_ISO}|${TS_MONTH}|${TS_CLOCK}|${TS_REL}|${TS_WORD})\\s*$`, "i"); const RE_READ_TIME = /^\s*\d+\s*(?:min|minute)s?\s*read\s*$/i; const RE_COUNTER = /^\s*[\d.,]+\s*[kKmM]?\s*(?:views?|likes?|shares?|comments?|followers?|stars?|forks?|downloads?|reactions?|votes?|points?|replies)?\s*$/i; const RE_COPYRIGHT = /©|\(c\)\s*\d{4}|\bcopyright\b|\ball rights reserved\b|\btous droits réservés\b/i; const RE_YEAR_ONLY = /^\s*(?:19|20)\d{2}(?:\s*[-–]\s*(?:19|20)\d{2})?\s*$/; const RE_COOKIE = /\bcookies?\b|\bconsent\b|privacy (?:preferences|settings|choices)|accept all|reject all|manage preferences|gdpr|ccpa|do not sell/i; const RE_ADVERT = /\b(?:sponsored|advertisement|advertising|promoted|promo code|coupon|limited time offer|black friday|cyber monday|save \d+%|newsletter|subscribe now|sign up for our|join our mailing|get the app|download the app|utm_|doubleclick|adsbygoogle|taboola|outbrain)\b/i; const RE_NAV_WORD = /^(?:home|menu|search|login|log in|sign in|sign up|register|subscribe|share|print|email|back to top|skip to (?:main )?content|close|next|previous|prev|read more|learn more|more|see all|view all|show more|show less|open menu|close menu|toggle navigation|about|about us|contact|contact us|careers|blog|news|pricing|docs|documentation|support|help|faq|terms|privacy|legal|sitemap|english|français|español|deutsch|en|fr|es|de|follow us|twitter|x|facebook|linkedin|youtube|instagram|github|rss|download|products?|solutions?|resources?|company|partners?|customers?|developers?|community|events?|press|investors?|status|changelog|api|login\/register|accessibility|cookie settings|feedback)$/i; const RE_SOCIAL = /\b(?:tweet|share on (?:x|twitter|facebook|linkedin)|copy link|whatsapp|telegram)\b/i; const RE_BREADCRUMB = /^(?:[\w .'&-]{1,30}\s*[›>/»]\s*){1,6}[\w .'&-]{1,40}$/; type LineClass = "timestamp" | "navigation" | "advertisement" | "boilerplate" | "content"; export function classifyLine(line: string): LineClass { const l = line.trim(); if (!l || l.length < 3) return "boilerplate"; if (RE_YEAR_ONLY.test(l) || RE_COPYRIGHT.test(l) || RE_COOKIE.test(l)) return "boilerplate"; if (RE_TIMESTAMP_ONLY.test(l) || RE_READ_TIME.test(l) || RE_COUNTER.test(l)) return "timestamp"; if (RE_ADVERT.test(l) && l.length < 160) return "advertisement"; if (RE_NAV_WORD.test(l) || RE_SOCIAL.test(l) || (l.length < 80 && RE_BREADCRUMB.test(l) && !/\d/.test(l))) return "navigation"; // very short label-like fragments in a nav-like burst are handled at the diff level return "content"; } /** True when two lines differ only by whitespace, punctuation, case or quotes. */ export function isCosmeticPair(before: string, after: string): boolean { const norm = (s: string): string => s.toLowerCase().replace(/[\s ]+/g, " ").replace(/[“”"'`´‘’«»]/g, "").replace(/[.,;:!?…\-–—()[\]{}]/g, "").trim(); return norm(before) === norm(after); } /** True when the only difference between two lines is a timestamp / counter / relative time token. */ export function isTimestampPair(before: string, after: string): boolean { const strip = (s: string): string => s.replace(/\d{4}-\d{2}-\d{2}(?:[t ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?z?)?/gi, "␣").replace(/\b\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm|utc|gmt)?/gi, "␣").replace(/\b\d+\s+(?:seconds?|minutes?|mins?|hours?|days?|weeks?)\s+ago/gi, "␣").replace(/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/gi, "␣").replace(/\b\d{1,2}\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{4}/gi, "␣").replace(/\b[\d.,]+\s*[km]?\s*(?:views?|likes?|shares?|comments?|followers?|stars?|forks?|downloads?)\b/gi, "␣"); const a = strip(before); const b = strip(after); return a === b && a !== before; } // ---- Field extraction --------------------------------------------------------------------------- const MONEY = /(?:(?:US|CA|AU|NZ|HK|SG)?\$|€|£|¥|₹|CHF\s?|USD\s?|CAD\s?|EUR\s?|GBP\s?)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:k|m|bn?|million|billion|trillion))?(?:\s?(?:\/|per)\s?(?:1k|1m|million|m|k)?\s?(?:tokens?|month|mo|year|yr|seat|user|hour|hr|gb|tb|request|call|image|minute|min))?/gi; const PERCENT = /-?\d+(?:\.\d+)?\s?%/g; const VERSION = /\bv?\d+\.\d+(?:\.\d+){0,2}(?:[-.](?:alpha|beta|rc|preview|dev|nightly|lts)\.?\d*)?\b/gi; const DATE = /\b(?:\d{4}-\d{2}-\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}|\d{1,2}\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{4})\b/gi; const NUMBER = /\b\d[\d,]*(?:\.\d+)?\s?(?:k|m|b|bn|million|billion|trillion|gb|tb|mb|ms|s|tokens?|rpm|tpm|req\/s|qps|users?|seats?|cores?|vcpus?|nodes?|regions?|countries|employees|people|jobs|positions)?\b/gi; const STATUS_WORDS = /\b(?:beta|alpha|preview|public preview|private preview|early access|general availability|generally available|ga|stable|production|deprecated|sunset|retired|discontinued|end of life|eol|coming soon|waitlist|available|unavailable|limited|paused|suspended|resolved|investigating|identified|monitoring|degraded|operational|partial outage|major outage|under maintenance|active|inactive|open|closed|approved|rejected|pending|recalled|withdrawn|terminated|completed|recruiting|not yet recruiting|enrolling)\b/gi; function toNumber(s: string): number | null { const m = s.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/); if (!m) return null; let n = Number(m[0]); const l = s.toLowerCase(); if (/\b(?:bn|billion)\b|(?<=\d)b\b/.test(l)) n *= 1e9; else if (/\bmillion\b|(?<=\d)\s?m\b/.test(l) && !/\bms\b|\bmb\b/.test(l)) n *= 1e6; else if (/(?<=\d)\s?k\b/.test(l)) n *= 1e3; return Number.isFinite(n) ? n : null; } function deltaPct(before: string | null, after: string | null): number | null { if (!before || !after) return null; const a = toNumber(before); const b = toNumber(after); if (a === null || b === null || a === 0) return null; return Math.round(((b - a) / Math.abs(a)) * 1000) / 10; } /** Words immediately before a token → a human label ("Input tokens", "Context window"). */ function labelFor(line: string, token: string, fallback: string): string { const i = line.indexOf(token); const head = (i > 0 ? line.slice(0, i) : line.slice(i + token.length)).replace(/[:\-–—|]+\s*$/, "").trim(); const words = head.split(/\s+/).filter(Boolean); const label = words.slice(-6).join(" ").replace(/^[^\p{L}\p{N}]+/u, ""); return (label.length >= 3 ? label : fallback).slice(0, 60); } function pairTokens(before: string, after: string, re: RegExp, kind: FieldChange["kind"], out: FieldChange[], seen: Set): void { const b = [...before.matchAll(re)].map((m) => m[0].trim()); const a = [...after.matchAll(re)].map((m) => m[0].trim()); if (!b.length && !a.length) return; const bs = new Set(b); const as = new Set(a); const gone = b.filter((x) => !as.has(x)); const fresh = a.filter((x) => !bs.has(x)); if (!gone.length && !fresh.length) return; const n = Math.max(gone.length, fresh.length); for (let i = 0; i < Math.min(n, 4); i++) { const bef = gone[i] ?? null; const aft = fresh[i] ?? null; const key = `${kind}|${bef}|${aft}`; if (seen.has(key)) continue; seen.add(key); const label = labelFor(aft ? after : before, aft ?? bef ?? "", kind === "price" ? "Price" : kind === "percent" ? "Rate" : kind === "version" ? "Version" : kind === "date" ? "Date" : kind === "status" ? "Status" : "Value"); out.push({ label, kind, before: bef, after: aft, deltaPct: kind === "price" || kind === "number" || kind === "percent" ? deltaPct(bef, aft) : null }); } } export function extractFieldChanges(diff: DiffResult, max = 12): FieldChange[] { const out: FieldChange[] = []; const seen = new Set(); if (diff.kind === "text") { // Pair removed/added lines that share a "Label:" prefix (the line differ only pairs similar strings, // so "Status: beta" → "Status: general availability" arrives as one removal + one addition). const labelled = (lines: string[]): Map => { const m = new Map(); for (const l of lines) { const x = l.match(/^\s*([^:]{2,40}):\s*(.{1,120})$/); if (x && !m.has(x[1]!.trim().toLowerCase())) m.set(x[1]!.trim().toLowerCase(), l.trim()); } return m; }; const rem = labelled(diff.removed); const add = labelled(diff.added); const pairs: { before: string; after: string }[] = [...diff.modified]; for (const [k, b] of rem) { const a = add.get(k); if (a && a !== b) pairs.push({ before: b, after: a }); } for (const m of pairs) { if (isCosmeticPair(m.before, m.after) || isTimestampPair(m.before, m.after)) continue; pairTokens(m.before, m.after, MONEY, "price", out, seen); pairTokens(m.before, m.after, PERCENT, "percent", out, seen); pairTokens(m.before, m.after, VERSION, "version", out, seen); pairTokens(m.before, m.after, DATE, "date", out, seen); pairTokens(m.before, m.after, STATUS_WORDS, "status", out, seen); // plain numbers only when nothing more specific was found on this pair if (!out.some((f) => f.kind !== "text" && (m.before.includes(f.before ?? "") || m.after.includes(f.after ?? "")))) pairTokens(m.before, m.after, NUMBER, "number", out, seen); if (out.length >= max) break; } // Whole-line replacements that are short and look like a labelled value ("CEO: Jane Doe") if (out.length < max) { for (const m of pairs) { const lb = m.before.match(/^([^:]{2,40}):\s*(.{1,80})$/); const la = m.after.match(/^([^:]{2,40}):\s*(.{1,80})$/); if (lb && la && lb[1]!.trim().toLowerCase() === la[1]!.trim().toLowerCase() && lb[2] !== la[2]) { const key = `text|${lb[2]}|${la[2]}`; if (seen.has(key)) continue; seen.add(key); out.push({ label: lb[1]!.trim().slice(0, 60), kind: "text", before: lb[2]!.trim(), after: la[2]!.trim(), deltaPct: null }); if (out.length >= max) break; } } } } else if (diff.kind === "json") { for (const c of diff.changes) { if (/(timestamp|updated_at|updatedAt|generated|nonce|etag|request_id|_id$|token|cache|expires|ttl|lastModified|last_modified)/i.test(c.path)) continue; const b = c.before === undefined ? null : typeof c.before === "string" ? c.before : JSON.stringify(c.before); const a = c.after === undefined ? null : typeof c.after === "string" ? c.after : JSON.stringify(c.after); if (b !== null && a !== null && b.length > 200 && a.length > 200) continue; const num = typeof c.before === "number" && typeof c.after === "number"; const kind: FieldChange["kind"] = num ? "number" : b && a && MONEY.test(b + a) ? "price" : b && a && STATUS_WORDS.test(a) ? "status" : "text"; MONEY.lastIndex = 0; STATUS_WORDS.lastIndex = 0; const label = c.path.replace(/^\$\.?/, "").split(".").slice(-2).join(" · ").replace(/\[\d+\]/g, "").replace(/[_-]+/g, " ") || "value"; out.push({ label: label.slice(0, 60), kind, before: b, after: a, deltaPct: num ? deltaPct(String(c.before), String(c.after)) : null }); if (out.length >= max) break; } } else { for (const m of diff.modified) { for (const f of m.fields) { const b = m.before[f]; const a = m.after[f]; const bs = b === undefined || b === null ? null : typeof b === "string" ? b : JSON.stringify(b); const as = a === undefined || a === null ? null : typeof a === "string" ? a : JSON.stringify(a); if (bs === as) continue; const label = `${String(m.after.title ?? m.after.name ?? m.key).slice(0, 40)} · ${f}`; const kind: FieldChange["kind"] = typeof b === "number" && typeof a === "number" ? "number" : as && STATUS_WORDS.test(as) ? "status" : "text"; STATUS_WORDS.lastIndex = 0; out.push({ label, kind, before: bs && bs.length > 160 ? bs.slice(0, 157) + "…" : bs, after: as && as.length > 160 ? as.slice(0, 157) + "…" : as, deltaPct: kind === "number" ? deltaPct(String(b), String(a)) : null }); if (out.length >= max) return out; } } } return out.slice(0, max); } // ---- Change classification ------------------------------------------------------------------------ const PRICING_WORDS = /\b(price|pricing|per (?:million|1k|1m) tokens|\$\s?\d|€\s?\d|£\s?\d|usd|cad|eur|per month|per seat|per user|\/mo\b|billing|discount|free tier|rate card|fee|fees|subscription|plan)\b/i; const POLICY_WORDS = /\b(policy|policies|terms of (?:service|use)|terms and conditions|acceptable use|privacy policy|guidelines|code of conduct|license|licence|eula|agreement|compliance|gdpr|data processing|retention|prohibited|must not|may not|shall)\b/i; const PERSONNEL_WORDS = /\b(ceo|cfo|cto|coo|cio|ciso|chief \w+ officer|president|chair(?:man|woman|person)?|director|vice president|vp\b|head of|appointed|appointment|steps? down|resign|joins|joined|hired|departure|leadership|board member|founder|general manager|managing director)\b/i; const PRODUCT_WORDS = /\b(launch|introducing|now available|new model|model|version|release|feature|api|sdk|endpoint|context window|parameters|benchmark|availability|preview|beta|general availability|deprecat|sunset|discontinu|end of life|region|integration|update)\b/i; export function classifyChange(diff: DiffResult, ctx: { url: string; sensorType: string; title?: string | null }): SemanticResult { const reasons: string[] = []; let content = 0; const noise: Record, number> = { timestamp: 0, navigation: 0, advertisement: 0, boilerplate: 0 }; let cosmetic = 0; const contentText: string[] = []; if (diff.kind === "text") { for (const m of diff.modified) { if (isCosmeticPair(m.before, m.after)) { cosmetic++; continue; } if (isTimestampPair(m.before, m.after)) { noise.timestamp++; continue; } const c = classifyLine(m.after); if (c === "content") { content++; contentText.push(m.before, m.after); } else noise[c]++; } for (const l of [...diff.added, ...diff.removed]) { const c = classifyLine(l); if (c === "content") { content++; contentText.push(l); } else noise[c]++; } // A burst of many short lines with no sentence = navigation/template churn (site redesign). const shortFragments = [...diff.added, ...diff.removed].filter((l) => l.trim().length < 28 && !/[.!?]/.test(l)).length; if (shortFragments >= 12 && shortFragments / Math.max(1, diff.added.length + diff.removed.length) > 0.6) { reasons.push("burst of short label-like fragments (template/navigation churn)"); noise.navigation += Math.round(shortFragments * 0.5); content = Math.max(0, content - Math.round(shortFragments * 0.5)); } } else if (diff.kind === "json") { for (const c of diff.changes) { if (/(timestamp|updated_at|updatedAt|generated|nonce|etag|request_id|_id$|token|cache|expires|ttl|lastModified|last_modified|servedAt|fetchedAt|\bdate$)/i.test(c.path)) noise.timestamp++; else { content++; contentText.push(`${c.path}: ${JSON.stringify(c.before ?? "")} → ${JSON.stringify(c.after ?? "")}`); } } } else { content = diff.added.length + diff.removed.length + diff.modified.length; for (const i of [...diff.added, ...diff.modified.map((m) => m.after)]) contentText.push(String(i.title ?? ""), String(i.summary ?? "")); for (const i of diff.removed) contentText.push(String(i.title ?? i.url ?? i.key)); } const noiseTotal = noise.timestamp + noise.navigation + noise.advertisement + noise.boilerplate + cosmetic; const total = content + noiseTotal; const noiseRatio = total ? noiseTotal / total : 1; const fieldChanges = extractFieldChanges(diff); const corpus = `${ctx.title ?? ""}\n${contentText.join("\n")}`.slice(0, 20_000); let cls: ChangeClass; let confidence: number; if (total === 0) { cls = "cosmetic"; confidence = 0.9; reasons.push("no visible change after canonicalization"); } else if (content === 0 || (content <= 1 && noiseRatio >= 0.75 && !fieldChanges.some((f) => f.kind === "price" || f.kind === "status"))) { const top = (Object.entries(noise) as [Exclude, number][]).sort((a, b) => b[1] - a[1])[0]; cls = cosmetic >= (top?.[1] ?? 0) ? "cosmetic" : (top?.[0] ?? "boilerplate"); confidence = 0.85; reasons.push(`all ${total} changed line(s) are ${cls === "cosmetic" ? "punctuation/whitespace/case" : cls} noise`); } else { // signal — pick the most specific sub-class const money = fieldChanges.filter((f) => f.kind === "price").length; const pricingHits = (corpus.match(new RegExp(PRICING_WORDS.source, "gi")) ?? []).length; const policyHits = (corpus.match(new RegExp(POLICY_WORDS.source, "gi")) ?? []).length; const personnelHits = (corpus.match(new RegExp(PERSONNEL_WORDS.source, "gi")) ?? []).length; const productHits = (corpus.match(new RegExp(PRODUCT_WORDS.source, "gi")) ?? []).length; const urlPricing = /pricing|price|plans?\b|billing/i.test(ctx.url); const urlPolicy = /terms|tos\b|polic|privacy|legal|license|acceptable-use|eula/i.test(ctx.url); const scores: [ChangeClass, number][] = [ ["pricing", money * 3 + pricingHits + (urlPricing ? 3 : 0)], ["policy", policyHits * 1.2 + (urlPolicy ? 3 : 0)], ["personnel", personnelHits * 1.5], ["product", productHits * 0.8 + fieldChanges.filter((f) => f.kind === "version" || f.kind === "status").length * 2], ]; scores.sort((a, b) => b[1] - a[1]); const [best, bestScore] = scores[0]!; if (bestScore >= 3) { cls = best; confidence = Math.min(0.95, 0.55 + bestScore / 20); reasons.push(`${best} vocabulary dominates (${bestScore.toFixed(1)})`); } else { cls = "meaningful"; confidence = Math.min(0.9, 0.5 + content / 20) * (1 - noiseRatio * 0.5); } if (noiseRatio > 0.5) reasons.push(`${Math.round(noiseRatio * 100)}% of changed lines are template noise`); if (fieldChanges.length) reasons.push(`${fieldChanges.length} field-level change(s) extracted`); } return { class: cls, confidence: Math.round(confidence * 100) / 100, reasons, contentLines: content, noiseLines: noiseTotal, noiseRatio: Math.round(noiseRatio * 100) / 100, fieldChanges }; } /** Compact, human-readable "what changed" line built from field changes (used in titles/summaries). */ export function describeFieldChanges(f: FieldChange[], max = 3): string { return f .slice(0, max) .map((x) => `${x.label}: ${x.before ?? "∅"} → ${x.after ?? "∅"}${x.deltaPct !== null && x.deltaPct !== undefined ? ` (${x.deltaPct > 0 ? "+" : ""}${x.deltaPct}%)` : ""}`) .join(" · "); }