124 lines
4.5 KiB
JavaScript
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();
|
|
}
|
|
}
|