99 lines
3.7 KiB
JavaScript
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); });
|