mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-13 20:59:12 +00:00
feat: add Latchshot page capture skill
This commit is contained in:
+532
@@ -0,0 +1,532 @@
|
||||
#!/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));
|
||||
}
|
||||
Reference in New Issue
Block a user