/** * shared/cache.ts — Phase 2.3 re-extraction cache * * Skips Playwright extraction if the URL content hasn't materially changed since last run. * Fingerprint = hash(URL + HTTP headers ETag/last-modified/content-length + algorithm version). * * Cache hit: ~2s (HEAD request only) vs full extraction ~140s = 99% time saved on re-runs. * * Safety: * - Atomic writes (temp file + rename) prevent corruption on partial writes * - Algorithm version baked into fingerprint = automatic bust when extractors change * - Force flag bypasses cache for explicit re-extractions * - TTL prevents stale fingerprints from blocking forever */ import { createHash } from 'crypto'; import { existsSync, readFileSync, writeFileSync, renameSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { mkdir } from 'fs/promises'; /** Algorithm version — bump when extraction logic materially changes. Bust all caches. */ export const CACHE_ALGORITHM_VERSION = '2026.05.26'; export interface CacheCheckResult { skip: boolean; reason: string; fingerprint?: string; } /** * Check if extraction should be skipped for this URL. * Performs a HEAD request to detect URL content changes via standard HTTP headers. * Returns {skip:true} when fingerprints match AND TTL not exceeded. * * @param url - Target URL to potentially re-extract * @param domain - Resolved domain (extractions// dir) * @param opts - Options * - force: bypass cache entirely (always extract) * - ttlDays: max age before re-extraction even on match (default 7) * - cacheDir: extractions root (default 'extractions') */ export async function shouldSkipExtraction( url: string, domain: string, opts: { force?: boolean; ttlDays?: number; cacheDir?: string } = {} ): Promise { if (opts.force) return { skip: false, reason: 'force flag set' }; const cacheDir = opts.cacheDir || 'extractions'; const ttlDays = opts.ttlDays ?? 7; const fpPath = join(cacheDir, domain, '.extraction-fingerprint'); const rawCssPath = join(cacheDir, domain, 'raw-css.json'); // No existing extraction → must run, but still compute fingerprint for post-extraction write if (!existsSync(rawCssPath) || !existsSync(fpPath)) { let fp: string | undefined; try { fp = await computeUrlFingerprint(url); } catch { // HEAD fail → no fingerprint (cache cannot be populated this run) } return { skip: false, reason: 'no prior extraction or fingerprint', fingerprint: fp }; } // TTL check try { const stat = statSync(fpPath); const ageMs = Date.now() - stat.mtimeMs; const ttlMs = ttlDays * 86400 * 1000; if (ageMs > ttlMs) { return { skip: false, reason: `fingerprint older than ${ttlDays}d TTL` }; } } catch { return { skip: false, reason: 'cannot stat fingerprint file' }; } // Compute current fingerprint from URL HEAD let newFp: string; try { newFp = await computeUrlFingerprint(url); } catch (e) { return { skip: false, reason: `HEAD request failed: ${(e as Error).message}` }; } // Compare with stored fingerprint try { const storedFp = readFileSync(fpPath, 'utf-8').trim(); if (storedFp === newFp) { return { skip: true, reason: 'fingerprint match (unchanged)', fingerprint: newFp }; } return { skip: false, reason: 'fingerprint differs (content or version changed)', fingerprint: newFp }; } catch { return { skip: false, reason: 'cannot read stored fingerprint' }; } } /** * Write fingerprint atomically (temp file + rename) so partial writes never corrupt cache. */ export async function writeFingerprint( domain: string, fingerprint: string, opts: { cacheDir?: string } = {} ): Promise { const cacheDir = opts.cacheDir || 'extractions'; const finalPath = join(cacheDir, domain, '.extraction-fingerprint'); const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`; await mkdir(dirname(finalPath), { recursive: true }); writeFileSync(tmpPath, fingerprint, 'utf-8'); renameSync(tmpPath, finalPath); // atomic on POSIX filesystems } /** * Compute a fingerprint from URL + HTTP headers + algorithm version. * Strategy: * 1. HEAD request → get ETag / Last-Modified / Content-Length / Content-Type * 2. Hash(URL + headers + ALGORITHM_VERSION) * 3. Returns hex string (40 chars sha1) * * Why include algorithm version: if extractors evolve (new fields, fixed bugs), cache * must invalidate even when content is identical. */ async function computeUrlFingerprint(url: string): Promise { let etag = ''; let lastMod = ''; let contentLen = ''; let contentType = ''; try { const response = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000), }); if (response.ok || response.status === 405) { // Some sites reject HEAD (405) but headers may still be informative etag = response.headers.get('etag') || ''; lastMod = response.headers.get('last-modified') || ''; contentLen = response.headers.get('content-length') || ''; contentType = response.headers.get('content-type') || ''; } } catch { // HEAD failed (CORS, timeout, network). Fall through with empty headers. } const material = [url, etag, lastMod, contentLen, contentType, CACHE_ALGORITHM_VERSION].join('|'); return createHash('sha1').update(material).digest('hex'); }