import { chromium, type Browser, type BrowserContext } from "playwright"; import { join } from "node:path"; import { collectPage, type PageSnapshot, type FontFace } from "./walker.js"; import { tagElements, captureInteractions, type InteractionCapture } from "./interactions.js"; import { captureMotion, probeReveals, type MotionCapture } from "./motion.js"; import { discoverBreakpoints } from "./breakpoints.js"; import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js"; import { sha1_12, round } from "../util/canonical.js"; export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const; // The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing // law (constant / proportional / clamped / flex-distributed) far better than 4 — enough to drop a // baked px for a relative construct with confidence (and to surface a shrunk item's natural size, // which needs widths beyond 1920). REQUIRED_VIEWPORTS ⊂ this; only those 4 carry responsive bands. export const SAMPLE_VIEWPORTS = [375, 480, 640, 768, 1024, 1280, 1536, 1920, 2560] as const; const VIEWPORT_HEIGHTS: Record = { 375: 812, 480: 854, 640: 960, 768: 1024, 1024: 768, 1280: 800, 1536: 864, 1920: 1080, 2560: 1440, }; const DESKTOP_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; // Defines esbuild/tsx runtime helpers in the page so serialized evaluate // callbacks that reference them don't throw ReferenceError. const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);" + "globalThis.__defProp = globalThis.__defProp || Object.defineProperty;"; export type DiscoveredAsset = { url: string; type: string; // image|svg|video|font|lottie|css|manifest|other contentType: string | null; status: number | null; storedAs: string | null; // sha1-named file in assets-store, or null if not downloaded bytes: number; // size of stored file via: string[]; // discovery sources }; export type SeoResource = { kind: "robots" | "sitemap" | "llms" | "llms-full"; url: string; status: number | null; contentType: string | null; text?: string; }; export type CaptureResult = { sourceUrl: string; capturedAt: string; viewports: number[]; // Browser-as-oracle: the widths where the SOURCE layout actually restructures (display / // flex-direction / wrap / grid-track-count / position / visibility flips), found by sweeping the // viewport and binary-searching each discrete-signature change. Ground truth for the real // responsive band edges — distinct from REQUIRED_VIEWPORTS (our fixed capture widths). Absent if // discovery was disabled or the sweep failed. See breakpoints.ts. breakpoints?: number[]; perViewport: Array<{ viewport: number; height: number; scrollHeight: number; nodeCount: number; truncated: boolean; overlaysRemaining?: number; blocking?: boolean; quiescent?: boolean; }>; assets: DiscoveredAsset[]; seoResources?: SeoResource[]; fontFaces: FontFace[]; cssTexts: string[]; // sha1 names of stored css files // Stage 2: overlay/popup dismissal audit (union of actions across viewports). dismissal?: { dismissed: string[]; overlaysRemaining: number; removed: number; videoStills: number; blocking: boolean }; // Stage 4: optional interaction capture (hover/focus + recognized patterns). interaction?: InteractionCapture; // Stage 5: optional motion capture (WAAPI animations + rotating text). CSS @keyframes // motion is reconstructed from the IR, so it isn't re-captured here. motion?: MotionCapture; }; function viewportHeight(width: number): number { return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66); } function extFromUrl(url: string): string { try { const p = new URL(url).pathname; const dot = p.lastIndexOf("."); if (dot >= 0 && dot > p.lastIndexOf("/")) { const ext = p.slice(dot + 1).toLowerCase().slice(0, 5); if (/^[a-z0-9]+$/.test(ext)) return ext; } } catch { /* ignore */ } return ""; } const CONTENT_TYPE_EXT: Record = { "image/webp": "webp", "image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/avif": "avif", "image/gif": "gif", "image/svg+xml": "svg", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", "image/bmp": "bmp", "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "video/ogg": "ogv", "font/woff2": "woff2", "font/woff": "woff", "font/ttf": "ttf", "font/otf": "otf", "application/font-woff2": "woff2", "application/font-woff": "woff", "application/x-font-ttf": "ttf", "application/vnd.ms-fontobject": "eot", "text/css": "css", "application/json": "json", "application/manifest+json": "webmanifest", }; function extFromContentType(contentType: string | null): string { if (!contentType) return ""; const ct = contentType.split(";")[0]!.trim().toLowerCase(); return CONTENT_TYPE_EXT[ct] ?? ""; } function classifyAsset(url: string, contentType: string | null): string | null { const u = url.toLowerCase().split("?")[0]!; const ct = (contentType || "").toLowerCase(); if (u.endsWith(".svg") || ct === "image/svg+xml") return "svg"; if (/\.(jpg|jpeg|png|webp|avif|gif|ico|bmp)$/.test(u) || ct.startsWith("image/")) return "image"; if (/\.(mp4|mov|webm|m4v|ogv)$/.test(u) || ct.startsWith("video/")) return "video"; if (/\.(woff2|woff|ttf|otf|eot)$/.test(u) || ct.startsWith("font/") || ct.includes("font-woff") || ct.includes("x-font")) return "font"; if (u.endsWith(".json") && (u.includes("lottie") || u.includes("animation"))) return "lottie"; if (u.endsWith(".webmanifest") || /(?:^|\/)manifest\.json$/.test(u) || ct.includes("manifest+json")) return "manifest"; if (u.endsWith(".css") || ct.startsWith("text/css")) return "css"; return null; } function isCss(url: string, contentType: string | null): boolean { return classifyAsset(url, contentType) === "css"; } async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise { // Scroll through the page to trigger lazy-loaded images/backgrounds, then return // to the top so document-coordinate bboxes are measured from a settled layout. await page.evaluate(async (step: number) => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const maxScroll = () => document.documentElement.scrollHeight - window.innerHeight; let y = 0; let guard = 0; while (y < maxScroll() && guard < 100) { y += step; window.scrollTo(0, y); await sleep(60); guard++; } window.scrollTo(0, 0); await sleep(120); }, Math.max(Math.round(vpHeight * 0.8), 300)); } async function settle(page: import("playwright").Page, maxMs = 2500): Promise { try { await page.waitForLoadState("networkidle", { timeout: Math.max(maxMs / 2, 800) }); } catch { /* ignore */ } try { // document.fonts.ready can never resolve when a font request hangs (heavy SaaS // pages), and page.evaluate has no default timeout — so bound it explicitly. await Promise.race([ page.evaluate(() => (document as Document).fonts?.ready as unknown as Promise), new Promise((r) => setTimeout(r, 6000)), ]); } catch { /* ignore */ } await page.waitForTimeout(250); } export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean }; /** * Stage 2 — overlay/popup dismissal, phase 1: click the accept/close affordance. * Cookie-consent walls, newsletter modals, region/age/app-install interstitials * cover the real page; the capture must see the state a returning user sees. * Deterministic + replayable: click the same known/accept controls in DOM order. * Removal of a stuck overlay happens later (finalizeOverlays) AFTER a settle, so a * just-clicked dialog has time to close and unlock scrolling before we judge it. */ async function clickDismiss(page: import("playwright").Page): Promise { try { return await Promise.race([ page.evaluate(() => { const dismissed: string[] = []; const vis = (el: Element): boolean => { const cs = getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false; const r = (el as HTMLElement).getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } }; // 1) Known consent-framework / generic close affordances, in priority order. const KNOWN = [ "#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler", "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept", "#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close", "[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']", "#didomi-notice-agree-button", ".didomi-continue-without-agreeing", ".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']", ".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button", ]; for (const sel of KNOWN) { try { for (const el of Array.from(document.querySelectorAll(sel))) { if (vis(el)) { click(el); dismissed.push(sel); break; } } } catch { /* invalid selector in this browser */ } } // 2) Scoped text-button pass: only inside overlay-ish containers (dialogs, // or id/class naming cookie/consent/modal/popup/newsletter) so we never // click an ordinary page button. const ACCEPT = new Set([ "accept", "accept all", "accept all cookies", "accept cookies", "accept & close", "i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies", "allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks", "dismiss", "close", "got it!", "understood", "yes, i agree", ]); const containerSel = "[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," + "[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," + "[class*='newsletter' i],[class*='interstitial' i]"; let containers: Element[] = []; try { containers = Array.from(document.querySelectorAll(containerSel)); } catch { /* ignore */ } for (const c of containers) { if (!vis(c)) continue; const btns = Array.from(c.querySelectorAll("button,[role='button'],a,input[type='button'],input[type='submit']")); for (const b of btns) { const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase(); if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); break; } } } return dismissed; }), new Promise((res) => setTimeout(() => res([]), 6000)), ]); } catch { return []; } } /** * Stage 2 — overlay/popup dismissal, phase 2 (run after a settle). Detect any * full-viewport, high-z, fixed/sticky overlay still present. A fixed nav is wide * but short; a sticky sidebar is tall but narrow — both excluded; a consent wall / * modal backdrop covers most of both axes. If the page is *still scroll-locked* * (the modal is genuinely blocking) remove the overlay — but ONLY when its id/class * looks like a consent/modal layer, never a header/nav/main/footer, so legitimate * sticky chrome is never stripped. Reports `blocking` = a scroll-locking overlay we * could not clear (the pollution gate keys off this, not mere overlay presence). */ async function finalizeOverlays(page: import("playwright").Page): Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }> { try { return await Promise.race([ page.evaluate(() => { const vw = window.innerWidth, vh = window.innerHeight; const bigOverlays = (): HTMLElement[] => { const out: HTMLElement[] = []; for (const el of Array.from(document.body.querySelectorAll("*"))) { const cs = getComputedStyle(el); if (cs.position !== "fixed" && cs.position !== "sticky") continue; if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) continue; const r = (el as HTMLElement).getBoundingClientRect(); const z = parseInt(cs.zIndex || "0", 10) || 0; const area = (r.width * r.height) / (vw * vh); if (area >= 0.5 && z >= 100 && r.width >= vw * 0.7 && r.height >= vh * 0.5) out.push(el as HTMLElement); } return out.filter((el) => !out.some((o) => o !== el && o.contains(el))); }; const isLocked = (): boolean => { const b = document.body, h = document.documentElement; return getComputedStyle(b).overflow === "hidden" || getComputedStyle(h).overflow === "hidden" || getComputedStyle(b).position === "fixed"; }; // A scroll-locked page behind a full-viewport overlay IS a blocking modal by // definition (legit pages don't scroll-lock). So remove ANY such overlay that // isn't page chrome — many modals/drawers carry no consent/modal keyword and // an icon-only close, so a keyword/aria allowlist misses them (ruggable's // z-[1001] drawer). PROTECTED guards real chrome (header/nav/footer) only. const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i; const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString(); const removedLabels: string[] = []; let removed = 0; let remaining = bigOverlays(); if (remaining.length && isLocked()) { for (const el of remaining) { const s = sig(el); const z = parseInt(getComputedStyle(el).zIndex || "0", 10) || 0; // Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page // chrome. Always remove iframes (cross-origin close, unclickable) and the // max-z-index popup trick. const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" || el.tagName === "IFRAME" || z >= 2_000_000_000; if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); } } if (removed) { document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible"; document.body.style.position = "static"; } remaining = bigOverlays(); } return { overlaysRemaining: remaining.length, removed, blocking: remaining.length > 0 && isLocked(), removedLabels }; }), new Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)), ]); } catch { return { overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }; } } /** * Stage 2 — animation settling. Wait until layout stops moving (entrance/scroll * reveals finished) before measuring, so geometry isn't sampled mid-transition * ("sizes off due to animations in progress"). Samples large-box geometry across * frames; resolves when stable for several windows or the bound elapses. */ async function waitForQuiescence(page: import("playwright").Page, maxMs = 4000): Promise { try { return await Promise.race([ page.evaluate(async (budget: number) => { const sample = (): string => { const els = Array.from(document.body.querySelectorAll("*")).filter((e) => { const r = (e as HTMLElement).getBoundingClientRect(); return r.width > 80 && r.height > 40; }).slice(0, 240); return els.map((e) => { const r = (e as HTMLElement).getBoundingClientRect(); return `${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}`; }).join("|"); }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const deadline = Date.now() + budget; let prev = sample(); let stable = 0; while (Date.now() < deadline && stable < 3) { await sleep(130); const cur = sample(); if (cur === prev) stable++; else { stable = 0; prev = cur; } } return stable >= 3; }, maxMs), new Promise((res) => setTimeout(() => res(false), maxMs + 1500)), ]); } catch { return false; } } /** * Stage 2 — dynamic-media first frame. A streamed `