orbit/dev/cdpshot.mjs

124 lines
4.5 KiB
JavaScript

// Minimal CDP driver — real-time (no virtual-time budget), so
// requestAnimationFrame keeps ticking like in a normal browser session.
// (Headless Chrome with --virtual-time-budget stops rAF after a couple of
// frames — the old `chrome --headless --screenshot` path is broken for this
// game; this is not.)
//
// CLI: node cdpshot.mjs <url> <out.png> [waitMs] [w] [h]
// Module: import { CDP, launch } from './cdpshot.mjs'
import { spawn } from 'node:child_process';
import fs from 'node:fs';
const CHROME = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Launch an isolated headless Chrome with CDP enabled.
* Returns { url, kill() } — url = the webSocketDebuggerUrl of a new tab at
* `targetUrl` (blank if omitted). kill() terminates Chrome and cleans up
* its profile dir.
*/
export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() * 100), width = 1280, height = 720, waitMs = 4000) {
const os = await import('node:os');
const base = process.platform === 'win32'
? 'C:/Users/BRIAN~1.FER/AppData/Local/Temp'
: os.tmpdir();
const userDataDir = `${base.replace(/\/$/, '')}/cdp-prof-${process.pid}-${port}`;
fs.rmSync(userDataDir, { recursive: true, force: true });
const chrome = spawn(CHROME, [
'--headless',
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--no-sandbox', // container/CI friendly (also a no-op where it isn't needed)
'--enable-unsafe-swiftshader',
'--hide-scrollbars',
`--window-size=${width},${height}`,
'about:blank',
], { stdio: 'ignore' });
for (let i = 0; i < waitMs / 100; i++) {
try {
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
if (r.ok) break;
} catch { /* not up yet */ }
await sleep(100);
}
// A fresh tab at the target URL (PUT = newer Chrome, POST = older).
const newUrl = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(targetUrl || 'about:blank')}`;
let target;
try {
target = await (await fetch(newUrl, { method: 'PUT' })).json();
} catch {
target = await (await fetch(newUrl, { method: 'POST' })).json();
}
const kill = () => {
chrome.kill();
// Chrome may still hold profile files for a moment after kill; retry politely.
(async () => {
for (let i = 0; i < 12; i++) {
try { fs.rmSync(userDataDir, { recursive: true, force: true }); return; } catch { await sleep(250); }
}
})();
};
return { url: target.webSocketDebuggerUrl, kill };
}
/** Connect a CDP session to an already-running page. */
export class CDP {
constructor(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.id = 0;
this.pending = new Map();
this.listeners = new Map();
this.ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
} else if (msg.method && this.listeners.has(msg.method)) {
for (const fn of this.listeners.get(msg.method)) fn(msg.params);
}
};
}
open() { return new Promise((r, j) => { this.ws.onopen = r; this.ws.onerror = j; }); }
send(method, params = {}) {
return new Promise((resolve, reject) => {
const mid = ++this.id;
this.pending.set(mid, { resolve, reject });
this.ws.send(JSON.stringify({ id: mid, method, params }));
});
}
on(method, fn) {
if (!this.listeners.has(method)) this.listeners.set(method, []);
this.listeners.get(method).push(fn);
}
close() { try { this.ws.close(); } catch { /* already closed */ } }
}
// ---- CLI entry ------------------------------------------------------
if (process.argv[1] && /cdpshot\.mjs$/.test(process.argv[1])) {
const [url, out, waitMs = '7000', W = '1280', H = '720'] = process.argv.slice(2);
const { url: wsUrl, kill } = await launch(url, undefined, Number(W), Number(H));
const cdp = new CDP(wsUrl);
try {
await cdp.open();
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Page.navigate', { url });
await sleep(Number(waitMs));
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(out, Buffer.from(shot.data, 'base64'));
console.log('saved', out);
} finally {
cdp.close();
kill();
}
}