Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes

Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
  capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
  markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
  generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
  (collapsed sections, viewport-inconsistent captures); settle autoplay
  carousels to home slide before every snapshot; force-reveal hidden
  videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
  picture>source through IR prune with srcset rewrite (mobile-crop-on-
  desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
  forms) with element-screenshot fallback; asset download retry +
  visual-assets-missing reporting

Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 17:45:42 -07:00
co-authored by Claude Fable 5
parent da537e68b8
commit 9aed2540aa
31 changed files with 2579 additions and 121 deletions
+236 -21
View File
@@ -3,6 +3,14 @@ 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 {
promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
forceRevealForShot, restoreRevealForShot,
} from "./stabilize.js";
import {
enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
MAX_GRAFT_FRAMES, FRAME_GRAFT_MAX_NODES, type FrameCandidate,
} from "./graft.js";
import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js";
@@ -90,6 +98,23 @@ function viewportHeight(width: number): number {
return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
}
// ---- Asset download retry (Fix: transient failures silently degrade to placeholders) ----
/** Types whose absence is visible in the clone (image → transparent GIF, video → blank,
* font → fallback face). These earn one retry; css/manifest/lottie degrade gracefully. */
const RETRYABLE_ASSET_TYPES = new Set(["image", "svg", "video", "font"]);
/** Fixed, bounded delay before the single retry — deterministic (no jitter/backoff). */
export const ASSET_RETRY_DELAY_MS = 750;
/** Should a failed download of `type` with HTTP `status` (null = network error / no
* response) be retried once? Transient states only: connection failures, 5xx, and 429.
* 4xx (404/403/…) are authoritative — retrying can't change them. */
export function isRetryableAssetFailure(type: string, status: number | null): boolean {
if (!RETRYABLE_ASSET_TYPES.has(type)) return false;
if (status === null) return true;
return status >= 500 || status === 429;
}
function extFromUrl(url: string): string {
try {
const p = new URL(url).pathname;
@@ -137,6 +162,19 @@ function isCss(url: string, contentType: string | null): boolean {
return classifyAsset(url, contentType) === "css";
}
/** Container-magic check for accepted video bytes: ISO-BMFF (`ftyp` within the first
* 12 bytes — mp4/m4v/mov), webm/mkv (EBML 0x1A45DFA3), or ogg (`OggS`). A range
* fragment (e.g. an mp4's moov-atom tail served as a 206) fails all three, so a
* corrupt partial body is never stored as the asset. */
export function looksLikeVideoFile(bytes: Buffer): boolean {
if (bytes.length < 12) return false;
const head = bytes.subarray(0, 12);
if (head.includes("ftyp")) return true;
if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return true; // EBML (webm/mkv)
if (head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) return true; // "OggS"
return false;
}
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
// 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.
@@ -535,6 +573,10 @@ export async function captureSite(opts: {
const storeBytes = (url: string, type: string, bytes: Buffer): void => {
if (!bytes || bytes.length === 0) return;
// Reject non-container bytes for video from EVERY path (a range fragment or error
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
// asset surfaces in visual_assets_missing instead.
if (type === "video" && !looksLikeVideoFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
@@ -655,6 +697,10 @@ export async function captureSite(opts: {
const existing = assetMap.get(url);
if (existing?.storedAs) return;
if (status >= 400) return;
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
// ship a corrupt file under first-stored-wins and the full-download fallback
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
if (status === 206) return;
bodyPromises.push(
(async () => {
try {
@@ -684,6 +730,42 @@ export async function captureSite(opts: {
if (!navigated) throw navErr;
await settle(page);
// Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in
// `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver
// (collapsed sections in the snapshot) and the interaction pass can trigger the swap
// midway (viewports then DISAGREE about the section's size). Promote once, before any
// snapshot, so every viewport measures the same loaded media.
const lazyPromoted = await promoteLazyMedia(page);
if (lazyPromoted) {
log({ event: "lazy_promoted", count: lazyPromoted });
await settle(page, 2000); // newly-real images reflow the layout
}
// Stage 2: reveal settling. Scroll reveals (Elementor waypoints, WOW/AOS class swaps)
// are the same class of one-shot load-state as lazy media: fire them ONCE, before any
// viewport snapshot, so every width records the POST-REVEAL steady state — otherwise
// the snapshot bakes `visibility:hidden` wrappers (or a mid-fade opacity) with no JS
// to ever reveal them, and the clone renders below-fold content blank.
// A scroll-locking consent wall would defeat the dwell walk, so clear it first (the
// per-viewport dismissal below still runs and owns the audit trail).
const preClicked = await clickDismiss(page);
if (preClicked.length) {
for (const d of preClicked) if (!dismissUnion.dismissed.includes(d)) dismissUnion.dismissed.push(d);
await settle(page, 1000);
}
// Motion capture needs the PRE-reveal hidden state, observable only before the first
// scroll — tag + probe now (captureMotion confirms the candidates at the canonical
// snapshot, reading each revealed element's entrance animation from computed style).
if (opts.motion) { await tagElements(page); await probeReveals(page); }
const revealSettle = await settleScrollReveals(page);
log({ event: "reveals_settled", ...revealSettle });
// Belt-and-braces: reveal any element STILL carrying a known-library pre-reveal marker
// (below the step bound, or keyed to a non-scroll trigger) via the library's own
// revealed state, so captured computed styles are genuine post-reveal values.
const neutralized = await neutralizePreReveal(page);
if (neutralized) log({ event: "prereveal_neutralized", count: neutralized });
await settle(page, 1500); // revealed content reflows the layout
for (const vw of viewports) {
const vh = viewportHeight(vw);
await page.setViewportSize({ width: vw, height: vh });
@@ -710,12 +792,8 @@ export async function captureSite(opts: {
};
await applyDismiss("load");
// Stage 5 (scroll reveals): at the FIRST viewport, before any scroll, tag elements
// and snapshot the pre-reveal hidden state — scroll reveals fire on the first
// autoScroll and stay revealed, so this is the only moment their hidden state is
// observable. Idempotent + motion-gated; the settled snapshot is unchanged.
if (opts.motion && vw === viewports[0]) { await tagElements(page); await probeReveals(page); }
// (Scroll-reveal probing happens once, before the viewport loop — see the reveal
// settling pass above; by this point all one-shot reveals have already fired.)
await autoScroll(page, vh);
await settle(page, 1500);
await applyDismiss("post-scroll");
@@ -737,6 +815,15 @@ export async function captureSite(opts: {
});
await page.waitForTimeout(150);
// Stage 2: settle autoplaying carousels (pause + navigate to the home slide)
// before EVERY snapshot — otherwise each viewport freezes a different track
// offset (per-band CSS then bakes four different slides), and the interaction
// pass between the canonical and last snapshots would leave the final viewport
// contaminated. Scoped to named-library tracks so motion.ts's marquee/rotator
// capture (which runs after the canonical snapshot) observes them unchanged.
const carousels = await settleCarousels(page);
if (carousels.roots) log({ event: "carousels_settled", viewport: vw, ...carousels });
// Stage 2: dynamic-media first frame. Materialize a still for poster-less
// videos so they don't render blank — canvas where the frame is readable, else
// an element screenshot (the page cannot screenshot itself). Done once at the
@@ -755,12 +842,27 @@ export async function captureSite(opts: {
} catch { /* ignore */ }
}
for (const s of plan.shots) {
// A visibility:hidden video (entrance animation not yet fired) passes the size
// gate, but locator.screenshot auto-waits for visibility and times out —
// force-reveal the hidden ancestor chain for the shot, restore exactly after.
let shot = false;
try {
await page.evaluate(forceRevealForShot, s.sel);
const buf = await page.locator(s.sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
storeBytes(s.url, "image", buf);
dismissUnion.videoStills++;
} catch { /* element not shootable (off-screen/detached) — poster falls back to placeholder */ }
shot = true;
} catch (e) {
log({ event: "video_still_error", viewport: vw, sel: s.sel, error: String(e).slice(0, 200) });
} finally {
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
}
// A synthetic poster with no bytes behind it generates a transparent tile —
// on failure, remove the attr so the video renders as it did pre-capture.
if (!shot) {
await page.evaluate((sel) => { document.querySelector(sel)?.removeAttribute("poster"); }, s.sel).catch(() => { /* ignore */ });
}
}
// Element screenshots scroll the target into view; restore scroll 0 so the
// canonical DOM walk + screenshot match the generated app's default render.
@@ -774,6 +876,95 @@ export async function captureSite(opts: {
// them (whitelisted), enabling interaction deltas + motion specs to map to cids.
if ((opts.interactions || opts.motion) && vw === canonical) await tagElements(page);
// Stage 2.6: cross-origin iframe content. The in-page walker cannot see into a
// cross-origin frame (newsletter/form embeds), but Node CAN evaluate in it — run the
// SAME collectPage per meaningful frame here, then graft each subtree into this
// viewport's snapshot below (graft.ts). Frames that can't be grafted (media players,
// dead frames) get an element-screenshot recorded as the iframe's background at the
// canonical viewport, so the box at least PAINTS. Runs before the main collectPage so
// the fallback's inline background is part of the canonical computed style.
const frameCands: FrameCandidate[] = await Promise.race([
page.evaluate(enumerateFramesInPage),
new Promise<FrameCandidate[]>((res) => setTimeout(() => res([]), 8000)),
]).catch(() => [] as FrameCandidate[]);
const frameGrafts: Array<{ cand: FrameCandidate; snap: PageSnapshot }> = [];
let graftBudget = MAX_GRAFT_FRAMES;
let stillBudget = MAX_GRAFT_FRAMES; // fallback stills share the same per-page bound
let frameShotScrolled = false;
for (const cand of frameCands) {
if (!cand.visible) continue;
const plan = planForFrameUrl(cand.url);
if (plan === "skip") continue;
let frameSnap: PageSnapshot | null = null;
if (plan === "graft" && graftBudget > 0) {
try {
const handle = await page.$(`iframe[data-ditto-frame="${cand.idx}"]`);
const frame = handle ? await handle.contentFrame() : null;
if (frame) {
await frame.evaluate(ESBUILD_SHIM);
frameSnap = await Promise.race([
frame.evaluate(collectPage, { maxNodes: FRAME_GRAFT_MAX_NODES }),
new Promise<never>((_, rej) => setTimeout(() => rej(new Error("frame collect timeout")), 20_000)),
]);
}
await handle?.dispose();
} catch (e) {
log({ event: "frame_graft_error", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160), error: String(e).slice(0, 200) });
frameSnap = null;
}
}
if (frameSnap && frameHasRenderableContent(frameSnap.root)) {
graftBudget--;
frameGrafts.push({ cand, snap: frameSnap });
// Merge the frame's discoveries exactly like the main document's below: its
// assets/fonts flow through the same pipeline so grafted <img>/@font-face resolve.
for (const da of frameSnap.domAssets) {
const t = classifyAsset(da.url, null) ?? (da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
recordAsset(da.url, t, null, null, `frame${cand.idx}:${da.via}`);
}
for (const u of frameSnap.cssUrls) {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-url");
}
for (const ff of frameSnap.fontFaces) {
const key = `${ff.family}|${ff.weight ?? ""}|${ff.style ?? ""}|${ff.src}`;
if (!fontFaceMap.has(key)) fontFaceMap.set(key, ff);
}
} else if (vw === canonical && stillBudget > 0) {
stillBudget--;
// Screenshot fallback — synthetic-poster pattern (see captureVideoStills): the
// still's bytes ride the normal asset pipeline under a synthetic URL; the iframe's
// inline background then rewrites to the local file at generation.
const stillUrl = `https://clone-frame.local/${cand.idx}-${sha1_12(cand.url || String(cand.idx))}.jpg`;
const sel = `iframe[data-ditto-frame="${cand.idx}"]`;
try {
await page.evaluate(forceRevealForShot, sel);
const buf = await page.locator(sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
recordAsset(stillUrl, "image", "image/jpeg", 200, "iframe-still");
storeBytes(stillUrl, "image", buf);
frameShotScrolled = true;
await page.evaluate(({ s, u }) => {
const el = document.querySelector(s) as HTMLElement | null;
if (el) {
el.style.backgroundImage = `url("${u}")`;
el.style.backgroundSize = "100% 100%";
el.style.backgroundRepeat = "no-repeat";
}
}, { s: sel, u: stillUrl });
log({ event: "frame_still", viewport: vw, frame: cand.idx, url: cand.url.slice(0, 160) });
} catch (e) {
log({ event: "frame_still_error", viewport: vw, frame: cand.idx, error: String(e).slice(0, 200) });
} finally {
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
}
}
}
// Element screenshots scroll the target into view; restore scroll 0 before the walk.
if (frameShotScrolled) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(80);
}
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
@@ -781,6 +972,13 @@ export async function captureSite(opts: {
new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`collectPage timeout vp${vw}`)), 60_000)),
]);
// Graft the captured frame subtrees into this viewport's snapshot (offset bboxes,
// namespaced ids, frame-URL-absolutized src/href — see graft.ts).
for (const g of frameGrafts) {
const ok = graftFrameIntoSnapshot(snapshot, g.cand, g.snap);
log({ event: ok ? "frame_grafted" : "frame_graft_orphaned", viewport: vw, frame: g.cand.idx, url: g.cand.url.slice(0, 160), nodes: g.snap.doc.nodeCount });
}
// Merge discovered references from the snapshot (DOM + accessible CSS).
for (const da of snapshot.domAssets) {
const t = classifyAsset(da.url, null) ?? (da.kind === "manifest" ? "manifest" : da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
@@ -895,21 +1093,38 @@ export async function captureSite(opts: {
if (a.storedAs) continue;
if (a.url.startsWith("data:")) continue;
if (!["image", "svg", "video", "font", "lottie", "css", "manifest"].includes(a.type)) continue;
try {
const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
a.status = resp.status();
if (resp.ok()) {
const buf = await resp.body();
a.contentType = a.contentType ?? (resp.headers()["content-type"] || null);
storeBytes(a.url, a.type, buf);
if (a.type === "css") parseCssForFonts(buf.toString("utf8"), a.url, fontFaceMap, (u) => {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-text");
});
if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
}
} catch { /* unreachable/signed — left as skipped */ }
// One bounded retry for transiently-failed VISUAL assets (network error / 5xx / 429):
// a single flaky fetch otherwise degrades an image to the transparent-GIF placeholder.
for (let attempt = 0; attempt < 2; attempt++) {
let failStatus: number | null = null;
try {
const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
a.status = resp.status();
if (resp.ok()) {
const buf = await resp.body();
a.contentType = a.contentType ?? (resp.headers()["content-type"] || null);
storeBytes(a.url, a.type, buf);
if (a.type === "css") parseCssForFonts(buf.toString("utf8"), a.url, fontFaceMap, (u) => {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-text");
});
if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
break;
}
failStatus = resp.status();
} catch { failStatus = null; /* unreachable/signed — left as skipped */ }
if (attempt > 0 || !isRetryableAssetFailure(a.type, failStatus)) break;
log({ event: "asset_retry", url: a.url, type: a.type, status: failStatus });
await new Promise((r) => setTimeout(r, ASSET_RETRY_DELAY_MS));
}
}
// Every visual asset that ultimately failed, in one machine-readable event: these are
// the boxes that will paint as placeholders (image → transparent GIF, video → blank).
const visualMissing = [...assetMap.values()]
.filter((a) => !a.storedAs && !a.url.startsWith("data:") && (a.type === "image" || a.type === "svg" || a.type === "video"))
.map((a) => ({ url: a.url, type: a.type, status: a.status }))
.sort((x, y) => x.url.localeCompare(y.url));
if (visualMissing.length) log({ event: "visual_assets_missing", count: visualMissing.length, assets: visualMissing });
const seoResources: SeoResource[] = [];
const fetchedSeo = new Set<string>();
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise<void> => {