mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): bound capture runs and degrade WebGL navigation (#4215)
* fix(cli): bound capture runs and degrade WebGL navigation * fix(cli): keep partial capture on deadline * fix(cli): persist partial capture artifacts * fix(cli): reduce capture phase complexity * chore(cli): tighten capture comments * fix(cli): restore post-capture phase calls * fix(cli): parse extracted scripts before filtering * fix(cli): remove next data through dom filter * refactor(cli): move capture attempt into module * refactor(cli): extract capture phases into modules * refactor(cli): split navigation checks * refactor(cli): deduplicate capture phase modules * fix(cli): preserve bounded capture output on deadline * fix(cli): secure capture artifact writes * fix(cli): close remaining capture file alerts * fix(cli): mark deadline captures unsuccessful * refactor(cli): centralize capture phase schema * style(cli): format capture watchdog test * docs(cli): condense capture attempt header
This commit is contained in:
@@ -419,6 +419,8 @@
|
||||
// as a direct dep to guarantee installation even when transitive
|
||||
// resolution fails (corrupted cache, dedup edge cases).
|
||||
"debug",
|
||||
// Loaded through the optional package loader for Gemini vision captioning.
|
||||
"@google/genai",
|
||||
"puppeteer",
|
||||
"puppeteer-core",
|
||||
"esbuild",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* product-launch-video skill — this file points agents there.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { readdirSync, existsSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
import type { DesignTokens } from "./types.js";
|
||||
import type { AnimationCatalog } from "./animationCataloger.js";
|
||||
@@ -52,9 +53,9 @@ export function generateAgentPrompt(
|
||||
_detectedLibraries?: string[],
|
||||
): void {
|
||||
const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders);
|
||||
writeFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
|
||||
writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
|
||||
writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
|
||||
writeCaptureFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
|
||||
writeCaptureFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
|
||||
writeCaptureFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import { isBlockedNetworkHost } from "@hyperframes/engine";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DesignTokens, DownloadedAsset } from "./types.js";
|
||||
@@ -206,7 +207,7 @@ async function fetchAndInspectIcon(
|
||||
if (!ext) return null;
|
||||
|
||||
const file = `assets/${stem}${ext}`;
|
||||
writeFileSync(join(outputDir, file), buffer);
|
||||
writeCaptureFileSync(join(outputDir, file), buffer);
|
||||
const verdict = await classifyIcon(buffer, ext);
|
||||
return {
|
||||
buffer,
|
||||
@@ -235,7 +236,7 @@ function promoteHeadline(
|
||||
if (!chosen) return null;
|
||||
|
||||
const file = `assets/favicon${extname(chosen.file)}`;
|
||||
writeFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!);
|
||||
writeCaptureFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!);
|
||||
manifest.headline = {
|
||||
file,
|
||||
source: chosen.file,
|
||||
@@ -346,7 +347,7 @@ export async function downloadAssets(
|
||||
const name = `${finalSlug}.svg`;
|
||||
const localPath = `assets/svgs/${name}`;
|
||||
try {
|
||||
writeFileSync(join(outputDir, localPath), svgFile, "utf-8");
|
||||
writeCaptureFileSync(join(outputDir, localPath), svgFile, "utf-8");
|
||||
assets.push({ url: "", localPath, type: "svg" });
|
||||
} catch {
|
||||
drops.unavailable++;
|
||||
@@ -472,7 +473,7 @@ export async function downloadAssets(
|
||||
const name = `${slug}${ext}`;
|
||||
usedNames.add(slug);
|
||||
const localPath = `assets/${name}`;
|
||||
writeFileSync(join(outputDir, localPath), buffer);
|
||||
writeCaptureFileSync(join(outputDir, localPath), buffer);
|
||||
assets.push({ url, localPath, type: "image" });
|
||||
imgIdx++;
|
||||
} catch {
|
||||
@@ -501,7 +502,7 @@ export async function downloadAssets(
|
||||
drops["size-floor"]++;
|
||||
} else {
|
||||
const localPath = `assets/og-image${ext}`;
|
||||
writeFileSync(join(outputDir, localPath), buffer);
|
||||
writeCaptureFileSync(join(outputDir, localPath), buffer);
|
||||
assets.push({ url: tokens.ogImage, localPath, type: "image" });
|
||||
}
|
||||
}
|
||||
@@ -614,7 +615,7 @@ export async function downloadAndRewriteFonts(
|
||||
const filename = captureFontFilename(fontUrl, extension, usedFontNames);
|
||||
const localPath = join(assetsDir, filename);
|
||||
const relativePath = `assets/fonts/${filename}`;
|
||||
writeFileSync(localPath, buffer);
|
||||
writeCaptureFileSync(localPath, buffer);
|
||||
rewritten = rewritten.split(fontUrl).join(relativePath);
|
||||
} else {
|
||||
drops.unavailable++;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function captureBrowserArgs(
|
||||
disableWebgl: boolean,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
): string[] {
|
||||
return [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
...(disableWebgl
|
||||
? ["--disable-gpu"]
|
||||
: ["--enable-webgl", "--ignore-gpu-blocklist", "--use-gl=angle", "--use-angle=swiftshader"]),
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
`--window-size=${viewportWidth},${viewportHeight}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { LottieDiscovery } from "./lottieDiscovery.js";
|
||||
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
/**
|
||||
* Two-pass capture: full load catalogs animations, then scripts are blocked to extract stable HTML.
|
||||
* - Rich animation metadata for Claude Code to recreate
|
||||
*/
|
||||
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { noDrops } from "./assetDownloader.js";
|
||||
import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
import { setupAnimationCapture, startCdpAnimationCapture } from "./animationCataloger.js";
|
||||
import type { DiscoveredLottie } from "./mediaCapture.js";
|
||||
import { detectLibraries } from "./contentExtractor.js";
|
||||
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
|
||||
import { captureProtocolTimeoutMs } from "./captureTimeout.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CapturePhase,
|
||||
CapturePhaseProgress,
|
||||
CaptureResult,
|
||||
DesignTokens,
|
||||
ExtractedHtml,
|
||||
} from "./types.js";
|
||||
import type { CaptureWatchdog } from "./captureWatchdog.js";
|
||||
import { captureBrowserArgs } from "./browserLaunchArgs.js";
|
||||
import type { PartialCaptureState } from "./partialCapture.js";
|
||||
import { runNavigationChecks } from "./navigationPhase.js";
|
||||
import { runCoreExtraction } from "./coreExtractionPhase.js";
|
||||
import { runPostExtraction } from "./postExtractionPhase.js";
|
||||
|
||||
const DEFAULT_POST_NAVIGATION_BUDGET_MS = 120_000;
|
||||
|
||||
export async function captureWebsiteAttempt(
|
||||
opts: CaptureOptions,
|
||||
onProgress: ((stage: string, detail?: string) => void) | undefined,
|
||||
disableWebgl: boolean,
|
||||
watchdog: CaptureWatchdog,
|
||||
state: PartialCaptureState,
|
||||
): Promise<CaptureResult> {
|
||||
const {
|
||||
url,
|
||||
outputDir,
|
||||
viewportWidth = 1920,
|
||||
viewportHeight = 1080,
|
||||
timeout = 120000,
|
||||
settleTime = 3000,
|
||||
maxScreenshots: _maxScreenshots = 24,
|
||||
skipAssets = false,
|
||||
skipVision = false,
|
||||
postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS,
|
||||
onPhase,
|
||||
} = opts;
|
||||
|
||||
const downloadByteBudget = createCaptureDownloadBudget();
|
||||
const warnings: string[] = [...state.warnings];
|
||||
state.warnings = warnings;
|
||||
const progress = (stage: string, detail?: string) => {
|
||||
onProgress?.(stage, detail);
|
||||
};
|
||||
const budgetMs =
|
||||
Number.isFinite(postNavigationBudgetMs) && postNavigationBudgetMs > 0
|
||||
? postNavigationBudgetMs
|
||||
: DEFAULT_POST_NAVIGATION_BUDGET_MS;
|
||||
let postNavigationDeadline: number | undefined;
|
||||
const remainingMs = (): number =>
|
||||
postNavigationDeadline === undefined
|
||||
? budgetMs
|
||||
: Math.max(0, postNavigationDeadline - Date.now());
|
||||
const canWrite = (): boolean => !watchdog.expired();
|
||||
let lastPhase: CapturePhaseProgress = {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "browser",
|
||||
status: "started",
|
||||
remainingMs: null,
|
||||
};
|
||||
const phase = (
|
||||
name: CapturePhase,
|
||||
status: CapturePhaseProgress["status"],
|
||||
reason?: CapturePhaseProgress["reason"],
|
||||
): void => {
|
||||
if (watchdog.expired()) return;
|
||||
const remaining = postNavigationDeadline === undefined ? null : remainingMs();
|
||||
lastPhase = reason
|
||||
? {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: name,
|
||||
status,
|
||||
remainingMs: remaining,
|
||||
reason,
|
||||
}
|
||||
: { schema: CAPTURE_PHASE_SCHEMA, phase: name, status, remainingMs: remaining };
|
||||
onPhase?.(lastPhase);
|
||||
};
|
||||
|
||||
phase("browser", "started");
|
||||
|
||||
// Load .env file from repo root if it exists (for GEMINI_API_KEY, etc.)
|
||||
loadEnvFile(outputDir);
|
||||
|
||||
// Create output directories
|
||||
mkdirSync(join(outputDir, "extracted"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "assets"), { recursive: true });
|
||||
|
||||
// Launch browser
|
||||
progress("browser", "Launching headless Chrome...");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const browser = await ensureBrowser();
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
protocolTimeout: captureProtocolTimeoutMs(timeout, budgetMs),
|
||||
args: captureBrowserArgs(disableWebgl, viewportWidth, viewportHeight),
|
||||
});
|
||||
watchdog.registerBrowser(chromeBrowser);
|
||||
|
||||
let animationCatalog: CaptureResult["animationCatalog"];
|
||||
let tokens: DesignTokens = state.tokens;
|
||||
let extracted: ExtractedHtml = state.extracted;
|
||||
let screenshots: string[] = [];
|
||||
let catalogedAssets: import("./assetCataloger.js").CatalogedAsset[] = [];
|
||||
let capturedShaders: Array<{ type: string; source: string }> | undefined;
|
||||
let visibleTextContent = "";
|
||||
let faviconLinks: IconCandidate[] = [];
|
||||
let detectedLibraries: Awaited<ReturnType<typeof detectLibraries>> = [];
|
||||
let assets: CaptureResult["assets"] = [];
|
||||
let dropped = noDrops();
|
||||
let fontDrops = noDrops();
|
||||
let httpStatus: number | null = null;
|
||||
let pageContentCheck = {
|
||||
textLength: 0,
|
||||
title: "",
|
||||
hasChallengeElement: false,
|
||||
bodyChildCount: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
let contentCheckTimedOut = false;
|
||||
|
||||
try {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PASS 1: Full page load — all JS runs
|
||||
// Goal: Catalog animations + take screenshots (with JS rendering)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
phase("browser", "completed");
|
||||
phase("navigation", "started");
|
||||
progress("animations", "Cataloging animations (full JS)...");
|
||||
|
||||
const page1 = await chromeBrowser.newPage();
|
||||
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||
await page1.setUserAgent(CAPTURE_USER_AGENT);
|
||||
|
||||
// Set up hooks BEFORE navigation
|
||||
await setupAnimationCapture(page1);
|
||||
const { cdp, animations: cdpAnims } = await startCdpAnimationCapture(page1);
|
||||
|
||||
// Hook WebGL to capture shader source code (GLSL)
|
||||
// Captured shaders inform Claude Code about the site's visual effects
|
||||
// and enable reliable library detection (Three.js/PixiJS/Babylon.js uniforms survive bundling)
|
||||
await page1.evaluateOnNewDocument(`
|
||||
var origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
window.__capturedShaders = [];
|
||||
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
|
||||
var ctx = origGetContext.call(this, type, attrs);
|
||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||
window.__hfWebglSeen = true;
|
||||
if (ctx.shaderSource && !ctx.__hfHooked) {
|
||||
var origShaderSource = ctx.shaderSource.bind(ctx);
|
||||
ctx.shaderSource = function(shader, source) {
|
||||
try {
|
||||
var shaderType = ctx.getShaderParameter(shader, ctx.SHADER_TYPE);
|
||||
window.__capturedShaders.push({
|
||||
type: shaderType === ctx.VERTEX_SHADER ? 'vertex' : 'fragment',
|
||||
source: source.slice(0, 5000)
|
||||
});
|
||||
} catch(e) {}
|
||||
return origShaderSource(shader, source);
|
||||
};
|
||||
ctx.__hfHooked = true;
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
`);
|
||||
|
||||
// Intercept network responses to detect Lottie JSON files
|
||||
const discoveredLotties: DiscoveredLottie[] = [];
|
||||
const lottieDiscovery = new LottieDiscovery();
|
||||
// Layer 1 (passive video discovery): every direct-video URL the page fetches
|
||||
// over the whole session (load / scroll / carousel rotation), independent of
|
||||
// whether a <video> for it exists at snapshot time. captureVideoManifest
|
||||
// downloads these (guarded) and merges them into the manifest.
|
||||
const discoveredVideoUrls = new Set<string>();
|
||||
page1.on("response", (response) => {
|
||||
try {
|
||||
const responseUrl = response.url();
|
||||
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
|
||||
discoveredVideoUrls.add(responseUrl);
|
||||
}
|
||||
lottieDiscovery.collect(response);
|
||||
} catch {
|
||||
/* not JSON or parse error — skip */
|
||||
}
|
||||
});
|
||||
|
||||
const navigationResult = await runNavigationChecks({
|
||||
page1,
|
||||
url,
|
||||
timeout,
|
||||
settleTime,
|
||||
budgetMs,
|
||||
warnings,
|
||||
progress,
|
||||
remainingMs,
|
||||
pageContentCheck,
|
||||
contentCheckTimedOut,
|
||||
postNavigationDeadline,
|
||||
outputDir,
|
||||
phase,
|
||||
httpStatus,
|
||||
canWrite,
|
||||
});
|
||||
({ pageContentCheck, contentCheckTimedOut, httpStatus, postNavigationDeadline } =
|
||||
navigationResult);
|
||||
|
||||
const coreResult = await runCoreExtraction({
|
||||
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,
|
||||
});
|
||||
({
|
||||
animationCatalog,
|
||||
capturedShaders,
|
||||
catalogedAssets,
|
||||
detectedLibraries,
|
||||
visibleTextContent,
|
||||
faviconLinks,
|
||||
tokens,
|
||||
extracted,
|
||||
screenshots,
|
||||
} = coreResult);
|
||||
|
||||
phase("core-extraction", "completed");
|
||||
const postResult = await runPostExtraction({
|
||||
state,
|
||||
outputDir,
|
||||
warnings,
|
||||
progress,
|
||||
remainingMs,
|
||||
phase,
|
||||
animationCatalog,
|
||||
catalogedAssets,
|
||||
visibleTextContent,
|
||||
faviconLinks,
|
||||
tokens,
|
||||
extracted,
|
||||
skipAssets,
|
||||
skipVision,
|
||||
downloadByteBudget,
|
||||
assets,
|
||||
dropped,
|
||||
fontDrops,
|
||||
canWrite,
|
||||
});
|
||||
({ assets, dropped, fontDrops, extracted, tokens, animationCatalog } = postResult);
|
||||
|
||||
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
|
||||
phase("scaffold", "started");
|
||||
if (!watchdog.expired()) {
|
||||
await generateProjectScaffold(
|
||||
outputDir,
|
||||
url,
|
||||
tokens,
|
||||
animationCatalog,
|
||||
screenshots.length > 0,
|
||||
discoveredLotties.length > 0,
|
||||
existsSync(join(outputDir, "extracted", "shaders.json")),
|
||||
catalogedAssets,
|
||||
progress,
|
||||
warnings,
|
||||
detectedLibraries,
|
||||
);
|
||||
}
|
||||
phase("scaffold", "completed");
|
||||
|
||||
progress("done", "Capture complete");
|
||||
phase("complete", "completed");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
projectDir: outputDir,
|
||||
url,
|
||||
httpStatus,
|
||||
title: tokens.title,
|
||||
extracted,
|
||||
screenshots,
|
||||
tokens,
|
||||
assets,
|
||||
dropped,
|
||||
animationCatalog,
|
||||
warnings,
|
||||
lastPhase,
|
||||
};
|
||||
} finally {
|
||||
watchdog.unregisterBrowser(chromeBrowser);
|
||||
await chromeBrowser.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class NavigationDeadlineError extends Error {
|
||||
constructor(
|
||||
readonly cause: unknown,
|
||||
readonly webglObserved: boolean,
|
||||
) {
|
||||
super("capture navigation timed out");
|
||||
this.name = "NavigationDeadlineError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
export function writeCaptureFileSync(
|
||||
path: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
options?: Parameters<typeof writeFileSync>[2],
|
||||
): void {
|
||||
const normalized = typeof options === "string" ? { encoding: options } : options;
|
||||
writeFileSync(path, data, { ...normalized, mode: 0o600 });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCaptureWatchdog, parseCaptureDeadline, runWithWatchdog } from "./captureWatchdog.js";
|
||||
|
||||
describe("parseCaptureDeadline", () => {
|
||||
it.each([
|
||||
[undefined, undefined],
|
||||
["", undefined],
|
||||
["0", undefined],
|
||||
["1.5", undefined],
|
||||
["240000", 240000],
|
||||
])("parses %s as %s", (raw, expected) => expect(parseCaptureDeadline(raw)).toBe(expected));
|
||||
});
|
||||
|
||||
describe("createCaptureWatchdog", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("closes the registered browser and resolves the deadline signal", async () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const watchdog = createCaptureWatchdog(1000);
|
||||
watchdog.registerBrowser({ close });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(watchdog.promise).resolves.toBeUndefined();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(watchdog.expired()).toBe(true);
|
||||
watchdog.dispose();
|
||||
});
|
||||
|
||||
it("observes a losing attempt rejection instead of leaving it unhandled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const watchdog = createCaptureWatchdog(1);
|
||||
const work = new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error("browser closed")), 10);
|
||||
});
|
||||
const resultPromise = runWithWatchdog(work, watchdog.promise);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(resultPromise).resolves.toEqual({ kind: "deadline" });
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
watchdog.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface CaptureWatchdogBrowser {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CaptureWatchdog {
|
||||
readonly expired: () => boolean;
|
||||
readonly promise: Promise<void>;
|
||||
readonly registerBrowser: (browser: CaptureWatchdogBrowser) => void;
|
||||
readonly unregisterBrowser: (browser: CaptureWatchdogBrowser) => void;
|
||||
readonly dispose: () => void;
|
||||
}
|
||||
|
||||
export type CaptureAttempt<T> =
|
||||
| { kind: "complete"; result: T }
|
||||
| { kind: "error"; error: unknown }
|
||||
| { kind: "deadline" };
|
||||
|
||||
export async function runWithWatchdog<T>(
|
||||
work: Promise<T>,
|
||||
deadline: Promise<void>,
|
||||
): Promise<CaptureAttempt<T>> {
|
||||
const observedWork = work.then(
|
||||
(result): CaptureAttempt<T> => ({ kind: "complete", result }),
|
||||
(error): CaptureAttempt<T> => ({ kind: "error", error }),
|
||||
);
|
||||
return await Promise.race([
|
||||
observedWork,
|
||||
deadline.then((): CaptureAttempt<T> => ({ kind: "deadline" })),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createCaptureWatchdog(deadlineMs: number | undefined): CaptureWatchdog {
|
||||
let browser: CaptureWatchdogBrowser | undefined;
|
||||
let didExpire = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let resolveDeadline: () => void = () => {};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolveDeadline = resolve;
|
||||
});
|
||||
|
||||
if (deadlineMs !== undefined) {
|
||||
timer = setTimeout(() => {
|
||||
didExpire = true;
|
||||
resolveDeadline();
|
||||
void browser?.close().catch(() => undefined);
|
||||
}, deadlineMs);
|
||||
}
|
||||
|
||||
return {
|
||||
expired: () => didExpire,
|
||||
promise,
|
||||
registerBrowser: (nextBrowser) => {
|
||||
browser = nextBrowser;
|
||||
if (didExpire) void browser.close().catch(() => undefined);
|
||||
},
|
||||
unregisterBrowser: (closedBrowser) => {
|
||||
if (browser === closedBrowser) browser = undefined;
|
||||
},
|
||||
dispose: () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCaptureDeadline(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined || raw === "") return undefined;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import sharp, { type OverlayOptions } from "sharp";
|
||||
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname, basename, dirname } from "node:path";
|
||||
|
||||
interface ContactSheetOptions {
|
||||
@@ -326,7 +327,7 @@ export async function createSvgContactSheet(
|
||||
.flatten({ background: { r: 245, g: 245, b: 245 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
writeFileSync(tmpPath, thumb);
|
||||
writeCaptureFileSync(tmpPath, thumb);
|
||||
tmpPaths.push(tmpPath);
|
||||
labels.push(svgFileNames[i]!.replace(".svg", ""));
|
||||
} catch {
|
||||
|
||||
@@ -463,7 +463,7 @@ export async function captionImagesWithGemini(
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (file: string) => {
|
||||
const filePath = join(outputDir, "assets", file);
|
||||
const fd = openSync(filePath, "r");
|
||||
const fd = openSync(filePath, "r", 0o600);
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
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 { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.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 { 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;
|
||||
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[];
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
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;
|
||||
mkdirSync(lottieDir, { recursive: true });
|
||||
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
|
||||
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, 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();
|
||||
|
||||
// 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();
|
||||
|
||||
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();
|
||||
|
||||
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 =
|
||||
"animation catalog evaluate timed out; continuing without animation catalog";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const message = "animation catalog evaluate timed out; continuing without animation catalog";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
try {
|
||||
await cdp.send("Animation.disable");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
progress("screenshots", "Capturing scroll screenshots...");
|
||||
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
|
||||
try {
|
||||
if (!canWrite()) return;
|
||||
screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
|
||||
state.screenshots = screenshots;
|
||||
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const message = "scroll screenshots timed out; continuing without screenshots";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
}
|
||||
};
|
||||
await runAnimationCapture();
|
||||
|
||||
const runHtmlExtraction = async (): Promise<void> => {
|
||||
// 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 {
|
||||
/* 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 {
|
||||
animationCatalog,
|
||||
capturedShaders,
|
||||
catalogedAssets,
|
||||
detectedLibraries,
|
||||
visibleTextContent,
|
||||
faviconLinks,
|
||||
tokens,
|
||||
extracted,
|
||||
screenshots,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterExtractedScripts } from "./filterExtractedScripts.js";
|
||||
|
||||
describe("filterExtractedScripts", () => {
|
||||
it("removes framework scripts while preserving surrounding markup", () => {
|
||||
const result = filterExtractedScripts(
|
||||
"<main><h1>Welcome</h1><script>self.__next_f.push([])</script></main>",
|
||||
'<style>body{color:red}</style><script src="/_next/static/chunks/main.js"></script>',
|
||||
);
|
||||
|
||||
expect(result.bodyHtml).toContain("<main><h1>Welcome</h1></main>");
|
||||
expect(result.headHtml).toContain("<style>body{color:red}</style>");
|
||||
expect(result.bodyHtml).not.toContain("__next_f");
|
||||
expect(result.headHtml).not.toContain("/_next/static/chunks/main.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const FRAMEWORK_SRC_PATTERNS = [
|
||||
/_next\/static\/chunks\/(main|framework|webpack|pages\/)/,
|
||||
/_next\/static\/chunks\/app\//,
|
||||
/_buildManifest\.js/,
|
||||
/_ssgManifest\.js/,
|
||||
];
|
||||
|
||||
const NEXT_BOOTSTRAP_MARKERS = [
|
||||
"__next_f",
|
||||
"self.__next_f",
|
||||
"__NEXT_LOADED_PAGES__",
|
||||
"_N_E",
|
||||
"__NEXT_P",
|
||||
];
|
||||
|
||||
function removeMatchingScripts(source: string, shouldRemove: (script: Element) => boolean): string {
|
||||
const rootId = "__hf_root";
|
||||
const { document } = parseHTML(`<div id="${rootId}">${source}</div>`);
|
||||
for (const script of document.querySelectorAll("script")) {
|
||||
if (script.id === "__NEXT_DATA__" || shouldRemove(script)) script.remove();
|
||||
}
|
||||
return document.getElementById(rootId)?.innerHTML ?? "";
|
||||
}
|
||||
|
||||
export function filterExtractedScripts(
|
||||
bodyHtml: string,
|
||||
headHtml: string,
|
||||
): { bodyHtml: string; headHtml: string } {
|
||||
const body = removeMatchingScripts(bodyHtml, (script) =>
|
||||
NEXT_BOOTSTRAP_MARKERS.some((marker) => script.textContent?.includes(marker)),
|
||||
);
|
||||
const head = removeMatchingScripts(headHtml, (script) => {
|
||||
const src = script.getAttribute("src") ?? "";
|
||||
return FRAMEWORK_SRC_PATTERNS.some((pattern) => pattern.test(src));
|
||||
});
|
||||
return { bodyHtml: body, headHtml: head };
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
* aggregation. See FontsManifest type for shape.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
import * as fontkit from "fontkit";
|
||||
import type { Font, FontCollection } from "fontkit";
|
||||
@@ -139,7 +140,7 @@ export function extractFontMetadata(fontsDir: string, outputPath: string): Fonts
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
|
||||
writeCaptureFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,924 +1,61 @@
|
||||
import { LottieDiscovery } from "./lottieDiscovery.js";
|
||||
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
/**
|
||||
* Website capture orchestrator.
|
||||
*
|
||||
* Two-pass capture approach:
|
||||
* Pass 1: Full page load (all JS) → catalog animations + snapshot canvases
|
||||
* Pass 2: Framework scripts blocked → extract stable HTML/CSS
|
||||
*
|
||||
* This ensures we get both:
|
||||
* - Rich animation metadata for Claude Code to recreate
|
||||
* - Stable, renderable HTML that won't crash in Puppeteer
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { extractHtml } from "./htmlExtractor.js";
|
||||
// captureScreenshots removed — full-page screenshot replaces per-section shots
|
||||
import { extractTokens } from "./tokenExtractor.js";
|
||||
import { extractDesignStyles } from "./designStyleExtractor.js";
|
||||
import {
|
||||
downloadAssets,
|
||||
downloadAndRewriteFonts,
|
||||
mergeDrops,
|
||||
noDrops,
|
||||
totalDrops,
|
||||
} from "./assetDownloader.js";
|
||||
import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
import { extractFontMetadata } from "./fontMetadataExtractor.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { diag } from "../ui/diagnostics.js";
|
||||
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
|
||||
import {
|
||||
setupAnimationCapture,
|
||||
startCdpAnimationCapture,
|
||||
collectAnimationCatalog,
|
||||
} from "./animationCataloger.js";
|
||||
import {
|
||||
saveLottieAnimations,
|
||||
renderLottiePreviews,
|
||||
captureVideoManifest,
|
||||
} from "./mediaCapture.js";
|
||||
import type { DiscoveredLottie } from "./mediaCapture.js";
|
||||
import {
|
||||
detectLibraries,
|
||||
extractVisibleText,
|
||||
captionImagesWithGemini,
|
||||
generateAssetDescriptions,
|
||||
resolveVisionPhaseCompletion,
|
||||
} from "./contentExtractor.js";
|
||||
import type { VisionCaptionOutcome } from "./contentExtractor.js";
|
||||
import { loadEnvFile, generateProjectScaffold } from "./scaffolding.js";
|
||||
import { detectBlockedPage } from "./pageBlockDetection.js";
|
||||
import { writeResponseRecord } from "./responseRecord.js";
|
||||
import { navigateForCapture } from "./navigateForCapture.js";
|
||||
import {
|
||||
captureProtocolTimeoutMs,
|
||||
isDegradableEvaluateTimeoutError,
|
||||
withRemainingBudget,
|
||||
} from "./captureTimeout.js";
|
||||
import { lazyScrollForCapture } from "./lazyScrollForCapture.js";
|
||||
import type { CaptureOptions, CapturePhase, CapturePhaseProgress, CaptureResult } from "./types.js";
|
||||
import { createCaptureWatchdog, runWithWatchdog } from "./captureWatchdog.js";
|
||||
import { createPartialCaptureState, writePartialCaptureBundle } from "./partialCapture.js";
|
||||
import type { PartialCaptureState } from "./partialCapture.js";
|
||||
import { captureWebsiteAttempt } from "./captureAttempt.js";
|
||||
import { NavigationDeadlineError } from "./captureErrors.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
|
||||
import type { CaptureOptions, CaptureResult } from "./types.js";
|
||||
|
||||
export type { CaptureOptions, CaptureResult } from "./types.js";
|
||||
|
||||
const DEFAULT_POST_NAVIGATION_BUDGET_MS = 120_000;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function captureWebsite(
|
||||
opts: CaptureOptions,
|
||||
onProgress?: (stage: string, detail?: string) => void,
|
||||
): Promise<CaptureResult> {
|
||||
const {
|
||||
url,
|
||||
outputDir,
|
||||
viewportWidth = 1920,
|
||||
viewportHeight = 1080,
|
||||
timeout = 120000,
|
||||
settleTime = 3000,
|
||||
maxScreenshots: _maxScreenshots = 24,
|
||||
skipAssets = false,
|
||||
skipVision = false,
|
||||
postNavigationBudgetMs = DEFAULT_POST_NAVIGATION_BUDGET_MS,
|
||||
onPhase,
|
||||
} = opts;
|
||||
|
||||
const downloadByteBudget = createCaptureDownloadBudget();
|
||||
const warnings: string[] = [];
|
||||
const progress = (stage: string, detail?: string) => {
|
||||
onProgress?.(stage, detail);
|
||||
};
|
||||
const budgetMs =
|
||||
Number.isFinite(postNavigationBudgetMs) && postNavigationBudgetMs > 0
|
||||
? postNavigationBudgetMs
|
||||
: DEFAULT_POST_NAVIGATION_BUDGET_MS;
|
||||
let postNavigationDeadline: number | undefined;
|
||||
const remainingMs = (): number =>
|
||||
postNavigationDeadline === undefined
|
||||
? budgetMs
|
||||
: Math.max(0, postNavigationDeadline - Date.now());
|
||||
let lastPhase: CapturePhaseProgress = {
|
||||
schema: "hyperframes.capture.phase.v1",
|
||||
phase: "browser",
|
||||
status: "started",
|
||||
remainingMs: null,
|
||||
};
|
||||
const phase = (
|
||||
name: CapturePhase,
|
||||
status: CapturePhaseProgress["status"],
|
||||
reason?: CapturePhaseProgress["reason"],
|
||||
): void => {
|
||||
const remaining = postNavigationDeadline === undefined ? null : remainingMs();
|
||||
lastPhase = reason
|
||||
? {
|
||||
schema: "hyperframes.capture.phase.v1",
|
||||
phase: name,
|
||||
status,
|
||||
remainingMs: remaining,
|
||||
reason,
|
||||
}
|
||||
: { schema: "hyperframes.capture.phase.v1", phase: name, status, remainingMs: remaining };
|
||||
onPhase?.(lastPhase);
|
||||
};
|
||||
|
||||
phase("browser", "started");
|
||||
|
||||
// Load .env file from repo root if it exists (for GEMINI_API_KEY, etc.)
|
||||
loadEnvFile(outputDir);
|
||||
|
||||
// Create output directories
|
||||
mkdirSync(join(outputDir, "extracted"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
|
||||
mkdirSync(join(outputDir, "assets"), { recursive: true });
|
||||
|
||||
// Launch browser
|
||||
progress("browser", "Launching headless Chrome...");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const browser = await ensureBrowser();
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
protocolTimeout: captureProtocolTimeoutMs(timeout, budgetMs),
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
`--window-size=${viewportWidth},${viewportHeight}`,
|
||||
],
|
||||
});
|
||||
|
||||
let animationCatalog: CaptureResult["animationCatalog"];
|
||||
|
||||
const watchdog = createCaptureWatchdog(opts.captureDeadlineMs);
|
||||
const state = createPartialCaptureState(opts);
|
||||
const attempt = captureWebsiteAttempt(opts, onProgress, false, watchdog, state);
|
||||
try {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PASS 1: Full page load — all JS runs
|
||||
// Goal: Catalog animations + take screenshots (with JS rendering)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
phase("browser", "completed");
|
||||
phase("navigation", "started");
|
||||
progress("animations", "Cataloging animations (full JS)...");
|
||||
|
||||
const page1 = await chromeBrowser.newPage();
|
||||
await page1.setViewport({ width: viewportWidth, height: viewportHeight });
|
||||
await page1.setUserAgent(CAPTURE_USER_AGENT);
|
||||
|
||||
// Set up hooks BEFORE navigation
|
||||
await setupAnimationCapture(page1);
|
||||
const { cdp, animations: cdpAnims } = await startCdpAnimationCapture(page1);
|
||||
|
||||
// Hook WebGL to capture shader source code (GLSL)
|
||||
// Captured shaders inform Claude Code about the site's visual effects
|
||||
// and enable reliable library detection (Three.js/PixiJS/Babylon.js uniforms survive bundling)
|
||||
await page1.evaluateOnNewDocument(`
|
||||
var origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
window.__capturedShaders = [];
|
||||
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
|
||||
var ctx = origGetContext.call(this, type, attrs);
|
||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||
if (ctx.shaderSource && !ctx.__hfHooked) {
|
||||
var origShaderSource = ctx.shaderSource.bind(ctx);
|
||||
ctx.shaderSource = function(shader, source) {
|
||||
try {
|
||||
var shaderType = ctx.getShaderParameter(shader, ctx.SHADER_TYPE);
|
||||
window.__capturedShaders.push({
|
||||
type: shaderType === ctx.VERTEX_SHADER ? 'vertex' : 'fragment',
|
||||
source: source.slice(0, 5000)
|
||||
});
|
||||
} catch(e) {}
|
||||
return origShaderSource(shader, source);
|
||||
};
|
||||
ctx.__hfHooked = true;
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
`);
|
||||
|
||||
// Intercept network responses to detect Lottie JSON files
|
||||
const discoveredLotties: DiscoveredLottie[] = [];
|
||||
const lottieDiscovery = new LottieDiscovery();
|
||||
// Layer 1 (passive video discovery): every direct-video URL the page fetches
|
||||
// over the whole session (load / scroll / carousel rotation), independent of
|
||||
// whether a <video> for it exists at snapshot time. captureVideoManifest
|
||||
// downloads these (guarded) and merges them into the manifest.
|
||||
const discoveredVideoUrls = new Set<string>();
|
||||
// fallow-ignore-next-line complexity
|
||||
page1.on("response", (response) => {
|
||||
try {
|
||||
const responseUrl = response.url();
|
||||
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
|
||||
discoveredVideoUrls.add(responseUrl);
|
||||
}
|
||||
lottieDiscovery.collect(response);
|
||||
} catch {
|
||||
/* not JSON or parse error — skip */
|
||||
}
|
||||
const first = await runWithWatchdog(attempt, watchdog.promise);
|
||||
if (first.kind === "deadline") return deadlineResult(opts, state);
|
||||
if (first.kind === "error") throw first.error;
|
||||
return first.result;
|
||||
} catch (err) {
|
||||
if (!(err instanceof NavigationDeadlineError) || watchdog.expired() || !err.webglObserved) {
|
||||
throw err;
|
||||
}
|
||||
onProgress?.("warn", "Navigation timed out; retrying once with WebGL disabled");
|
||||
state.warnings.push("Navigation timed out; retrying once with WebGL disabled");
|
||||
opts.onPhase?.({
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "navigation",
|
||||
status: "degraded",
|
||||
remainingMs: null,
|
||||
reason: "webgl-disabled-retry",
|
||||
});
|
||||
|
||||
const navigation = await navigateForCapture(page1, url, timeout);
|
||||
const navigationResponse = navigation.response;
|
||||
if (navigation.fellBackFromNetworkIdle) {
|
||||
warnings.push(
|
||||
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continued with domcontentloaded`,
|
||||
);
|
||||
progress(
|
||||
"warn",
|
||||
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continuing with domcontentloaded`,
|
||||
);
|
||||
}
|
||||
postNavigationDeadline = Date.now() + budgetMs;
|
||||
await new Promise((r) => setTimeout(r, settleTime));
|
||||
|
||||
let pageContentCheck: {
|
||||
textLength: number;
|
||||
title: string;
|
||||
hasChallengeElement: boolean;
|
||||
bodyChildCount: number;
|
||||
} = {
|
||||
textLength: 0,
|
||||
title: "",
|
||||
hasChallengeElement: false,
|
||||
bodyChildCount: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
let contentCheckTimedOut = false;
|
||||
try {
|
||||
pageContentCheck = (await withRemainingBudget(
|
||||
page1.evaluate(`(() => {
|
||||
var text = (document.body && document.body.innerText || "").trim();
|
||||
var title = document.title || "";
|
||||
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
|
||||
var bodyChildCount = document.body ? document.body.children.length : 0;
|
||||
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
|
||||
})()`),
|
||||
Math.min(5_000, remainingMs()),
|
||||
"content-check",
|
||||
)) as typeof pageContentCheck;
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
contentCheckTimedOut = true;
|
||||
const message =
|
||||
"post-navigation content check timed out; continuing with HTTP-status blocked-page detection only";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
}
|
||||
|
||||
// Persisted before the blocked-page check, so a capture that reaches navigation always leaves
|
||||
// a record of what the server said. That makes the file's ABSENCE mean "capture never got a
|
||||
// response", which is a third state distinct from a status of 404 and from a status of null.
|
||||
const httpStatus = navigationResponse?.status() ?? null;
|
||||
writeResponseRecord(join(outputDir, "extracted"), { status: httpStatus });
|
||||
|
||||
const blockedReason = detectBlockedPage({
|
||||
httpStatus,
|
||||
...(contentCheckTimedOut
|
||||
? {
|
||||
title: "",
|
||||
textLength: 0,
|
||||
bodyChildCount: 0,
|
||||
hasChallengeElement: false,
|
||||
}
|
||||
: pageContentCheck),
|
||||
});
|
||||
if (blockedReason) {
|
||||
phase("navigation", "degraded", "blocked");
|
||||
throw new Error(blockedReason);
|
||||
}
|
||||
|
||||
phase("navigation", "completed");
|
||||
phase("core-extraction", "started");
|
||||
|
||||
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));
|
||||
|
||||
// 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) => l.url === lottieUrl)
|
||||
) {
|
||||
discoveredLotties.push({ url: lottieUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* DOM scan failed — non-critical */
|
||||
}
|
||||
|
||||
for (const found of await lottieDiscovery.run(downloadByteBudget, remainingMs)) {
|
||||
const existing = discoveredLotties.findIndex((item) => item.url === found.url);
|
||||
if (existing < 0) discoveredLotties.push(found);
|
||||
else discoveredLotties[existing] = found;
|
||||
}
|
||||
|
||||
if (discoveredLotties.length > 0 && remainingMs() > 0) {
|
||||
const lottieDir = join(outputDir, "assets", "lottie");
|
||||
mkdirSync(lottieDir, { recursive: true });
|
||||
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
|
||||
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
|
||||
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
|
||||
if (savedCount > 0 && remainingMs() > 0) {
|
||||
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
|
||||
progress("lottie", `${savedCount} Lottie animation(s) saved`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save captured WebGL shaders (useful context for shader transitions + library detection)
|
||||
let capturedShaders: Array<{ type: string; source: string }> | undefined;
|
||||
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;
|
||||
writeFileSync(
|
||||
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 */
|
||||
}
|
||||
|
||||
// ── READ-ONLY phase: extract data from the live DOM before any mutations ──
|
||||
// extractHtml (below) converts image src to data URLs and removes scripts —
|
||||
// all read-only operations must run BEFORE it to see the original DOM.
|
||||
|
||||
// Extract design tokens
|
||||
progress("tokens", "Extracting design tokens...");
|
||||
const tokens = await extractTokens(page1);
|
||||
// Save tokens.json without SVG outerHTML (kept in memory for asset downloader)
|
||||
const tokensForDisk = {
|
||||
...tokens,
|
||||
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
|
||||
};
|
||||
writeFileSync(
|
||||
join(outputDir, "extracted", "tokens.json"),
|
||||
JSON.stringify(tokensForDisk, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Extract computed design styles (typography, buttons, cards, spacing, shadows)
|
||||
progress("style", "Extracting design styles...");
|
||||
try {
|
||||
const designStyles = await extractDesignStyles(page1);
|
||||
writeFileSync(
|
||||
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}`);
|
||||
}
|
||||
|
||||
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 =
|
||||
"animation catalog evaluate timed out; continuing without animation catalog";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const message = "animation catalog evaluate timed out; continuing without animation catalog";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
try {
|
||||
await cdp.send("Animation.disable");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
progress("screenshots", "Capturing scroll screenshots...");
|
||||
const { captureScrollScreenshots } = await import("./screenshotCapture.js");
|
||||
let screenshots: string[] = [];
|
||||
try {
|
||||
screenshots = await captureScrollScreenshots(page1, outputDir, { remainingMs });
|
||||
progress("screenshots", `${screenshots.length} scroll screenshots captured`);
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const message = "scroll screenshots timed out; continuing without screenshots";
|
||||
warnings.push(message);
|
||||
progress("warn", message);
|
||||
}
|
||||
|
||||
// Catalog all assets (must run before extractHtml which converts img src to data URLs)
|
||||
progress("design", "Cataloging assets...");
|
||||
let catalogedAssets: import("./assetCataloger.js").CatalogedAsset[] = [];
|
||||
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...");
|
||||
const extracted = await extractHtml(page1, { settleTime: 1000 });
|
||||
|
||||
// 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 __NEXT_DATA__ (has its own ID so safe to target)
|
||||
.replace(/<script\s+id="__NEXT_DATA__"[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
// Remove React hydration markers
|
||||
.replace(/\s*data-reactroot="[^"]*"/g, "")
|
||||
.replace(/\s*data-reactroot/g, "");
|
||||
|
||||
// Remove Next.js bootstrap scripts individually (match each script tag separately)
|
||||
extracted.bodyHtml = extracted.bodyHtml.replace(
|
||||
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
// fallow-ignore-next-line complexity
|
||||
(match: string, content: string) => {
|
||||
// Only remove if this specific script contains Next.js bootstrap code
|
||||
if (
|
||||
content.includes("__next_f") ||
|
||||
content.includes("self.__next_f") ||
|
||||
content.includes("__NEXT_LOADED_PAGES__") ||
|
||||
content.includes("_N_E") ||
|
||||
content.includes("__NEXT_P")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
|
||||
// Strip framework script tags from head (keep styles + visual library scripts)
|
||||
const FRAMEWORK_SRC_PATTERNS = [
|
||||
/_next\/static\/chunks\/(main|framework|webpack|pages\/)/,
|
||||
/_next\/static\/chunks\/app\//,
|
||||
/_buildManifest\.js/,
|
||||
/_ssgManifest\.js/,
|
||||
];
|
||||
extracted.headHtml = extracted.headHtml.replace(
|
||||
/<script[^>]*src="([^"]*)"[^>]*><\/script>/gi,
|
||||
(match: string, src: string) => {
|
||||
if (FRAMEWORK_SRC_PATTERNS.some((p) => p.test(src))) return "";
|
||||
return match;
|
||||
},
|
||||
);
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
/* non-blocking — video manifest is best-effort */
|
||||
}
|
||||
|
||||
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
|
||||
const detectedLibraries = await detectLibraries(page1, capturedShaders);
|
||||
|
||||
// Extract all visible text in DOM order
|
||||
const 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.
|
||||
const 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();
|
||||
|
||||
phase("core-extraction", "completed");
|
||||
|
||||
// Download fonts and rewrite URLs to local paths.
|
||||
//
|
||||
// Called even with the budget already gone, which is the point: its own loop is the only
|
||||
// thing that knows how many faces the page declared, so letting it run and record
|
||||
// `budget-exhausted` for every one of them replaces a warning string that could only ever
|
||||
// say "some". A zero budget means it breaks on the first url, so this costs no network.
|
||||
phase("fonts", "started");
|
||||
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
|
||||
remainingMs,
|
||||
byteBudget: downloadByteBudget,
|
||||
});
|
||||
extracted.headHtml = fontPass.css;
|
||||
phase(
|
||||
"fonts",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
|
||||
// Identify each downloaded font by reading its OpenType name table.
|
||||
// Modern frameworks hash font filenames; this manifest tells the
|
||||
// downstream pipeline (DESIGN.md authoring, beat sub-agents) which file
|
||||
// belongs to which family without guessing from filename patterns.
|
||||
try {
|
||||
const fontsManifest = extractFontMetadata(
|
||||
join(outputDir, "assets", "fonts"),
|
||||
join(outputDir, "extracted", "fonts-manifest.json"),
|
||||
);
|
||||
if (fontsManifest.families.length > 0) {
|
||||
const summary = fontsManifest.families
|
||||
.map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`)
|
||||
.join(", ");
|
||||
// stderr (via diag): `capture --json` writes its envelope to stdout, so
|
||||
// these progress/advisory lines must not land there.
|
||||
diag.notice(`Font metadata extracted: ${summary}`);
|
||||
if (fontsManifest.unidentified.length > 0) {
|
||||
diag.warn(
|
||||
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
diag.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
|
||||
}
|
||||
|
||||
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
|
||||
if (animationCatalog) {
|
||||
// Extract just what's useful: counts, named animations, a few representative keyframed entries
|
||||
const uniqueAnimNames = new Set<string>();
|
||||
for (const d of animationCatalog.cssDeclarations || []) {
|
||||
if (d.animation?.name) uniqueAnimNames.add(d.animation.name);
|
||||
}
|
||||
|
||||
// Keep up to 10 Web Animations that have actual keyframe data (most useful for recreation)
|
||||
const representativeAnims = (animationCatalog.webAnimations || [])
|
||||
.filter((a) => a.keyframes && a.keyframes.length > 0)
|
||||
.slice(0, 10);
|
||||
|
||||
const leanCatalog = {
|
||||
summary: animationCatalog.summary,
|
||||
namedAnimations: Array.from(uniqueAnimNames),
|
||||
scrollTriggeredElements: (animationCatalog.scrollTargets || []).length,
|
||||
representativeAnimations: representativeAnims,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(outputDir, "extracted", "animations.json"),
|
||||
JSON.stringify(leanCatalog, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// Download assets — single pass using the catalog for best image quality
|
||||
let assets: CaptureResult["assets"] = [];
|
||||
let assetDrops = noDrops();
|
||||
if (!skipAssets) {
|
||||
// Called even with the budget already gone, for the reason the font pass is: the loop that
|
||||
// skips an asset is the only thing that can say how many it skipped.
|
||||
phase("assets", "started");
|
||||
progress("assets", "Downloading assets...");
|
||||
const assetPass = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks, {
|
||||
remainingMs,
|
||||
byteBudget: downloadByteBudget,
|
||||
});
|
||||
assets = assetPass.assets;
|
||||
assetDrops = assetPass.drops;
|
||||
// Which icons the site declared, what each one is, and why one became favicon.<ext>.
|
||||
// The brand-kit consumer needs the shape to decide which tile an icon belongs in; the
|
||||
// reason is what stops a substituted headline from being silent again.
|
||||
writeFileSync(
|
||||
join(outputDir, "extracted", "icons-manifest.json"),
|
||||
JSON.stringify(assetPass.icons, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
phase(
|
||||
"assets",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
} else {
|
||||
phase("assets", "degraded", "disabled");
|
||||
}
|
||||
// One capture-wide tally, summed from the two passes that own the drops. The warning is
|
||||
// DERIVED from it rather than written alongside it, so the prose and the number cannot
|
||||
// disagree the way two separately-authored budget strings could.
|
||||
const dropped = mergeDrops(fontPass.drops, assetDrops);
|
||||
const droppedTotal = totalDrops(dropped);
|
||||
if (droppedTotal > 0) {
|
||||
const breakdown = Object.entries(dropped)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([reason, n]) => `${n} ${reason}`)
|
||||
.join(", ");
|
||||
warnings.push(
|
||||
`${droppedTotal} referenced asset(s) are not in this capture (${breakdown}). ` +
|
||||
"A thin capture with no drops is a thin page; this one was truncated.",
|
||||
);
|
||||
}
|
||||
|
||||
// Join in-section media URLs → downloaded local paths, then re-write
|
||||
// tokens.json. Downstream page recreation MUST reference local files:
|
||||
// remote URLs fail at render time (hotlink/CORS 403, no egress in
|
||||
// Docker/Lambda, frame-timing blanks for not-yet-loaded images).
|
||||
if (assets.length && Array.isArray(tokens.sections)) {
|
||||
const base = (u: string): string => u.split(/[#?]/)[0] ?? u;
|
||||
const localByUrl = new Map<string, string>();
|
||||
for (const a of assets) {
|
||||
if (!a.url || !a.localPath) continue;
|
||||
localByUrl.set(a.url, a.localPath);
|
||||
localByUrl.set(base(a.url), a.localPath);
|
||||
}
|
||||
for (const sec of tokens.sections) {
|
||||
const local: string[] = [];
|
||||
for (const u of sec.assetUrls || []) {
|
||||
const hit = localByUrl.get(u) || localByUrl.get(base(u));
|
||||
if (hit && !local.includes(hit)) local.push(hit);
|
||||
}
|
||||
if (local.length) sec.assets = local;
|
||||
}
|
||||
const tokensForDisk2 = {
|
||||
...tokens,
|
||||
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
|
||||
};
|
||||
writeFileSync(
|
||||
join(outputDir, "extracted", "tokens.json"),
|
||||
JSON.stringify(tokensForDisk2, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// Persist a self-contained page recreation (extracted/page.html) as the
|
||||
// high-fidelity structural reference for the page-card rebuild. NOT a
|
||||
// composition — kept under extracted/ so the producer (which discovers
|
||||
// compositions by index.html) never picks it up. Images are already inlined
|
||||
// as data URLs by extractHtml, so it renders standalone.
|
||||
try {
|
||||
const pageHtml = `<!doctype html>\n<html ${extracted.htmlAttrs || ""}>\n<head>\n${extracted.headHtml}\n</head>\n<body>\n${extracted.bodyHtml}\n</body>\n</html>\n`;
|
||||
writeFileSync(join(outputDir, "extracted", "page.html"), pageHtml, "utf-8");
|
||||
} catch (err) {
|
||||
warnings.push(`page.html write failed: ${err}`);
|
||||
}
|
||||
|
||||
// Save visible text content for AI agent to use
|
||||
if (visibleTextContent) {
|
||||
writeFileSync(join(outputDir, "extracted", "visible-text.txt"), visibleTextContent, "utf-8");
|
||||
}
|
||||
|
||||
// detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing
|
||||
|
||||
// AI-powered image captioning via Gemini (optional — enriches asset descriptions)
|
||||
let geminiCaptions: Record<string, string> = {};
|
||||
if (skipVision) {
|
||||
phase("vision", "degraded", "disabled");
|
||||
} else if (remainingMs() <= 0) {
|
||||
warnings.push(
|
||||
"Capture budget exhausted before vision captioning; catalog descriptions were preserved.",
|
||||
);
|
||||
phase("vision", "degraded", "budget-exhausted");
|
||||
} else {
|
||||
phase("vision", "started");
|
||||
let visionOutcome: VisionCaptionOutcome = {
|
||||
timedOutRequests: 0,
|
||||
failedRequests: 0,
|
||||
budgetExhausted: false,
|
||||
};
|
||||
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
|
||||
remainingMs,
|
||||
onOutcome: (outcome) => {
|
||||
visionOutcome = outcome;
|
||||
},
|
||||
});
|
||||
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
|
||||
phase(
|
||||
"vision",
|
||||
completion.status,
|
||||
completion.status === "degraded" ? completion.reason : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate asset descriptions for the AI agent
|
||||
progress("design", "Generating asset descriptions...");
|
||||
try {
|
||||
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
|
||||
|
||||
if (lines.length > 0) {
|
||||
// Mirrors the provider gate in contentExtractor: Vertex needs a project AND a service
|
||||
// account, and is the configuration a server deployment actually has. Without it here the
|
||||
// header claimed "GEMINI_API_KEY not set — descriptions are catalog-derived" on a capture
|
||||
// whose captions Vertex had just generated, and that header is read downstream.
|
||||
const hasVisionKey = !!(
|
||||
!skipVision &&
|
||||
(process.env.OPENROUTER_API_KEY ||
|
||||
process.env.GEMINI_API_KEY ||
|
||||
process.env.GOOGLE_API_KEY ||
|
||||
(process.env.HYPERFRAMES_VERTEX_PROJECT_ID &&
|
||||
process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT))
|
||||
);
|
||||
const header = hasVisionKey
|
||||
? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file — that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n"
|
||||
: "# Asset Descriptions\n\n⚠️ No vision credentials — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n";
|
||||
writeFileSync(
|
||||
join(outputDir, "extracted", "asset-descriptions.md"),
|
||||
header + lines.map((l) => "- " + l).join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
progress(
|
||||
"design",
|
||||
`${lines.length} asset descriptions written${hasVisionKey ? "" : " (no vision provider — catalog-fallback mode)"}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
progress("design", "DESIGN.md will be created by your AI agent");
|
||||
|
||||
// Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually)
|
||||
// All functions return string[] — paginated so every image is covered
|
||||
if (remainingMs() > 0) {
|
||||
phase("contact-sheets", "started");
|
||||
try {
|
||||
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
|
||||
await import("./contactSheet.js");
|
||||
|
||||
const contactSheetBudget = { remainingMs };
|
||||
|
||||
const scrollSheets = await createScrollContactSheet(
|
||||
join(outputDir, "screenshots"),
|
||||
join(outputDir, "screenshots", "contact-sheet.jpg"),
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (scrollSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
|
||||
const assetsImgDir = join(outputDir, "assets");
|
||||
if (existsSync(assetsImgDir)) {
|
||||
const assetSheets = await createAssetContactSheet(
|
||||
assetsImgDir,
|
||||
join(outputDir, "assets", "contact-sheet.jpg"),
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (assetSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from <img src="*.svg">)
|
||||
// so sites like huly.io that only use external SVGs still get a grid
|
||||
const svgsDir = join(outputDir, "assets", "svgs");
|
||||
const assetsRootDir = join(outputDir, "assets");
|
||||
const svgOutputPath = existsSync(svgsDir)
|
||||
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
|
||||
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
|
||||
const svgSheets = await createSvgContactSheet(
|
||||
svgsDir,
|
||||
svgOutputPath,
|
||||
assetsRootDir,
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (svgSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
} catch {
|
||||
/* contact sheets are non-critical — agent can still read images individually */
|
||||
}
|
||||
phase(
|
||||
"contact-sheets",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
} else {
|
||||
warnings.push(
|
||||
"Capture budget exhausted before contact sheets; source images were preserved.",
|
||||
);
|
||||
phase("contact-sheets", "degraded", "budget-exhausted");
|
||||
}
|
||||
|
||||
// Generate project scaffold (index.html, meta.json, CLAUDE.md)
|
||||
phase("scaffold", "started");
|
||||
await generateProjectScaffold(
|
||||
outputDir,
|
||||
url,
|
||||
tokens,
|
||||
animationCatalog,
|
||||
screenshots.length > 0,
|
||||
discoveredLotties.length > 0,
|
||||
existsSync(join(outputDir, "extracted", "shaders.json")),
|
||||
catalogedAssets,
|
||||
progress,
|
||||
warnings,
|
||||
detectedLibraries,
|
||||
);
|
||||
phase("scaffold", "completed");
|
||||
|
||||
progress("done", "Capture complete");
|
||||
phase("complete", "completed");
|
||||
|
||||
const retry = captureWebsiteAttempt(opts, onProgress, true, watchdog, state);
|
||||
const second = await runWithWatchdog(retry, watchdog.promise);
|
||||
if (second.kind === "deadline") return deadlineResult(opts, state);
|
||||
if (second.kind === "error") throw second.error;
|
||||
return {
|
||||
ok: true,
|
||||
projectDir: outputDir,
|
||||
url,
|
||||
httpStatus,
|
||||
title: tokens.title,
|
||||
extracted,
|
||||
screenshots,
|
||||
tokens,
|
||||
assets,
|
||||
dropped,
|
||||
animationCatalog,
|
||||
warnings,
|
||||
lastPhase,
|
||||
...second.result,
|
||||
warnings: [...new Set([...state.warnings, ...second.result.warnings])],
|
||||
};
|
||||
} finally {
|
||||
await chromeBrowser.close();
|
||||
watchdog.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineResult(opts: CaptureOptions, state: PartialCaptureState): CaptureResult {
|
||||
const lastPhase = {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "complete" as const,
|
||||
status: "degraded" as const,
|
||||
remainingMs: null,
|
||||
reason: "deadline" as const,
|
||||
};
|
||||
opts.onPhase?.(lastPhase);
|
||||
return writePartialCaptureBundle(opts, state, lastPhase);
|
||||
}
|
||||
|
||||
// visual-style.md and capture-summary.md generators removed — DESIGN.md replaces them
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPartialCaptureState } from "./partialCapture.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
|
||||
import type { CaptureOptions, CaptureResult } from "./types.js";
|
||||
import { captureBrowserArgs } from "./browserLaunchArgs.js";
|
||||
import { NavigationDeadlineError } from "./captureErrors.js";
|
||||
|
||||
const { captureWebsiteAttemptMock } = vi.hoisted(() => ({
|
||||
captureWebsiteAttemptMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./captureAttempt.js", () => ({
|
||||
captureWebsiteAttempt: captureWebsiteAttemptMock,
|
||||
}));
|
||||
|
||||
describe("capture navigation retry launch", () => {
|
||||
it("uses the normal WebGL launch for the first attempt", () => {
|
||||
const args = captureBrowserArgs(false, 1920, 1080);
|
||||
expect(args).toContain("--enable-webgl");
|
||||
expect(args).toContain("--use-angle=swiftshader");
|
||||
expect(args).not.toContain("--disable-gpu");
|
||||
});
|
||||
|
||||
it("uses only the reduced GPU launch for the retry", () => {
|
||||
const args = captureBrowserArgs(true, 1920, 1080);
|
||||
expect(args).toContain("--disable-gpu");
|
||||
expect(args).not.toContain("--enable-webgl");
|
||||
expect(args).not.toContain("--use-angle=swiftshader");
|
||||
expect(args).not.toContain("--use-gl=angle");
|
||||
});
|
||||
|
||||
it("retries a WebGL navigation deadline and carries the degradation warning into the result", async () => {
|
||||
const options: CaptureOptions = {
|
||||
url: "https://example.com",
|
||||
outputDir: "/tmp/hf-capture-test",
|
||||
};
|
||||
const partial = createPartialCaptureState(options);
|
||||
const result: CaptureResult = {
|
||||
ok: true,
|
||||
projectDir: options.outputDir,
|
||||
url: options.url,
|
||||
httpStatus: 200,
|
||||
title: "Example",
|
||||
extracted: partial.extracted,
|
||||
screenshots: [],
|
||||
tokens: partial.tokens,
|
||||
assets: [],
|
||||
dropped: partial.dropped,
|
||||
warnings: [],
|
||||
lastPhase: {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "complete",
|
||||
status: "completed",
|
||||
remainingMs: 1,
|
||||
},
|
||||
};
|
||||
captureWebsiteAttemptMock
|
||||
.mockRejectedValueOnce(new NavigationDeadlineError(new Error("timeout"), true))
|
||||
.mockResolvedValueOnce(result);
|
||||
const phases: string[] = [];
|
||||
|
||||
const { captureWebsite } = await import("./index.js");
|
||||
const actual = await captureWebsite({
|
||||
...options,
|
||||
onPhase: (event) => phases.push(event.reason ?? event.phase),
|
||||
});
|
||||
|
||||
expect(captureWebsiteAttemptMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining(options),
|
||||
undefined,
|
||||
true,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(actual.warnings).toContain("Navigation timed out; retrying once with WebGL disabled");
|
||||
expect(phases).toContain("webgl-disabled-retry");
|
||||
});
|
||||
|
||||
it("does not retry a navigation deadline without observed WebGL", async () => {
|
||||
const options: CaptureOptions = {
|
||||
url: "https://example.com",
|
||||
outputDir: "/tmp/hf-capture-test-no-webgl",
|
||||
};
|
||||
captureWebsiteAttemptMock.mockReset();
|
||||
captureWebsiteAttemptMock.mockRejectedValueOnce(
|
||||
new NavigationDeadlineError(new Error("timeout"), false),
|
||||
);
|
||||
|
||||
const { captureWebsite } = await import("./index.js");
|
||||
await expect(captureWebsite(options)).rejects.toThrow("capture navigation timed out");
|
||||
expect(captureWebsiteAttemptMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import type { Browser, Page } from "puppeteer-core";
|
||||
import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join, extname } from "node:path";
|
||||
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
|
||||
import { CAPTURE_USER_AGENT } from "./userAgent.js";
|
||||
@@ -94,7 +95,7 @@ export async function saveLottieAnimations(
|
||||
|
||||
if (!validLottieJson(jsonData)) continue;
|
||||
|
||||
writeFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
|
||||
writeCaptureFileSync(join(lottieDir, `animation-${savedCount}.json`), jsonData, "utf-8");
|
||||
savedCount++;
|
||||
}
|
||||
} catch {
|
||||
@@ -214,7 +215,7 @@ export async function renderLottiePreviews(
|
||||
}
|
||||
}
|
||||
if (manifest.length > 0) {
|
||||
writeFileSync(
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "lottie-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
"utf-8",
|
||||
@@ -283,7 +284,7 @@ async function downloadVideoBody(
|
||||
}
|
||||
if (total < 1024) return null; // too small to be a real video (likely an error blob)
|
||||
const safe = /\.[a-z0-9]+$/i.test(filename) ? filename.replace(/[^\w.-]/g, "_") : `video${ext}`;
|
||||
writeFileSync(join(videosDir, safe), Buffer.concat(chunks));
|
||||
writeCaptureFileSync(join(videosDir, safe), Buffer.concat(chunks));
|
||||
return `assets/videos/${safe}`;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -573,7 +574,7 @@ export async function captureVideoManifest(
|
||||
}
|
||||
|
||||
if (videoManifest.length > 0) {
|
||||
writeFileSync(
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "video-manifest.json"),
|
||||
JSON.stringify(videoManifest, null, 2),
|
||||
"utf-8",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NavigationDeadlineError } from "./captureErrors.js";
|
||||
import { detectBlockedPage } from "./pageBlockDetection.js";
|
||||
import { navigateForCapture } from "./navigateForCapture.js";
|
||||
import {
|
||||
isDegradableEvaluateTimeoutError,
|
||||
isNavigationTimeoutError,
|
||||
withRemainingBudget,
|
||||
} from "./captureTimeout.js";
|
||||
import { writeResponseRecord } from "./responseRecord.js";
|
||||
import { join } from "node:path";
|
||||
import type { CapturePhase, CapturePhaseProgress } from "./types.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
|
||||
type ContentCheck = {
|
||||
textLength: number;
|
||||
title: string;
|
||||
hasChallengeElement: boolean;
|
||||
bodyChildCount: number;
|
||||
};
|
||||
|
||||
export interface NavigationPhaseInput {
|
||||
page1: Page;
|
||||
url: string;
|
||||
timeout: number;
|
||||
settleTime: number;
|
||||
budgetMs: number;
|
||||
warnings: string[];
|
||||
progress: (stage: string, detail?: string) => void;
|
||||
remainingMs: () => number;
|
||||
pageContentCheck: ContentCheck;
|
||||
contentCheckTimedOut: boolean;
|
||||
postNavigationDeadline?: number;
|
||||
outputDir: string;
|
||||
phase: (
|
||||
name: CapturePhase,
|
||||
status: CapturePhaseProgress["status"],
|
||||
reason?: CapturePhaseProgress["reason"],
|
||||
) => void;
|
||||
httpStatus: number | null;
|
||||
canWrite: () => boolean;
|
||||
}
|
||||
|
||||
async function navigatePage(context: NavigationPhaseInput) {
|
||||
let navigation;
|
||||
try {
|
||||
navigation = await navigateForCapture(context.page1, context.url, context.timeout);
|
||||
} catch (err) {
|
||||
if (isNavigationTimeoutError(err)) {
|
||||
let webglObserved = false;
|
||||
try {
|
||||
webglObserved = Boolean(await context.page1.evaluate("Boolean(window.__hfWebglSeen)"));
|
||||
} catch {
|
||||
/* the browser may already be closing */
|
||||
}
|
||||
throw new NavigationDeadlineError(err, webglObserved);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (navigation.fellBackFromNetworkIdle) {
|
||||
context.warnings.push(
|
||||
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continued with domcontentloaded`,
|
||||
);
|
||||
context.progress(
|
||||
"warn",
|
||||
`networkidle2 timed out after ${navigation.networkIdleTimeoutMs}ms; continuing with domcontentloaded`,
|
||||
);
|
||||
}
|
||||
context.postNavigationDeadline = Date.now() + context.budgetMs;
|
||||
await new Promise((resolve) => setTimeout(resolve, context.settleTime));
|
||||
return navigation;
|
||||
}
|
||||
|
||||
async function checkPageContent(context: NavigationPhaseInput): Promise<ContentCheck> {
|
||||
try {
|
||||
return (await withRemainingBudget(
|
||||
context.page1.evaluate(`(() => {
|
||||
var text = (document.body && document.body.innerText || "").trim();
|
||||
var title = document.title || "";
|
||||
var hasCfTurnstile = !!document.querySelector('.cf-turnstile, [data-sitekey], iframe[src*="challenges.cloudflare.com"], #challenge-running, #challenge-form');
|
||||
var bodyChildCount = document.body ? document.body.children.length : 0;
|
||||
return { textLength: text.length, title: title, hasChallengeElement: hasCfTurnstile, bodyChildCount: bodyChildCount };
|
||||
})()`),
|
||||
Math.min(5_000, context.remainingMs()),
|
||||
"content-check",
|
||||
)) as ContentCheck;
|
||||
} catch (err) {
|
||||
if (!isDegradableEvaluateTimeoutError(err)) throw err;
|
||||
context.contentCheckTimedOut = true;
|
||||
const message =
|
||||
"post-navigation content check timed out; continuing with HTTP-status blocked-page detection only";
|
||||
context.warnings.push(message);
|
||||
context.progress("warn", message);
|
||||
return context.pageContentCheck;
|
||||
}
|
||||
}
|
||||
|
||||
function persistNavigationResult(
|
||||
context: NavigationPhaseInput,
|
||||
navigationResponse: unknown,
|
||||
pageContentCheck: ContentCheck,
|
||||
): void {
|
||||
context.httpStatus = navigationResponse
|
||||
? (navigationResponse as { status: () => number }).status()
|
||||
: null;
|
||||
if (context.canWrite()) {
|
||||
writeResponseRecord(join(context.outputDir, "extracted"), { status: context.httpStatus });
|
||||
}
|
||||
const blockedReason = detectBlockedPage({
|
||||
httpStatus: context.httpStatus,
|
||||
...(context.contentCheckTimedOut
|
||||
? { title: "", textLength: 0, bodyChildCount: 0, hasChallengeElement: false }
|
||||
: pageContentCheck),
|
||||
});
|
||||
if (blockedReason) {
|
||||
context.phase("navigation", "degraded", "blocked");
|
||||
throw new Error(blockedReason);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NavigationPhaseResult {
|
||||
pageContentCheck: ContentCheck;
|
||||
contentCheckTimedOut: boolean;
|
||||
httpStatus: number | null;
|
||||
postNavigationDeadline?: number;
|
||||
}
|
||||
|
||||
export async function runNavigationChecks(
|
||||
context: NavigationPhaseInput,
|
||||
): Promise<NavigationPhaseResult> {
|
||||
const navigation = await navigatePage(context);
|
||||
const pageContentCheck = await checkPageContent(context);
|
||||
persistNavigationResult(context, navigation.response, pageContentCheck);
|
||||
context.phase("navigation", "completed");
|
||||
context.phase("core-extraction", "started");
|
||||
return {
|
||||
pageContentCheck,
|
||||
contentCheckTimedOut: context.contentCheckTimedOut,
|
||||
httpStatus: context.httpStatus,
|
||||
postNavigationDeadline: context.postNavigationDeadline,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPartialCaptureState, writePartialCaptureBundle } from "./partialCapture.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "./types.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0))
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("writePartialCaptureBundle", () => {
|
||||
it("writes tokens collected before the deadline", () => {
|
||||
const outputDir = mkdtempSync(join(tmpdir(), "hyperframes-partial-capture-"));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const opts = { url: "https://example.com", outputDir };
|
||||
const state = createPartialCaptureState(opts);
|
||||
state.tokens.title = "Collected before deadline";
|
||||
state.tokens.colors = ["#ABCDEF"];
|
||||
|
||||
const result = writePartialCaptureBundle(opts, state, {
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "complete",
|
||||
status: "degraded",
|
||||
remainingMs: null,
|
||||
reason: "deadline",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.lastPhase.reason).toBe("deadline");
|
||||
|
||||
const tokensPath = join(outputDir, "extracted", "tokens.json");
|
||||
expect(existsSync(tokensPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(tokensPath, "utf8"))).toMatchObject({
|
||||
title: "Collected before deadline",
|
||||
colors: ["#ABCDEF"],
|
||||
});
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(outputDir, "extracted", "design-styles.json"), "utf8")),
|
||||
).toEqual(state.designStyles);
|
||||
expect(readFileSync(join(outputDir, "extracted", "page.html"), "utf8")).toBe(state.pageHtml);
|
||||
expect(JSON.parse(readFileSync(join(outputDir, "meta.json"), "utf8"))).toMatchObject({
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
import { noDrops } from "./assetDownloader.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
DesignStyles,
|
||||
DesignTokens,
|
||||
ExtractedHtml,
|
||||
} from "./types.js";
|
||||
|
||||
export function serializeTokensForCapture(tokens: DesignTokens): string {
|
||||
const tokensForDisk = {
|
||||
...tokens,
|
||||
svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest),
|
||||
};
|
||||
return JSON.stringify(tokensForDisk, null, 2);
|
||||
}
|
||||
|
||||
export interface PartialCaptureState {
|
||||
tokens: DesignTokens;
|
||||
designStyles: DesignStyles;
|
||||
extracted: ExtractedHtml;
|
||||
pageHtml: string;
|
||||
screenshots: string[];
|
||||
assets: CaptureResult["assets"];
|
||||
dropped: CaptureResult["dropped"];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function createPartialCaptureState(opts: CaptureOptions): PartialCaptureState {
|
||||
return {
|
||||
tokens: {
|
||||
title: "",
|
||||
description: "",
|
||||
cssVariables: {},
|
||||
fonts: [],
|
||||
colors: [],
|
||||
headings: [],
|
||||
ctas: [],
|
||||
svgs: [],
|
||||
sections: [],
|
||||
},
|
||||
designStyles: {
|
||||
typography: [],
|
||||
spacing: { observed: [], baseUnit: 0 },
|
||||
radius: [],
|
||||
shadows: [],
|
||||
buttons: [],
|
||||
cards: [],
|
||||
nav: null,
|
||||
},
|
||||
extracted: {
|
||||
headHtml: "",
|
||||
bodyHtml: "",
|
||||
cssomRules: "",
|
||||
htmlAttrs: "",
|
||||
viewportWidth: opts.viewportWidth ?? 1920,
|
||||
viewportHeight: opts.viewportHeight ?? 1080,
|
||||
fullPageHeight: 0,
|
||||
},
|
||||
pageHtml: "<!doctype html>\n<html><head></head><body></body></html>\n",
|
||||
screenshots: [],
|
||||
assets: [],
|
||||
dropped: noDrops(),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function writePartialCaptureBundle(
|
||||
opts: CaptureOptions,
|
||||
state: PartialCaptureState,
|
||||
lastPhase: CaptureResult["lastPhase"],
|
||||
): CaptureResult {
|
||||
const hostname = new URL(opts.url).hostname.replace(/^www\./, "");
|
||||
mkdirSync(opts.outputDir, { recursive: true });
|
||||
const extractedDir = join(opts.outputDir, "extracted");
|
||||
mkdirSync(extractedDir, { recursive: true });
|
||||
writeCaptureFileSync(join(extractedDir, "tokens.json"), serializeTokensForCapture(state.tokens));
|
||||
writeCaptureFileSync(
|
||||
join(extractedDir, "design-styles.json"),
|
||||
JSON.stringify(state.designStyles, null, 2),
|
||||
);
|
||||
writeCaptureFileSync(join(extractedDir, "page.html"), state.pageHtml);
|
||||
const metaPath = join(opts.outputDir, "meta.json");
|
||||
try {
|
||||
writeCaptureFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({ id: hostname + "-video", name: hostname, partial: true }, null, 2),
|
||||
{ flag: "wx" },
|
||||
);
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
|
||||
if (code !== "EEXIST") throw err;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
projectDir: opts.outputDir,
|
||||
url: opts.url,
|
||||
httpStatus: null,
|
||||
title: state.tokens.title,
|
||||
extracted: state.extracted,
|
||||
screenshots: state.screenshots,
|
||||
tokens: state.tokens,
|
||||
assets: state.assets,
|
||||
dropped: state.dropped,
|
||||
warnings: [
|
||||
...state.warnings,
|
||||
"Capture deadline reached; returning the partial capture bundle.",
|
||||
],
|
||||
lastPhase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
downloadAssets,
|
||||
downloadAndRewriteFonts,
|
||||
mergeDrops,
|
||||
noDrops,
|
||||
totalDrops,
|
||||
} from "./assetDownloader.js";
|
||||
import { extractFontMetadata } from "./fontMetadataExtractor.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { diag } from "../ui/diagnostics.js";
|
||||
import { serializeTokensForCapture } from "./partialCapture.js";
|
||||
import type { PartialCaptureState } from "./partialCapture.js";
|
||||
import type {
|
||||
CapturePhase,
|
||||
CapturePhaseProgress,
|
||||
CaptureResult,
|
||||
DesignTokens,
|
||||
ExtractedHtml,
|
||||
} from "./types.js";
|
||||
import type { CatalogedAsset } from "./assetCataloger.js";
|
||||
import type { IconCandidate } from "./faviconRanker.js";
|
||||
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
|
||||
import {
|
||||
captionImagesWithGemini,
|
||||
generateAssetDescriptions,
|
||||
resolveVisionPhaseCompletion,
|
||||
} from "./contentExtractor.js";
|
||||
import type { VisionCaptionOutcome } from "./contentExtractor.js";
|
||||
|
||||
function hasVisionCredentials(skipVision: boolean): boolean {
|
||||
return Boolean(
|
||||
!skipVision &&
|
||||
(process.env.OPENROUTER_API_KEY ||
|
||||
process.env.GEMINI_API_KEY ||
|
||||
process.env.GOOGLE_API_KEY ||
|
||||
(process.env.HYPERFRAMES_VERTEX_PROJECT_ID &&
|
||||
process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT)),
|
||||
);
|
||||
}
|
||||
|
||||
function writeAssetDescriptionsFile(outputDir: string, lines: string[], header: string): void {
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "asset-descriptions.md"),
|
||||
header + lines.map((line) => `- ${line}`).join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
export interface PostExtractionInput {
|
||||
state: PartialCaptureState;
|
||||
outputDir: string;
|
||||
warnings: string[];
|
||||
progress: (stage: string, detail?: string) => void;
|
||||
remainingMs: () => number;
|
||||
phase: (
|
||||
name: CapturePhase,
|
||||
status: CapturePhaseProgress["status"],
|
||||
reason?: CapturePhaseProgress["reason"],
|
||||
) => void;
|
||||
animationCatalog: CaptureResult["animationCatalog"];
|
||||
catalogedAssets: CatalogedAsset[];
|
||||
visibleTextContent: string;
|
||||
faviconLinks: IconCandidate[];
|
||||
tokens: DesignTokens;
|
||||
extracted: ExtractedHtml;
|
||||
skipAssets: boolean;
|
||||
skipVision: boolean;
|
||||
downloadByteBudget: ReturnType<typeof createCaptureDownloadBudget>;
|
||||
assets: CaptureResult["assets"];
|
||||
dropped: CaptureResult["dropped"];
|
||||
fontDrops: CaptureResult["dropped"];
|
||||
canWrite: () => boolean;
|
||||
}
|
||||
|
||||
export interface PostExtractionResult {
|
||||
assets: CaptureResult["assets"];
|
||||
dropped: CaptureResult["dropped"];
|
||||
fontDrops: CaptureResult["dropped"];
|
||||
extracted: ExtractedHtml;
|
||||
tokens: DesignTokens;
|
||||
animationCatalog: CaptureResult["animationCatalog"];
|
||||
}
|
||||
|
||||
export async function runPostExtraction(input: PostExtractionInput): Promise<PostExtractionResult> {
|
||||
let {
|
||||
state,
|
||||
outputDir,
|
||||
warnings,
|
||||
progress,
|
||||
remainingMs,
|
||||
phase,
|
||||
animationCatalog,
|
||||
catalogedAssets,
|
||||
visibleTextContent,
|
||||
faviconLinks,
|
||||
tokens,
|
||||
extracted,
|
||||
skipAssets,
|
||||
skipVision,
|
||||
downloadByteBudget,
|
||||
assets,
|
||||
dropped,
|
||||
fontDrops,
|
||||
canWrite,
|
||||
} = input;
|
||||
const runFontExtraction = async (): Promise<void> => {
|
||||
if (!canWrite()) return;
|
||||
// Download fonts and preserve per-font budget exhaustion in the capture tally.
|
||||
phase("fonts", "started");
|
||||
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
|
||||
remainingMs,
|
||||
byteBudget: downloadByteBudget,
|
||||
});
|
||||
fontDrops = fontPass.drops;
|
||||
extracted.headHtml = fontPass.css;
|
||||
phase(
|
||||
"fonts",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
|
||||
// Identify each downloaded font by reading its OpenType name table.
|
||||
// Modern frameworks hash font filenames; this manifest tells the
|
||||
// downstream pipeline (DESIGN.md authoring, beat sub-agents) which file
|
||||
// belongs to which family without guessing from filename patterns.
|
||||
try {
|
||||
const fontsManifest = extractFontMetadata(
|
||||
join(outputDir, "assets", "fonts"),
|
||||
join(outputDir, "extracted", "fonts-manifest.json"),
|
||||
);
|
||||
if (fontsManifest.families.length > 0) {
|
||||
const summary = fontsManifest.families
|
||||
.map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`)
|
||||
.join(", ");
|
||||
// stderr (via diag): `capture --json` writes its envelope to stdout, so
|
||||
// these progress/advisory lines must not land there.
|
||||
diag.notice(`Font metadata extracted: ${summary}`);
|
||||
if (fontsManifest.unidentified.length > 0) {
|
||||
diag.warn(
|
||||
` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
diag.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
|
||||
}
|
||||
};
|
||||
await runFontExtraction();
|
||||
|
||||
const runAnimationCatalog = async (): Promise<void> => {
|
||||
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
|
||||
if (animationCatalog) {
|
||||
// Extract just what's useful: counts, named animations, a few representative keyframed entries
|
||||
const uniqueAnimNames = new Set<string>();
|
||||
for (const d of animationCatalog.cssDeclarations || []) {
|
||||
if (d.animation?.name) uniqueAnimNames.add(d.animation.name);
|
||||
}
|
||||
|
||||
// Keep up to 10 Web Animations that have actual keyframe data (most useful for recreation)
|
||||
const representativeAnims = (animationCatalog.webAnimations || [])
|
||||
.filter((a: { keyframes?: unknown[] }) => a.keyframes && a.keyframes.length > 0)
|
||||
.slice(0, 10);
|
||||
|
||||
const leanCatalog = {
|
||||
summary: animationCatalog.summary,
|
||||
namedAnimations: Array.from(uniqueAnimNames),
|
||||
scrollTriggeredElements: (animationCatalog.scrollTargets || []).length,
|
||||
representativeAnimations: representativeAnims,
|
||||
};
|
||||
|
||||
if (canWrite()) {
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "animations.json"),
|
||||
JSON.stringify(leanCatalog, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
await runAnimationCatalog();
|
||||
|
||||
const runAssetDownload = async (): Promise<void> => {
|
||||
// Download assets — single pass using the catalog for best image quality
|
||||
let assetDrops = noDrops();
|
||||
const runAssetFetch = async (): Promise<void> => {
|
||||
if (!skipAssets && canWrite()) {
|
||||
// Called even with the budget already gone, for the reason the font pass is: the loop that
|
||||
// skips an asset is the only thing that can say how many it skipped.
|
||||
phase("assets", "started");
|
||||
progress("assets", "Downloading assets...");
|
||||
const assetPass = await downloadAssets(tokens, outputDir, catalogedAssets, faviconLinks, {
|
||||
remainingMs,
|
||||
byteBudget: downloadByteBudget,
|
||||
});
|
||||
assets = assetPass.assets;
|
||||
assetDrops = assetPass.drops;
|
||||
state.assets = assets;
|
||||
// Which icons the site declared, what each one is, and why one became favicon.<ext>.
|
||||
// The brand-kit consumer needs the shape to decide which tile an icon belongs in; the
|
||||
// reason is what stops a substituted headline from being silent again.
|
||||
if (canWrite()) {
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "icons-manifest.json"),
|
||||
JSON.stringify(assetPass.icons, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
phase(
|
||||
"assets",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
} else {
|
||||
phase("assets", "degraded", "disabled");
|
||||
}
|
||||
};
|
||||
await runAssetFetch();
|
||||
|
||||
const recordAssetDrops = (): void => {
|
||||
// One capture-wide tally, summed from the two passes that own the drops. The warning is
|
||||
// DERIVED from it rather than written alongside it, so the prose and the number cannot
|
||||
// disagree the way two separately-authored budget strings could.
|
||||
dropped = mergeDrops(fontDrops, assetDrops);
|
||||
state.dropped = dropped;
|
||||
const droppedTotal = totalDrops(dropped);
|
||||
if (droppedTotal > 0) {
|
||||
const breakdown = Object.entries(dropped)
|
||||
.filter(([, n]) => typeof n === "number" && n > 0)
|
||||
.map(([reason, n]) => `${n} ${reason}`)
|
||||
.join(", ");
|
||||
warnings.push(
|
||||
`${droppedTotal} referenced asset(s) are not in this capture (${breakdown}). ` +
|
||||
"A thin capture with no drops is a thin page; this one was truncated.",
|
||||
);
|
||||
}
|
||||
};
|
||||
recordAssetDrops();
|
||||
|
||||
const rewriteTokenAssets = (): void => {
|
||||
// Join in-section media URLs → downloaded local paths, then re-write
|
||||
// tokens.json. Downstream page recreation MUST reference local files:
|
||||
// remote URLs fail at render time (hotlink/CORS 403, no egress in
|
||||
// Docker/Lambda, frame-timing blanks for not-yet-loaded images).
|
||||
const base = (u: string): string => u.split(/[#?]/)[0] ?? u;
|
||||
const localByUrl = new Map<string, string>();
|
||||
const collectLocalAssetPaths = (): void => {
|
||||
for (const a of assets) {
|
||||
if (!a.url || !a.localPath) continue;
|
||||
localByUrl.set(a.url, a.localPath);
|
||||
localByUrl.set(base(a.url), a.localPath);
|
||||
}
|
||||
};
|
||||
collectLocalAssetPaths();
|
||||
const applyLocalAssetPaths = (): void => {
|
||||
for (const sec of tokens.sections || []) {
|
||||
const local: string[] = [];
|
||||
for (const u of sec.assetUrls || []) {
|
||||
const hit = localByUrl.get(u) || localByUrl.get(base(u));
|
||||
if (hit && !local.includes(hit)) local.push(hit);
|
||||
}
|
||||
if (local.length) sec.assets = local;
|
||||
}
|
||||
};
|
||||
applyLocalAssetPaths();
|
||||
if (assets.length && Array.isArray(tokens.sections)) {
|
||||
if (canWrite()) {
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "tokens.json"),
|
||||
serializeTokensForCapture(tokens),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
await rewriteTokenAssets();
|
||||
};
|
||||
await runAssetDownload();
|
||||
|
||||
// Persist a self-contained page recreation under extracted/, with images already inlined.
|
||||
try {
|
||||
const pageHtml = `<!doctype html>\n<html ${extracted.htmlAttrs || ""}>\n<head>\n${extracted.headHtml}\n</head>\n<body>\n${extracted.bodyHtml}\n</body>\n</html>\n`;
|
||||
state.pageHtml = pageHtml;
|
||||
if (canWrite())
|
||||
writeCaptureFileSync(join(outputDir, "extracted", "page.html"), pageHtml, "utf-8");
|
||||
} catch (err) {
|
||||
warnings.push(`page.html write failed: ${err}`);
|
||||
}
|
||||
// Save visible text content for AI agent to use
|
||||
if (visibleTextContent) {
|
||||
if (canWrite()) {
|
||||
writeCaptureFileSync(
|
||||
join(outputDir, "extracted", "visible-text.txt"),
|
||||
visibleTextContent,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing
|
||||
|
||||
// AI-powered image captioning via Gemini (optional — enriches asset descriptions)
|
||||
let geminiCaptions: Record<string, string> = {};
|
||||
const runVision = async (): Promise<void> => {
|
||||
if (skipVision) {
|
||||
phase("vision", "degraded", "disabled");
|
||||
} else if (remainingMs() <= 0 || !canWrite()) {
|
||||
warnings.push(
|
||||
"Capture budget exhausted before vision captioning; catalog descriptions were preserved.",
|
||||
);
|
||||
phase("vision", "degraded", "budget-exhausted");
|
||||
} else {
|
||||
phase("vision", "started");
|
||||
let visionOutcome: VisionCaptionOutcome = {
|
||||
timedOutRequests: 0,
|
||||
failedRequests: 0,
|
||||
budgetExhausted: false,
|
||||
};
|
||||
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
|
||||
remainingMs,
|
||||
onOutcome: (outcome) => {
|
||||
visionOutcome = outcome;
|
||||
},
|
||||
});
|
||||
const completion = resolveVisionPhaseCompletion(visionOutcome, remainingMs());
|
||||
phase(
|
||||
"vision",
|
||||
completion.status,
|
||||
completion.status === "degraded" ? completion.reason : undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
await runVision();
|
||||
|
||||
// Generate asset descriptions for the AI agent
|
||||
const runAssetDescriptions = (): void => {
|
||||
progress("design", "Generating asset descriptions...");
|
||||
try {
|
||||
const lines = generateAssetDescriptions(outputDir, tokens, catalogedAssets, geminiCaptions);
|
||||
if (lines.length === 0 || !canWrite()) return;
|
||||
const hasVisionKey = hasVisionCredentials(skipVision);
|
||||
const header = hasVisionKey
|
||||
? "# Asset Descriptions\n\nOne line per file. Read this instead of opening every image individually.\n\nTo find a specific brand or icon, **grep this file for the brand name in the description text** (e.g. `grep -i 'autodesk' asset-descriptions.md`). The Gemini Vision captions identify what's actually in each file — that's the agent's selector.\n\nThe `logo-<hash>.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n"
|
||||
: "# Asset Descriptions\n\n⚠️ No vision credentials — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, and re-run.\n\nThe `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n";
|
||||
writeAssetDescriptionsFile(outputDir, lines, header);
|
||||
progress(
|
||||
"design",
|
||||
`${lines.length} asset descriptions written${hasVisionKey ? "" : " (no vision provider — catalog-fallback mode)"}`,
|
||||
);
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
};
|
||||
runAssetDescriptions();
|
||||
|
||||
progress("design", "DESIGN.md will be created by your AI agent");
|
||||
|
||||
// Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually)
|
||||
// All functions return string[] — paginated so every image is covered
|
||||
const runContactSheets = async (): Promise<void> => {
|
||||
if (remainingMs() > 0 && canWrite()) {
|
||||
phase("contact-sheets", "started");
|
||||
try {
|
||||
const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } =
|
||||
await import("./contactSheet.js");
|
||||
|
||||
const contactSheetBudget = { remainingMs };
|
||||
|
||||
const runScrollContactSheet = async (): Promise<void> => {
|
||||
const scrollSheets = await createScrollContactSheet(
|
||||
join(outputDir, "screenshots"),
|
||||
join(outputDir, "screenshots", "contact-sheet.jpg"),
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (scrollSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
};
|
||||
await runScrollContactSheet();
|
||||
|
||||
const runAssetContactSheet = async (): Promise<void> => {
|
||||
const assetsImgDir = join(outputDir, "assets");
|
||||
if (existsSync(assetsImgDir)) {
|
||||
const assetSheets = await createAssetContactSheet(
|
||||
assetsImgDir,
|
||||
join(outputDir, "assets", "contact-sheet.jpg"),
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (assetSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
await runAssetContactSheet();
|
||||
|
||||
const runSvgContactSheet = async (): Promise<void> => {
|
||||
// Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from <img src="*.svg">)
|
||||
// so sites like huly.io that only use external SVGs still get a grid
|
||||
const svgsDir = join(outputDir, "assets", "svgs");
|
||||
const assetsRootDir = join(outputDir, "assets");
|
||||
const svgOutputPath = existsSync(svgsDir)
|
||||
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
|
||||
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
|
||||
const svgSheets = await createSvgContactSheet(
|
||||
svgsDir,
|
||||
svgOutputPath,
|
||||
assetsRootDir,
|
||||
contactSheetBudget,
|
||||
);
|
||||
if (svgSheets.length > 0)
|
||||
progress(
|
||||
"design",
|
||||
`SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`,
|
||||
);
|
||||
};
|
||||
await runSvgContactSheet();
|
||||
} catch {
|
||||
/* contact sheets are non-critical — agent can still read images individually */
|
||||
}
|
||||
phase(
|
||||
"contact-sheets",
|
||||
remainingMs() > 0 ? "completed" : "degraded",
|
||||
remainingMs() > 0 ? undefined : "budget-exhausted",
|
||||
);
|
||||
} else {
|
||||
warnings.push(
|
||||
"Capture budget exhausted before contact sheets; source images were preserved.",
|
||||
);
|
||||
phase("contact-sheets", "degraded", "budget-exhausted");
|
||||
}
|
||||
};
|
||||
await runContactSheets();
|
||||
|
||||
return { assets, dropped, fontDrops, extracted, tokens, animationCatalog };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ export interface CaptureResponseRecord {
|
||||
|
||||
/** Writes the record into an already-created `extracted/` directory. */
|
||||
export function writeResponseRecord(extractedDir: string, record: CaptureResponseRecord): void {
|
||||
writeFileSync(
|
||||
writeCaptureFileSync(
|
||||
join(extractedDir, RESPONSE_RECORD_FILENAME),
|
||||
JSON.stringify(record, null, 2),
|
||||
"utf-8",
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import type { Page } from "puppeteer-core";
|
||||
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { writeCaptureFileSync } from "./captureFile.js";
|
||||
|
||||
/**
|
||||
* Capture viewport screenshots covering the entire page height.
|
||||
@@ -92,7 +93,7 @@ export async function captureFullPagePlate(
|
||||
// than something silently wrong.
|
||||
const produced = pngHeight(buffer);
|
||||
if (produced != null && produced > MAX_PLATE_HEIGHT_PX) return null;
|
||||
writeFileSync(join(screenshotsDir, "full-page.png"), buffer);
|
||||
writeCaptureFileSync(join(screenshotsDir, "full-page.png"), buffer);
|
||||
return "screenshots/full-page.png";
|
||||
} finally {
|
||||
// A page that broke mid-capture will fail this too; letting that escape would replace the
|
||||
@@ -245,7 +246,7 @@ export async function captureScrollScreenshots(
|
||||
const filePath = join(screenshotsDir, filename);
|
||||
if ((budget.remainingMs?.() ?? 1) <= 0) break;
|
||||
const buffer = await page.screenshot({ type: "png" });
|
||||
writeFileSync(filePath, buffer);
|
||||
writeCaptureFileSync(filePath, buffer);
|
||||
filePaths.push(`screenshots/${filename}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ export type CapturePhase =
|
||||
| "scaffold"
|
||||
| "complete";
|
||||
|
||||
export const CAPTURE_PHASE_SCHEMA = "hyperframes.capture.phase.v1" as const;
|
||||
|
||||
export interface CapturePhaseProgress {
|
||||
schema: "hyperframes.capture.phase.v1";
|
||||
schema: typeof CAPTURE_PHASE_SCHEMA;
|
||||
phase: CapturePhase;
|
||||
status: "started" | "completed" | "degraded";
|
||||
/** Null before the post-navigation budget begins. */
|
||||
@@ -32,7 +34,9 @@ export interface CapturePhaseProgress {
|
||||
| "request-timeout"
|
||||
| "provider-error"
|
||||
| "internal-error"
|
||||
| "blocked";
|
||||
| "blocked"
|
||||
| "webgl-disabled-retry"
|
||||
| "deadline";
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
@@ -56,6 +60,8 @@ export interface CaptureOptions {
|
||||
skipVision?: boolean;
|
||||
/** Cooperative post-navigation budget in ms (default: 120000). */
|
||||
postNavigationBudgetMs?: number;
|
||||
/** Optional hard wall-clock deadline for the complete capture run. */
|
||||
captureDeadlineMs?: number;
|
||||
/** Stable, non-sensitive progress records for watchdog diagnostics. */
|
||||
onPhase?: (event: CapturePhaseProgress) => void;
|
||||
/** Output JSON for programmatic use */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CliRuntimeError } from "../utils/commandResult.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "../capture/types.js";
|
||||
|
||||
const { captureWebsiteMock } = vi.hoisted(() => ({
|
||||
captureWebsiteMock: vi.fn(async (options: unknown) => {
|
||||
@@ -10,7 +11,7 @@ const { captureWebsiteMock } = vi.hoisted(() => ({
|
||||
const onPhase = Reflect.get(options, "onPhase");
|
||||
if (typeof onPhase === "function") {
|
||||
onPhase({
|
||||
schema: "hyperframes.capture.phase.v1",
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "vision",
|
||||
status: "degraded",
|
||||
remainingMs: 0,
|
||||
@@ -85,6 +86,31 @@ describe("capture command — vision control", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("plumbs the optional whole-capture deadline from the environment", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS = "240000";
|
||||
|
||||
try {
|
||||
await captureCommand.run!({
|
||||
args: {
|
||||
url: "https://example.com",
|
||||
output: "/tmp/hf-capture-deadline-test",
|
||||
"skip-assets": false,
|
||||
"skip-vision": false,
|
||||
json: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(captureWebsiteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ captureDeadlineMs: 240_000 }),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1", 1],
|
||||
["45000", 45_000],
|
||||
@@ -156,7 +182,7 @@ describe("capture command — vision control", () => {
|
||||
if (typeof line !== "string") throw new Error("Expected capture phase diagnostic");
|
||||
const event = JSON.parse(line.slice("HYPERFRAMES_CAPTURE_PHASE ".length));
|
||||
expect(event).toEqual({
|
||||
schema: "hyperframes.capture.phase.v1",
|
||||
schema: CAPTURE_PHASE_SCHEMA,
|
||||
phase: "vision",
|
||||
status: "degraded",
|
||||
remainingMs: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Example } from "./_examples.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { diag } from "../ui/diagnostics.js";
|
||||
import type { CapturePhaseProgress } from "../capture/types.js";
|
||||
import { parseCaptureDeadline } from "../capture/captureWatchdog.js";
|
||||
|
||||
const CAPTURE_PHASE_PREFIX = "HYPERFRAMES_CAPTURE_PHASE ";
|
||||
|
||||
@@ -175,6 +176,7 @@ export default defineCommand({
|
||||
: undefined,
|
||||
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
|
||||
postNavigationBudgetMs: captureBudgetMs,
|
||||
captureDeadlineMs: parseCaptureDeadline(process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS),
|
||||
json: isJson,
|
||||
onPhase: emitCapturePhase,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user