Files
hyperframes/packages/cli/src/commands/capture.ts
T
ukimsanovandClaude Opus 4.6 4e87f28cd9 fix: address PR #339 review — 7 issues
1. Catalog failure safety: warn when catalog is empty or throws, so
   capture doesn't silently produce zero images
2. Dead path: extract-audio-data.py → skills/gsap/scripts/ (was
   skills/hyperframes/scripts/)
3. --json fonts compat: emit both `fonts` (string[]) and
   `fontsDetailed` (FontToken[]) to avoid breaking external consumers
4. Restore .cursorrules writing alongside AGENTS.md + CLAUDE.md
5. .gitignore: remove over-broad `projects/` and `videos/` entries,
   keep scoped `cursor-tests/` and `launch-video*/`
6. agentPromptGenerator: mark unused params as reserved with comments,
   remove _animations from buildPrompt
7. Cookie filter: threshold 20 → 8 chars to preserve footer copy like
   "© 2026 Stripe" (16 chars) and "Privacy & Terms" (15 chars)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 15:34:15 -04:00

184 lines
6.1 KiB
TypeScript

import { defineCommand } from "citty";
import { resolve } from "node:path";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
["Capture a website", "hyperframes capture https://stripe.com"],
["Capture to a specific directory", "hyperframes capture https://linear.app -o linear-video"],
["JSON output for AI agents", "hyperframes capture https://example.com --json"],
];
export default defineCommand({
meta: {
name: "capture",
description: "Capture a website as editable HyperFrames components",
},
args: {
url: {
type: "positional",
description: "Website URL to capture",
required: true,
},
output: {
type: "string",
description: "Output directory name",
alias: "o",
},
"skip-assets": {
type: "boolean",
description: "Skip downloading assets (images, SVGs)",
default: false,
},
"max-screenshots": {
type: "string",
description: "Maximum screenshots to capture (default: 24)",
},
timeout: {
type: "string",
description: "Page load timeout in ms (default: 120000)",
},
json: {
type: "boolean",
description: "Output JSON (for AI agents / programmatic use)",
default: false,
},
},
async run({ args }) {
const url = args.url as string;
// Validate URL
try {
new URL(url);
} catch {
console.error(`Invalid URL: ${url}`);
process.exit(1);
}
// Determine output directory — default to captures/<hostname> to keep repo root clean
let outputName = args.output as string | undefined;
if (!outputName) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
outputName = `captures/${hostname.replace(/\./g, "-")}`;
}
const outputDir = resolve(outputName);
const isJson = args.json as boolean;
if (!isJson) {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.dim("◆") + " Capturing " + c.bold(url));
console.log();
}
const { captureWebsite } = await import("../capture/index.js");
try {
const result = await captureWebsite(
{
url,
outputDir,
skipAssets: args["skip-assets"] as boolean,
maxScreenshots: args["max-screenshots"]
? parseInt(args["max-screenshots"] as string)
: undefined,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
json: isJson,
},
isJson
? undefined
: (stage: string, detail?: string) => {
const stages: Record<string, string> = {
browser: " Launching browser...",
navigate: " Loading page...",
extract: " Extracting HTML & CSS...",
tokens: " Extracting design tokens...",
screenshots: " Capturing screenshots...",
assets: " Downloading assets...",
style: " Generating visual style...",
done: " Done",
};
const label = stages[stage] || ` ${stage}`;
console.log(detail ? `${label} ${detail}` : label);
},
);
if (isJson) {
// Output structured JSON for Claude Code / programmatic use
console.log(
JSON.stringify(
{
ok: result.ok,
projectDir: result.projectDir,
url: result.url,
title: result.title,
screenshots: result.screenshots.length,
assets: result.assets.length,
detectedSections: result.tokens.sections.length,
fonts: result.tokens.fonts.map((f) => f.family),
fontsDetailed: result.tokens.fonts,
animations: result.animationCatalog?.summary,
warnings: result.warnings,
},
null,
2,
),
);
} else {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.success("◇") + ` Captured ${c.bold(result.title)}${c.dim(outputDir)}`);
console.log();
console.log(` ${c.dim("Screenshots:")} ${result.screenshots.length}`);
console.log(` ${c.dim("Assets:")} ${result.assets.length}`);
console.log(` ${c.dim("Sections:")} ${result.tokens.sections.length}`);
console.log(
` ${c.dim("Fonts:")} ${result.tokens.fonts
.map(function (f) {
return (
f.family +
" (" +
(f.variable && f.weightRange
? f.weightRange[0] + "-" + f.weightRange[1] + " variable"
: f.weights.join(",")) +
")"
);
})
.join(", ")}`,
);
if (result.warnings.length > 0) {
console.log();
for (const w of result.warnings) {
console.log(` ${c.warn("⚠")} ${w}`);
}
}
console.log();
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// Write BLOCKED.md so the user/agent knows the capture failed
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(outputDir, { recursive: true });
const isTimeout = /timeout|timed out/i.test(errMsg);
const reason = isTimeout
? "Page navigation timed out — the site may be blocking headless browsers or requires authentication."
: `Capture failed: ${errMsg}`;
writeFileSync(
`${outputDir}/BLOCKED.md`,
`# Capture Failed\n\n${reason}\n\nURL: ${url}\n\n## What to try\n\n- Re-run with a longer timeout: \`--timeout 60000\`\n- The site may block headless browsers (anti-bot protection)\n- Try capturing a different page on the same domain\n`,
"utf-8",
);
} catch {
/* best-effort */
}
if (isJson) {
console.log(JSON.stringify({ ok: false, error: errMsg }));
} else {
console.error(`\n ✗ Capture failed: ${errMsg}\n`);
}
process.exit(1);
}
},
});