orbit/dev/cdp-shot.mjs

99 lines
3.7 KiB
JavaScript

/**
* Dev-only CDP screenshot harness (any Chromium-CDP browser: Chrome,
* Brave, Edge). Unlike `--headless --screenshot` (which fires at first
* paint), this waits for the page to signal readiness before capturing.
*
* node dev/cdp-shot.mjs <url> <out.png> [readyExpr] [timeoutMs] [beforeCaptureExpr]
*
* readyExpr is a JS expression evaluated repeatedly in the page; the shot
* is taken when it returns a truthy value (default: none → capture
* shortly after load). The expression's truthy value is printed.
* beforeCaptureExpr (optional) runs once right before the capture (e.g.
* to hide dev overlays).
*
* Requires a headless browser exposing CDP on 127.0.0.1:9333:
* /opt/brave.com/brave/brave --headless --disable-gpu \
* --remote-debugging-port=9333 about:blank
* (or point CDP_PORT at another one.)
*/
const [url, out, readyExpr, timeoutMs, beforeCaptureExpr] = process.argv.slice(2);
if (!url || !out) {
console.error('usage: node dev/cdp-shot.mjs <url> <out.png> [readyExpr] [timeoutMs]');
process.exit(2);
}
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
const deadline = Date.now() + Number(timeoutMs ?? 60000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
// Pick the first page target (or open one).
let target = await (await fetch(`http://${CDP}/json/list`)).json()
.then((ts) => ts.find((t) => t.type === 'page'));
if (!target) {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
}
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
const events = [];
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?')));
});
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
} else if (msg.method) {
events.push(msg);
}
};
await send('Page.enable');
await send('Runtime.enable');
await send('Page.navigate', { url });
const loaded = new Promise((res) => {
const t = setInterval(() => {
if (events.some((e) => e.method === 'Page.loadEventFired')) { clearInterval(t); res(); }
}, 50);
setTimeout(() => { clearInterval(t); res(); }, 20000);
});
await loaded;
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
let ready = null;
if (readyExpr) {
while (Date.now() < deadline) {
ready = await evaluate(readyExpr).catch(() => null);
if (ready) break;
await sleep(200);
}
} else {
await sleep(1000);
}
if (beforeCaptureExpr) await evaluate(beforeCaptureExpr).catch(() => null);
// One more beat so any final paint lands.
await sleep(250);
const shot = await send('Page.captureScreenshot', { format: 'png' });
const buf = Buffer.from(shot.data, 'base64');
await (await import('node:fs/promises')).writeFile(out, buf);
ws.close();
console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 400)}` : ''));
}
main().catch((err) => { console.error('cdp-shot failed:', err.message); process.exit(1); });