/** * Incremental crawl diff — compute stable content hashes and skip unchanged screens. * * Each crawled page gets a content fingerprint (murmurhash-free, pure JS). * On the next crawl of the same project, pages with the same fingerprint are * marked SKIPPED instead of re-processed, drastically reducing crawl time and * LLM token spend for sites with mostly-static content. * * Design: "same content hash" means the meaningful text hasn't changed. * Layout/CSS/JS changes that don't affect visible text are ignored intentionally. */ export interface ContentHash { url: string; hash: string; textLength: number; computedAt: string; // ISO timestamp } export interface DiffResult { url: string; changed: boolean; previousHash?: string; currentHash: string; reason: 'new' | 'changed' | 'unchanged'; } /** * Compute a stable 64-bit-ish hash from text content. * djb2 variant — fast, no deps, collision-resistant enough for content fingerprinting. */ export function hashContent(text: string): string { let h1 = 5381; let h2 = 52711; for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = (Math.imul(h1, 33) ^ c) >>> 0; h2 = (Math.imul(h2, 31) ^ c) >>> 0; } return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0'); } /** * Normalize text before hashing — strips whitespace-only lines, trims each line, * collapses runs of blank lines. Makes hash stable across minor formatting changes. */ export function normalizeText(text: string): string { return text .split('\n') .map(l => l.trim()) .filter(l => l.length > 0) .join('\n'); } export function computeContentHash(url: string, markdownText: string): ContentHash { const normalized = normalizeText(markdownText); return { url, hash: hashContent(normalized), textLength: normalized.length, computedAt: new Date().toISOString(), }; } /** * Compare a page's current content against prior hashes. * Returns { changed: false } for unchanged pages — caller should skip re-processing. */ export function diffContent( url: string, currentMarkdown: string, priorHashes: Map, // url → hash ): DiffResult { const current = computeContentHash(url, currentMarkdown); const prior = priorHashes.get(url); if (!prior) { return { url, changed: true, currentHash: current.hash, reason: 'new' }; } if (prior === current.hash) { return { url, changed: false, previousHash: prior, currentHash: current.hash, reason: 'unchanged' }; } return { url, changed: true, previousHash: prior, currentHash: current.hash, reason: 'changed' }; } /** * Build a hash map from prior screen records (from DB query result). * screenRecords: Array<{ url: string; elementHash?: string | null }> */ export function buildPriorHashMap(screenRecords: Array<{ url: string; elementHash?: string | null }>): Map { const map = new Map(); for (const s of screenRecords) { if (s.url && s.elementHash) map.set(s.url, s.elementHash); } return map; } /** * Filter a batch of discovered URLs against prior hashes. * Returns { toProcess, toSkip } — caller enqueues toProcess, logs toSkip count. * * Note: filtering at URL level only works if the prior crawl already visited the URL. * For new URLs, they're always in toProcess. */ export function partitionByChange( urls: string[], priorHashes: Map, ): { toProcess: string[]; toSkip: string[] } { const toProcess: string[] = []; const toSkip: string[] = []; for (const url of urls) { if (priorHashes.has(url)) { // Can't know if changed without fetching — still needs fetch, but hash after fetch // will determine skip. Include all — hash check happens post-fetch. toProcess.push(url); } else { toProcess.push(url); } } return { toProcess, toSkip }; }