Files
awesome-copilot/skills/latchshot-page-capture/scripts/latchshot.mjs
T
2026-07-21 08:16:46 +00:00

533 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { randomBytes } from 'node:crypto';
import { access, mkdir, open, rename, rm, stat } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { basename, dirname, extname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const ORIGIN = 'https://latchshot.fly.dev';
const CAPTURE_URL = `${ORIGIN}/v1/render`;
const DEMO_URL = `${ORIGIN}/api/demo`;
const USAGE_URL = `${ORIGIN}/v1/usage`;
const USER_AGENT = 'latchshot-agent-skill/1.1';
const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
const MAX_JSON_BYTES = 1024 * 1024;
const FORMAT_BY_EXTENSION = new Map([
['.png', 'png'],
['.jpg', 'jpeg'],
['.jpeg', 'jpeg'],
['.pdf', 'pdf'],
]);
const CONTENT_TYPE_BY_FORMAT = {
png: 'image/png',
jpeg: 'image/jpeg',
pdf: 'application/pdf',
};
const booleanFlags = new Set([
'allow-query',
'block-ads',
'block-chats',
'block-trackers',
'dark',
'force',
'full-page',
'hide-cookie-banners',
'hide-popups',
'landscape',
'scroll-page',
]);
const valueFlags = new Set([
'delay-ms',
'format',
'height',
'output',
'paper',
'quality',
'scale',
'timeout-ms',
'url',
'wait-until',
'width',
]);
class CliError extends Error {
constructor(message, code = 'invalid_arguments', exitCode = 2) {
super(message);
this.name = 'CliError';
this.code = code;
this.exitCode = exitCode;
}
}
function help() {
return `Usage:
node scripts/latchshot.mjs capture --url URL --output FILE [OPTIONS]
node scripts/latchshot.mjs demo --url URL --output FILE [OPTIONS]
node scripts/latchshot.mjs usage
Capture one public HTTP(S) webpage as a validated local artifact. The demo
command needs no key and returns JPEG only, limited to 3 attempts per IP/hour.
Capture and usage read LATCHSHOT_API_KEY; keys are never accepted as arguments.
Demo options:
--width 320..2560 Viewport width (default: 1440)
--height 240..1440 Viewport height (default: 900)
--allow-query Confirm a query string contains no secret
--force Replace an existing output file atomically
Capture options:
--format png|jpeg|pdf Infer from output extension when omitted
--width 320..2560 Viewport width (default: 1440)
--height 240..1440 Viewport height (default: 900)
--scale 1|2 Device scale factor (default: 1)
--quality 1..100 JPEG only (default: 85)
--full-page Capture bounded full page (screenshots only)
--scroll-page Bounded lazy-content scroll; requires --full-page
--wait-until load|domcontentloaded|networkidle
--delay-ms 0..3000 Additional post-load wait
--timeout-ms 3000..30000 Navigation timeout
--dark Prefer dark color scheme
--block-ads Best-effort known ad-host blocking
--block-trackers Best-effort known tracker-host blocking
--block-chats Best-effort known chat-host blocking
--hide-cookie-banners Hide common consent overlays without clicking
--hide-popups Hide common signup/newsletter/discount overlays
--paper A4|Letter|Legal PDF only (default: A4)
--landscape PDF only
--allow-query Confirm a query string contains no secret
--force Replace an existing output file atomically
--help Show this help
Examples:
node scripts/latchshot.mjs demo --url https://example.com --output demo.jpg
node scripts/latchshot.mjs capture --url https://example.com --output page.png
node scripts/latchshot.mjs capture --url https://example.com --output page.pdf --paper A4
node scripts/latchshot.mjs usage
Exit codes: 0 success, 2 invalid input, 3 missing key, 4 API/network failure,
5 invalid artifact or filesystem failure.`;
}
function parseFlags(tokens) {
const flags = new Map();
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token.startsWith('--')) {
throw new CliError(`unexpected argument: ${token}`);
}
const separator = token.indexOf('=');
const name = token.slice(2, separator === -1 ? undefined : separator);
if (flags.has(name)) throw new CliError(`duplicate option: --${name}`);
if (name === 'help') {
flags.set(name, true);
continue;
}
if (booleanFlags.has(name)) {
if (separator !== -1) throw new CliError(`--${name} does not accept a value`);
flags.set(name, true);
continue;
}
if (!valueFlags.has(name)) throw new CliError(`unknown option: --${name}`);
const value = separator === -1 ? tokens[++index] : token.slice(separator + 1);
if (value === undefined || value.startsWith('--') || value === '') {
throw new CliError(`--${name} requires a value`);
}
flags.set(name, value);
}
return flags;
}
function integerFlag(flags, name, fallback, minimum, maximum) {
const raw = flags.get(name);
const value = raw === undefined ? fallback : Number(raw);
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new CliError(`--${name} must be an integer from ${minimum} to ${maximum}`);
}
return value;
}
function choiceFlag(flags, name, fallback, allowed) {
const value = flags.get(name) ?? fallback;
if (!allowed.includes(value)) {
throw new CliError(`--${name} must be one of: ${allowed.join(', ')}`);
}
return value;
}
function targetUrl(raw, allowQuery) {
if (!raw) throw new CliError('--url is required');
let target;
try {
target = new URL(raw);
} catch {
throw new CliError('--url must be a valid absolute HTTP or HTTPS URL', 'invalid_target');
}
if (!['http:', 'https:'].includes(target.protocol)) {
throw new CliError('--url must use http or https', 'invalid_target');
}
if (target.username || target.password) {
throw new CliError('--url must not contain credentials', 'invalid_target');
}
if (target.port && !['80', '443'].includes(target.port)) {
throw new CliError('--url may use only web ports 80 or 443', 'invalid_target');
}
if (target.search && !allowQuery) {
throw new CliError('--url has a query string; verify it contains no secret, then pass --allow-query', 'query_confirmation_required');
}
if (target.hash) {
throw new CliError('--url must not contain a fragment', 'invalid_target');
}
return target.href;
}
function apiKey(env) {
const key = env.LATCHSHOT_API_KEY;
if (!key || typeof key !== 'string' || /\s/.test(key)) {
throw new CliError('set LATCHSHOT_API_KEY to a Latchshot key; do not pass it as an argument', 'missing_api_key', 3);
}
return key;
}
function outputContract(flags) {
const raw = flags.get('output');
if (!raw || raw === '-') throw new CliError('--output is required and must be a file path');
const output = resolve(raw);
const inferred = FORMAT_BY_EXTENSION.get(extname(output).toLowerCase());
if (!inferred) throw new CliError('--output must end in .png, .jpg, .jpeg, or .pdf');
const format = choiceFlag(flags, 'format', inferred, ['png', 'jpeg', 'pdf']);
if (format !== inferred) {
throw new CliError(`--format ${format} does not match output file ${basename(output)}`);
}
return { output, format };
}
function capturePayload(flags) {
const { output, format } = outputContract(flags);
const fullPage = flags.has('full-page');
const scrollPage = flags.has('scroll-page');
if (scrollPage && !fullPage) throw new CliError('--scroll-page requires --full-page');
if (format === 'pdf' && (fullPage || scrollPage)) {
throw new CliError('--full-page and --scroll-page are screenshot options, not PDF options');
}
if (flags.has('quality') && format !== 'jpeg') {
throw new CliError('--quality is supported only for JPEG output');
}
if (format !== 'pdf' && (flags.has('paper') || flags.has('landscape'))) {
throw new CliError('--paper and --landscape are supported only for PDF output');
}
const payload = {
url: targetUrl(flags.get('url'), flags.has('allow-query')),
format,
width: integerFlag(flags, 'width', 1440, 320, 2560),
height: integerFlag(flags, 'height', 900, 240, 1440),
scale: integerFlag(flags, 'scale', 1, 1, 2),
fullPage,
scrollPage,
waitUntil: choiceFlag(flags, 'wait-until', 'domcontentloaded', ['load', 'domcontentloaded', 'networkidle']),
delayMs: integerFlag(flags, 'delay-ms', 0, 0, 3000),
timeoutMs: integerFlag(flags, 'timeout-ms', 15000, 3000, 30000),
darkMode: flags.has('dark'),
blockAds: flags.has('block-ads'),
blockTrackers: flags.has('block-trackers'),
blockChats: flags.has('block-chats'),
hideCookieBanners: flags.has('hide-cookie-banners'),
hidePopups: flags.has('hide-popups'),
};
if (format === 'jpeg') payload.quality = integerFlag(flags, 'quality', 85, 1, 100);
if (format === 'pdf') {
payload.paper = choiceFlag(flags, 'paper', 'A4', ['A4', 'Letter', 'Legal']);
payload.landscape = flags.has('landscape');
}
return { output, format, payload, force: flags.has('force') };
}
function demoPayload(flags) {
const allowed = new Set(['allow-query', 'force', 'height', 'output', 'url', 'width']);
for (const name of flags.keys()) {
if (!allowed.has(name)) throw new CliError(`demo does not accept --${name}`);
}
const { output, format } = outputContract(flags);
if (format !== 'jpeg') throw new CliError('demo output must end in .jpg or .jpeg');
return {
output,
format,
payload: {
url: targetUrl(flags.get('url'), flags.has('allow-query')),
width: integerFlag(flags, 'width', 1440, 320, 2560),
height: integerFlag(flags, 'height', 900, 240, 1440),
},
force: flags.has('force'),
};
}
async function readBounded(response, maximum) {
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maximum) {
throw new CliError(`response exceeds ${maximum} bytes`, 'response_too_large', 5);
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maximum) {
await reader.cancel();
throw new CliError(`response exceeds ${maximum} bytes`, 'response_too_large', 5);
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
function redact(value) {
return String(value || '')
.replace(/ls_live_[A-Za-z0-9_-]+/g, '[redacted-key]')
.replace(/https?:\/\/[^\s"']+/g, '[redacted-url]')
.replace(/[\r\n]+/g, ' ')
.slice(0, 500);
}
function apiFailure(status, body) {
let code = `http_${status}`;
let message = `Latchshot returned HTTP ${status}`;
try {
const parsed = JSON.parse(body.toString('utf8'));
code = redact(parsed?.error?.code || code);
message = redact(parsed?.error?.message || message);
} catch {
const text = redact(body.toString('utf8'));
if (text) message = text;
}
throw new CliError(message, code, 4);
}
function validateArtifact(format, contentType, body) {
const expected = CONTENT_TYPE_BY_FORMAT[format];
if (contentType !== expected) {
throw new CliError(`expected ${expected} but received ${contentType || 'no content type'}`, 'invalid_content_type', 5);
}
const valid = format === 'png'
? body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
: format === 'jpeg'
? body.length >= 4 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff
: body.subarray(0, 5).equals(Buffer.from('%PDF-'));
if (!valid) throw new CliError(`response body is not a valid ${format} artifact`, 'invalid_artifact', 5);
}
async function exists(path) {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function writeAtomic(output, body, force) {
if (!force && await exists(output)) {
throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5);
}
await mkdir(dirname(output), { recursive: true });
const temporary = `${output}.latchshot-${process.pid}-${randomBytes(6).toString('hex')}.tmp`;
let handle;
try {
handle = await open(temporary, 'wx', 0o600);
await handle.writeFile(body);
await handle.sync();
await handle.close();
handle = null;
await rename(temporary, output);
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
if (error instanceof CliError) throw error;
throw new CliError(`could not write output: ${redact(error.message)}`, 'output_failed', 5);
}
}
function numericHeader(headers, name) {
const value = headers.get(name);
if (value === null) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
async function capture(flags, env, fetchImpl) {
const key = apiKey(env);
const { output, format, payload, force } = capturePayload(flags);
if (!force && await exists(output)) {
throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5);
}
let response;
try {
response = await fetchImpl(CAPTURE_URL, {
method: 'POST',
headers: {
authorization: `Bearer ${key}`,
'content-type': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(payload.timeoutMs + 15000),
});
} catch (error) {
throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4);
}
const body = await readBounded(response, response.ok ? MAX_ARTIFACT_BYTES : MAX_JSON_BYTES);
if (!response.ok) apiFailure(response.status, body);
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
validateArtifact(format, contentType, body);
await writeAtomic(output, body, force);
const file = await stat(output);
return {
ok: true,
operation: 'capture',
output,
format,
contentType,
bytes: file.size,
render: {
durationMs: numericHeader(response.headers, 'x-latchshot-render-ms'),
navigation: response.headers.get('x-latchshot-navigation'),
fonts: response.headers.get('x-latchshot-fonts'),
scripts: response.headers.get('x-latchshot-scripts'),
scroll: response.headers.get('x-latchshot-scroll'),
},
quota: {
limit: numericHeader(response.headers, 'x-quota-limit'),
remaining: numericHeader(response.headers, 'x-quota-remaining'),
resetAt: response.headers.get('x-quota-reset'),
},
};
}
async function demo(flags, fetchImpl) {
const { output, format, payload, force } = demoPayload(flags);
if (!force && await exists(output)) {
throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5);
}
let response;
try {
response = await fetchImpl(DEMO_URL, {
method: 'POST',
headers: {
accept: 'image/jpeg',
'content-type': 'application/json',
'user-agent': USER_AGENT,
'x-latchshot-acquisition': 'agentskill',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30000),
});
} catch (error) {
throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4);
}
const body = await readBounded(response, response.ok ? MAX_ARTIFACT_BYTES : MAX_JSON_BYTES);
if (!response.ok) apiFailure(response.status, body);
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
validateArtifact(format, contentType, body);
await writeAtomic(output, body, force);
const file = await stat(output);
return {
ok: true,
operation: 'demo',
output,
format,
contentType,
bytes: file.size,
render: {
durationMs: numericHeader(response.headers, 'x-latchshot-render-ms'),
navigation: response.headers.get('x-latchshot-navigation'),
fonts: response.headers.get('x-latchshot-fonts'),
scripts: response.headers.get('x-latchshot-scripts'),
},
demo: {
authenticationRequired: false,
attemptsPerIpPerHour: 3,
},
};
}
async function usage(env, fetchImpl) {
const key = apiKey(env);
let response;
try {
response = await fetchImpl(USAGE_URL, {
headers: {
authorization: `Bearer ${key}`,
accept: 'application/json',
'user-agent': USER_AGENT,
},
signal: AbortSignal.timeout(15000),
});
} catch (error) {
throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4);
}
const body = await readBounded(response, MAX_JSON_BYTES);
if (!response.ok) apiFailure(response.status, body);
try {
return { ok: true, operation: 'usage', usage: JSON.parse(body.toString('utf8')) };
} catch {
throw new CliError('usage response was not valid JSON', 'invalid_usage_response', 5);
}
}
export async function run(argv, options = {}) {
const env = options.env || process.env;
const fetchImpl = options.fetchImpl || globalThis.fetch;
const stdout = options.stdout || ((line) => console.log(line));
const stderr = options.stderr || ((line) => console.error(line));
try {
if (argv.length === 0 || argv.includes('--help')) {
stdout(help());
return 0;
}
const [command, ...tokens] = argv;
const flags = parseFlags(tokens);
if (flags.has('help')) {
stdout(help());
return 0;
}
let result;
if (command === 'capture') result = await capture(flags, env, fetchImpl);
else if (command === 'demo') result = await demo(flags, fetchImpl);
else if (command === 'usage') {
if (flags.size > 0) throw new CliError('usage does not accept options');
result = await usage(env, fetchImpl);
} else {
throw new CliError(`command must be capture, demo, or usage; received: ${command}`);
}
stdout(JSON.stringify(result));
return 0;
} catch (error) {
const safe = error instanceof CliError
? error
: new CliError(redact(error.message), 'unexpected_error', 5);
stderr(JSON.stringify({ ok: false, error: { code: safe.code, message: redact(safe.message) } }));
return safe.exitCode;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
process.exitCode = await run(process.argv.slice(2));
}