mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 14:47:29 +00:00
* fix(cli): wait for consent navigation before cataloging assets A consent click can reload the page (some CMPs reload once consent is stored). The post-click DOM reads then raced the navigation: the overlay sweep and scrollHeight probe ran against a torn-down or still-parsing document, scroll screenshots degraded to a handful of shots, and the asset catalog observed an empty body (0 assets). Add waitForPageReady: wait for network idle, then poll document.readyState so a committed-but-still-parsing reload is also waited out — network-idle alone stops counting a request once its response headers arrive, and waitForNavigation deadlocks when armed after the commit. The overlay sweep now runs on the settled document instead of the pre-reload one, and the same settle guards the asset catalog boundary. Fixes #4923 * fix(cli): bound consent-click settle by wall clock and skip it on banner-free pages waitForPageReady now shares one deadline (timeoutMs, capped by the remaining capture budget) across its network-idle waits and readyState poll, uses waitForNetworkIdle concurrency: 2 (the networkidle2 semantic navigation already uses), races each readyState evaluate against the deadline, and rethrows degradable evaluate timeouts so the stage can degrade. captureScrollScreenshots only pays the settle when the consent sweep clicked a control or performance.timeOrigin shows the document was swapped; the catalog boundary call degrades to a budget-bounded readyState check (networkIdle: after-load). * fix(cli): drop added comments to keep comment share at merge-base * fix(cli): adapt consent-settle tests to ScreenshotCaptureResult and keep comment share flat * fix(cli): tolerate missing waitForNetworkIdle in consent settle waitForPageReady called page.waitForNetworkIdle unconditionally; a Page-shaped double without it throws a synchronous TypeError that captureScrollScreenshots' catch-all reports as internal-error with zero screenshots, breaking the partial-budget contract upstream's coreExtractionPhase.test.ts exercises with a thin mock page. The idle wait is best-effort, so make the hook optional and swallow its failures; also only keep the readyState poll alive while the page affirmatively reports "loading" so a non-boolean result cannot burn the settle deadline.
459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
import type { ScreenshotInterruption } from "./screenshotCapture.js";
|
|
import type { Browser, Page } from "puppeteer-core";
|
|
import type { LottieDiscovery } from "./lottieDiscovery.js";
|
|
import type { DiscoveredLottie } from "./mediaCapture.js";
|
|
import type { CatalogedAsset } from "./assetCataloger.js";
|
|
import { serializeTokensForCapture } from "./partialCapture.js";
|
|
import type { PartialCaptureState } from "./partialCapture.js";
|
|
import type { CaptureResult, DesignTokens, ExtractedHtml } from "./types.js";
|
|
import type { IconCandidate } from "./faviconRanker.js";
|
|
import { startCdpAnimationCapture } from "./animationCataloger.js";
|
|
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
|
import { detectLibraries } from "./contentExtractor.js";
|
|
import { writeCaptureFileSync } from "./captureFile.js";
|
|
import { CaptureDirRefusedError } from "./captureErrors.js";
|
|
import { join } from "node:path";
|
|
import { extractHtml } from "./htmlExtractor.js";
|
|
import { extractTokens } from "./tokenExtractor.js";
|
|
import { extractDesignStyles } from "./designStyleExtractor.js";
|
|
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
|
import { collectAnimationCatalog } from "./animationCataloger.js";
|
|
import {
|
|
saveLottieAnimations,
|
|
renderLottiePreviews,
|
|
captureVideoManifest,
|
|
} from "./mediaCapture.js";
|
|
import { extractVisibleText } from "./contentExtractor.js";
|
|
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
|
|
import { waitForPageReady } from "./navigateForCapture.js";
|
|
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
|
|
import { filterExtractedScripts } from "./filterExtractedScripts.js";
|
|
|
|
export interface CoreExtractionInput {
|
|
page1: Page;
|
|
chromeBrowser: Browser;
|
|
cdp: Awaited<ReturnType<typeof startCdpAnimationCapture>>["cdp"];
|
|
cdpAnims: Awaited<ReturnType<typeof startCdpAnimationCapture>>["animations"];
|
|
state: PartialCaptureState;
|
|
outputDir: string;
|
|
warnings: string[];
|
|
progress: (stage: string, detail?: string) => void;
|
|
remainingMs: () => number;
|
|
maxScreenshots: number;
|
|
pageContentCheck: {
|
|
textLength: number;
|
|
title: string;
|
|
hasChallengeElement: boolean;
|
|
bodyChildCount: number;
|
|
};
|
|
contentCheckTimedOut: boolean;
|
|
discoveredLotties: DiscoveredLottie[];
|
|
lottieDiscovery: LottieDiscovery;
|
|
discoveredVideoUrls: Set<string>;
|
|
animationCatalog: CaptureResult["animationCatalog"];
|
|
capturedShaders: Array<{ type: string; source: string }> | undefined;
|
|
catalogedAssets: CatalogedAsset[];
|
|
detectedLibraries: Awaited<ReturnType<typeof detectLibraries>>;
|
|
visibleTextContent: string;
|
|
faviconLinks: IconCandidate[];
|
|
tokens: DesignTokens;
|
|
extracted: ExtractedHtml;
|
|
screenshots: string[];
|
|
downloadByteBudget: ReturnType<typeof createCaptureDownloadBudget>;
|
|
canWrite: () => boolean;
|
|
}
|
|
|
|
export interface CoreExtractionResult {
|
|
animationCatalog: CaptureResult["animationCatalog"];
|
|
capturedShaders: Array<{ type: string; source: string }> | undefined;
|
|
catalogedAssets: CatalogedAsset[];
|
|
detectedLibraries: Awaited<ReturnType<typeof detectLibraries>>;
|
|
visibleTextContent: string;
|
|
faviconLinks: IconCandidate[];
|
|
tokens: DesignTokens;
|
|
extracted: ExtractedHtml;
|
|
screenshots: string[];
|
|
screenshotOutcome:
|
|
| { kind: "complete" }
|
|
| {
|
|
kind: "partial" | "failed";
|
|
reason: ScreenshotInterruption["reason"] | "deadline";
|
|
};
|
|
}
|
|
|
|
export async function runCoreExtraction(input: CoreExtractionInput): Promise<CoreExtractionResult> {
|
|
let {
|
|
page1,
|
|
chromeBrowser,
|
|
cdp,
|
|
cdpAnims,
|
|
state,
|
|
outputDir,
|
|
warnings,
|
|
progress,
|
|
remainingMs,
|
|
pageContentCheck,
|
|
contentCheckTimedOut,
|
|
discoveredLotties,
|
|
lottieDiscovery,
|
|
discoveredVideoUrls,
|
|
animationCatalog,
|
|
capturedShaders,
|
|
catalogedAssets,
|
|
detectedLibraries,
|
|
visibleTextContent,
|
|
faviconLinks,
|
|
tokens,
|
|
extracted,
|
|
screenshots,
|
|
downloadByteBudget,
|
|
canWrite,
|
|
} = input;
|
|
const { maxScreenshots } = input;
|
|
let exhaustedPhase: string | undefined = remainingMs() <= 0 ? "navigation checks" : undefined;
|
|
const recordBudget = (phase: string): void => {
|
|
if (exhaustedPhase === undefined && remainingMs() <= 0) exhaustedPhase = phase;
|
|
};
|
|
const animationWarning = (): string =>
|
|
remainingMs() > 0
|
|
? "animation catalog evaluate timed out; continuing without animation catalog"
|
|
: "--capture-budget spent before the animation catalog finished; continuing without animation catalog";
|
|
let screenshotOutcome: CoreExtractionResult["screenshotOutcome"] = { kind: "complete" };
|
|
const runLazyAndLottie = async (): Promise<void> => {
|
|
if (!contentCheckTimedOut && pageContentCheck.textLength < 100) {
|
|
const reason =
|
|
"Page has very little text content (" +
|
|
pageContentCheck.textLength +
|
|
" chars) — may be blocked or a client-rendered SPA that needs more time";
|
|
warnings.push(reason);
|
|
progress("warn", reason);
|
|
}
|
|
|
|
const lazyLoadBudgetMs = Math.min(15_000, remainingMs());
|
|
const lazyScroll = await lazyScrollForCapture(page1, lazyLoadBudgetMs, {
|
|
onWarning: (message) => {
|
|
warnings.push(message);
|
|
progress("warn", message);
|
|
},
|
|
});
|
|
if (lazyScroll.timedOut && !lazyScroll.degraded) {
|
|
const message = `lazy-scroll stopped after ${lazyScroll.steps} steps (budget ${lazyLoadBudgetMs}ms)`;
|
|
warnings.push(message);
|
|
progress("warn", message);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
};
|
|
await runLazyAndLottie();
|
|
recordBudget("lazy scrolling");
|
|
|
|
const runAnimationDiscovery = async (): Promise<void> => {
|
|
const scanDomLotties = async (): Promise<void> => {
|
|
// Save discovered Lottie animations
|
|
// Also scan DOM for Lottie web components not caught by network interception
|
|
try {
|
|
const domLotties = await page1.evaluate(`(() => {
|
|
var urls = [];
|
|
document.querySelectorAll('dotlottie-wc, lottie-player, dotlottie-player').forEach(function(el) {
|
|
var src = el.getAttribute('src');
|
|
if (src) urls.push(src);
|
|
});
|
|
// Also check lottie-web registered animations
|
|
if (window.lottie && window.lottie.getRegisteredAnimations) {
|
|
window.lottie.getRegisteredAnimations().forEach(function(anim) {
|
|
if (anim.path) urls.push(anim.path);
|
|
});
|
|
}
|
|
return urls;
|
|
})()`);
|
|
if (Array.isArray(domLotties)) {
|
|
for (const lottieUrl of domLotties) {
|
|
if (
|
|
typeof lottieUrl === "string" &&
|
|
!discoveredLotties.some((l: { url: string }) => l.url === lottieUrl)
|
|
) {
|
|
discoveredLotties.push({ url: lottieUrl });
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* DOM scan failed — non-critical */
|
|
}
|
|
};
|
|
await scanDomLotties();
|
|
|
|
const collectNetworkLotties = async (): Promise<void> => {
|
|
for (const found of await lottieDiscovery.run(downloadByteBudget, remainingMs)) {
|
|
const existing = discoveredLotties.findIndex(
|
|
(item: { url: string }) => item.url === found.url,
|
|
);
|
|
if (existing < 0) discoveredLotties.push(found);
|
|
else discoveredLotties[existing] = found;
|
|
}
|
|
};
|
|
await collectNetworkLotties();
|
|
|
|
const saveDiscoveredLotties = async (): Promise<void> => {
|
|
if (discoveredLotties.length > 0 && remainingMs() > 0) {
|
|
const lottieDir = join(outputDir, "assets", "lottie");
|
|
if (!canWrite()) return;
|
|
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
|
|
const savedCount = await saveLottieAnimations(
|
|
discoveredLotties,
|
|
lottieDir,
|
|
outputDir,
|
|
lottieBudget,
|
|
);
|
|
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
|
|
if (savedCount > 0 && remainingMs() > 0 && canWrite()) {
|
|
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
|
|
progress("lottie", `${savedCount} Lottie animation(s) saved`);
|
|
}
|
|
}
|
|
};
|
|
await saveDiscoveredLotties();
|
|
};
|
|
await runAnimationDiscovery();
|
|
recordBudget("animation discovery");
|
|
|
|
// Save captured WebGL shaders (useful context for shader transitions + library detection)
|
|
const runShaderCapture = async (): Promise<void> => {
|
|
try {
|
|
const shaders = await page1.evaluate(`window.__capturedShaders || []`);
|
|
if (Array.isArray(shaders) && shaders.length > 0) {
|
|
const seen = new Set<string>();
|
|
const unique = (shaders as Array<{ type: string; source: string }>).filter((s) => {
|
|
if (seen.has(s.source)) return false;
|
|
seen.add(s.source);
|
|
return true;
|
|
});
|
|
capturedShaders = unique;
|
|
if (canWrite()) {
|
|
writeCaptureFileSync(
|
|
join(outputDir, "extracted", "shaders.json"),
|
|
JSON.stringify(unique, null, 2),
|
|
"utf-8",
|
|
);
|
|
}
|
|
progress("shaders", `${unique.length} WebGL shader(s) captured`);
|
|
}
|
|
} catch {
|
|
/* shader extraction failed — non-critical */
|
|
}
|
|
};
|
|
await runShaderCapture();
|
|
recordBudget("shader capture");
|
|
|
|
const runTokenExtraction = async (): Promise<void> => {
|
|
// Extract DOM data before extractHtml mutates image URLs and removes scripts.
|
|
|
|
// Extract design tokens
|
|
progress("tokens", "Extracting design tokens...");
|
|
tokens = await extractTokens(page1);
|
|
state.tokens = tokens;
|
|
// Save tokens.json without SVG outerHTML (kept in memory for asset downloader)
|
|
if (canWrite()) {
|
|
writeCaptureFileSync(
|
|
join(outputDir, "extracted", "tokens.json"),
|
|
serializeTokensForCapture(tokens),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
// Extract computed design styles (typography, buttons, cards, spacing, shadows)
|
|
progress("style", "Extracting design styles...");
|
|
try {
|
|
const designStyles = await extractDesignStyles(page1);
|
|
state.designStyles = designStyles;
|
|
if (canWrite()) {
|
|
writeCaptureFileSync(
|
|
join(outputDir, "extracted", "design-styles.json"),
|
|
JSON.stringify(designStyles, null, 2),
|
|
"utf-8",
|
|
);
|
|
}
|
|
progress(
|
|
"tokens",
|
|
`${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`,
|
|
);
|
|
} catch (err) {
|
|
const errMsg =
|
|
err instanceof Error ? `${err.message}\n${err.stack}` : normalizeErrorMessage(err);
|
|
console.error(` ⚠ Design style extraction failed: ${errMsg}`);
|
|
warnings.push(`Design style extraction failed: ${errMsg}`);
|
|
}
|
|
};
|
|
await runTokenExtraction();
|
|
recordBudget("design token extraction");
|
|
|
|
const runAnimationCapture = async (): Promise<void> => {
|
|
progress("animations", "Cataloging animations...");
|
|
try {
|
|
const animationOutcome = await collectAnimationCatalog(page1, cdpAnims, cdp, {
|
|
scrollBudgetMs: Math.min(8_000, remainingMs()),
|
|
evaluateBudgetMs: Math.min(15_000, remainingMs()),
|
|
});
|
|
animationCatalog = animationOutcome.catalog;
|
|
if (animationOutcome.timedOut) {
|
|
const message = animationWarning();
|
|
warnings.push(message);
|
|
progress("warn", message);
|
|
}
|
|
} catch (err) {
|
|
if (!isDegradableEvaluateTimeoutError(err)) {
|
|
throw err;
|
|
}
|
|
const message = animationWarning();
|
|
warnings.push(message);
|
|
progress("warn", message);
|
|
try {
|
|
await cdp.send("Animation.disable");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
recordBudget("animation catalog");
|
|
};
|
|
await runAnimationCapture();
|
|
|
|
const runScreenshotCapture = async (): Promise<void> => {
|
|
progress("screenshots", "Capturing scroll screenshots...");
|
|
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
|
|
if (!canWrite()) {
|
|
screenshotOutcome = {
|
|
kind: "failed",
|
|
reason: "deadline",
|
|
};
|
|
return;
|
|
}
|
|
screenshots = [];
|
|
state.screenshots = screenshots;
|
|
const capture = await captureScrollScreenshots(page1, outputDir, {
|
|
remainingMs,
|
|
maxScreenshots,
|
|
files: screenshots,
|
|
});
|
|
const failed = capture.failures.map(
|
|
({ file, scrollY, message }) =>
|
|
`could not capture ${file}${scrollY === null ? "" : ` (scrolled to ${scrollY}px)`}: ${message}`,
|
|
);
|
|
if (capture.interruption || failed.length > 0 || screenshots.length === 0) {
|
|
recordBudget("screenshots");
|
|
const { reason, message: detail }: ScreenshotInterruption = capture.interruption ?? {
|
|
reason: "internal-error",
|
|
message: failed.length > 0 ? "" : "screenshot capture produced no files",
|
|
};
|
|
const cause = [
|
|
reason === "budget-exhausted"
|
|
? `--capture-budget exhausted during ${exhaustedPhase}`
|
|
: detail,
|
|
...failed,
|
|
]
|
|
.filter(Boolean)
|
|
.join("; ");
|
|
const message = `${screenshots.length}/${maxScreenshots} requested screenshot files captured: ${cause}`;
|
|
screenshotOutcome = {
|
|
kind: screenshots.length === 0 ? "failed" : "partial",
|
|
reason,
|
|
};
|
|
warnings.push(message);
|
|
progress("warn", message);
|
|
}
|
|
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
|
|
};
|
|
await runScreenshotCapture();
|
|
const runHtmlExtraction = async (): Promise<void> => {
|
|
await waitForPageReady(page1, {
|
|
timeoutMs: Math.min(15_000, remainingMs()),
|
|
networkIdle: "after-load",
|
|
});
|
|
|
|
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
|
|
progress("design", "Cataloging assets...");
|
|
try {
|
|
const { catalogAssets } = await import("./assetCataloger.js");
|
|
catalogedAssets = await catalogAssets(page1);
|
|
progress("design", `${catalogedAssets.length} assets cataloged`);
|
|
if (catalogedAssets.length === 0) {
|
|
warnings.push(
|
|
"Asset catalog is empty — no images will be downloaded. The page may use non-standard image loading.",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
warnings.push(`Asset cataloging failed (no images will be downloaded): ${err}`);
|
|
}
|
|
|
|
// ── MUTATION phase: extractHtml modifies the live DOM (converts images to data URLs) ──
|
|
progress("extract", "Extracting HTML & CSS...");
|
|
extracted = await extractHtml(page1, { settleTime: 1000 });
|
|
state.extracted = extracted;
|
|
|
|
// Strip framework scripts from the extracted body — keep visual library scripts
|
|
// IMPORTANT: Use non-greedy matching within individual script tags only
|
|
extracted.bodyHtml = extracted.bodyHtml
|
|
// Remove React hydration markers
|
|
.replace(/\s*data-reactroot="[^"]*"/g, "")
|
|
.replace(/\s*data-reactroot/g, "");
|
|
|
|
const filteredScripts = filterExtractedScripts(extracted.bodyHtml, extracted.headHtml);
|
|
extracted.bodyHtml = filteredScripts.bodyHtml;
|
|
extracted.headHtml = filteredScripts.headHtml;
|
|
|
|
// Generate video manifest — screenshot each <video> element + extract surrounding context
|
|
// so Claude Code can SEE what each video shows and WHERE it was used on the page.
|
|
try {
|
|
const videoBudgetMs = remainingMs();
|
|
if (videoBudgetMs > 0 && canWrite()) {
|
|
await captureVideoManifest(page1, outputDir, progress, {
|
|
networkVideoUrls: discoveredVideoUrls, // Layer 1 (live Set, read after sampling)
|
|
sampleMs: Math.min(12000, videoBudgetMs), // Layer 2: poll DOM within the shared budget
|
|
downloadBudgetMs: videoBudgetMs,
|
|
remainingMs,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof CaptureDirRefusedError)
|
|
warnings.push(`Video manifest skipped: ${err.message}`);
|
|
/* otherwise non-blocking — video manifest is best-effort */
|
|
}
|
|
|
|
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
|
|
detectedLibraries = await detectLibraries(page1, capturedShaders);
|
|
|
|
// Extract all visible text in DOM order
|
|
visibleTextContent = await extractVisibleText(page1);
|
|
|
|
// Extract favicon links before closing page (removed from tokens to reduce noise)
|
|
// `sizes` and `type` are the only evidence of icon quality: page.html on disk does not
|
|
// keep the <link> tags, and the bytes are only fetched for the candidate that wins, so
|
|
// dropping these attributes here makes the choice unrecoverable downstream.
|
|
faviconLinks = (await page1.evaluate(`(() => {
|
|
var iconEls = Array.from(document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]'));
|
|
return iconEls.map(function(l) {
|
|
return {
|
|
rel: l.rel,
|
|
href: l.href,
|
|
sizes: l.getAttribute('sizes'),
|
|
type: l.getAttribute('type'),
|
|
};
|
|
});
|
|
})()`)) as IconCandidate[];
|
|
|
|
await page1.close();
|
|
};
|
|
await runHtmlExtraction();
|
|
|
|
return {
|
|
screenshotOutcome,
|
|
animationCatalog,
|
|
capturedShaders,
|
|
catalogedAssets,
|
|
detectedLibraries,
|
|
visibleTextContent,
|
|
faviconLinks,
|
|
tokens,
|
|
extracted,
|
|
screenshots,
|
|
};
|
|
}
|