208 lines
7.6 KiB
JavaScript
208 lines
7.6 KiB
JavaScript
/**
|
||
* cdp.mjs — 极简 Chrome DevTools Protocol 客户端(零依赖,Node >= 22 全局 WebSocket)。
|
||
*
|
||
* launch():spawn 无头 Chromium(--remote-debugging-port=0),从 stderr 解析
|
||
* `DevTools listening on ws://…`,连浏览器级 WebSocket。
|
||
* browser.newPage():Target.createTarget + attachToTarget(flatten) → Page 句柄,
|
||
* 之后所有命令带 sessionId 发送。
|
||
*
|
||
* 用法:
|
||
* const browser = await launch();
|
||
* const page = await browser.newPage({ width: 390, height: 844 });
|
||
* await page.addInitScript(source);
|
||
* await page.navigate('http://localhost:8001/rpg/');
|
||
* const v = await page.eval('1+1');
|
||
* await page.tap(195, 422);
|
||
* await browser.dispose();
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
const CDP_TIMEOUT_MS = 30000;
|
||
|
||
/** 极简 CDP 连接:id 多路复用 + method 事件回调。 */
|
||
class Connection {
|
||
constructor(ws) {
|
||
this.ws = ws;
|
||
this.nextId = 1;
|
||
this.pending = new Map(); // id -> {resolve, reject, timer}
|
||
this.eventHandlers = []; // [{method, fn}],method 为 null 时收全部
|
||
ws.addEventListener('message', (e) => {
|
||
let msg;
|
||
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||
if (msg.id !== undefined) {
|
||
const p = this.pending.get(msg.id);
|
||
if (!p) return;
|
||
this.pending.delete(msg.id);
|
||
clearTimeout(p.timer);
|
||
if (msg.error) p.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
|
||
else p.resolve(msg.result);
|
||
} else if (msg.method) {
|
||
for (const h of this.eventHandlers) {
|
||
if (h.method === null || h.method === msg.method) {
|
||
try { h.fn(msg.params, msg.sessionId || null); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
ws.addEventListener('close', () => {
|
||
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('CDP websocket closed')); }
|
||
this.pending.clear();
|
||
});
|
||
}
|
||
|
||
static async connect(wsUrl) {
|
||
const ws = new WebSocket(wsUrl);
|
||
await new Promise((resolve, reject) => {
|
||
ws.addEventListener('open', resolve, { once: true });
|
||
ws.addEventListener('error', () => reject(new Error(`WebSocket 连接失败: ${wsUrl}`)), { once: true });
|
||
});
|
||
return new Connection(ws);
|
||
}
|
||
|
||
/** @param {string} method @param {object} [params] @param {string} [sessionId] */
|
||
send(method, params = {}, sessionId = undefined, timeoutMs = CDP_TIMEOUT_MS) {
|
||
const id = this.nextId++;
|
||
const msg = { id, method, params };
|
||
if (sessionId) msg.sessionId = sessionId;
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
this.pending.delete(id);
|
||
reject(new Error(`CDP 超时: ${method}`));
|
||
}, timeoutMs);
|
||
this.pending.set(id, { resolve, reject, timer });
|
||
this.ws.send(JSON.stringify(msg));
|
||
});
|
||
}
|
||
|
||
on(method, fn) { this.eventHandlers.push({ method, fn }); }
|
||
|
||
close() { try { this.ws.close(); } catch (_) {} }
|
||
}
|
||
|
||
/** 页面句柄:所有命令自动带 sessionId。 */
|
||
export class Page {
|
||
constructor(conn, sessionId, targetId) {
|
||
this.conn = conn;
|
||
this.sessionId = sessionId;
|
||
this.targetId = targetId;
|
||
}
|
||
|
||
send(method, params = {}, timeoutMs) { return this.conn.send(method, params, this.sessionId, timeoutMs); }
|
||
|
||
/** 页面脚本之前执行(每次导航都注入)。 */
|
||
async addInitScript(source) {
|
||
const r = await this.send('Page.addScriptToEvaluateOnNewDocument', { source });
|
||
return r.identifier;
|
||
}
|
||
|
||
async navigate(url) { return this.send('Page.navigate', { url }); }
|
||
|
||
/** Runtime.evaluate,returnByValue;可选 awaitPromise。异常会 reject。 */
|
||
async eval(expression, { awaitPromise = false, timeoutMs } = {}) {
|
||
const r = await this.send('Runtime.evaluate', {
|
||
expression, returnByValue: true, awaitPromise,
|
||
}, timeoutMs);
|
||
if (r.exceptionDetails) {
|
||
const d = r.exceptionDetails;
|
||
throw new Error(`页面内求值异常: ${d.text} ${(d.exception && d.exception.description) || ''}`);
|
||
}
|
||
return r.result ? r.result.value : undefined;
|
||
}
|
||
|
||
/** 视口与触摸仿真(在 navigate 前设置)。 */
|
||
async setViewport(width, height, { deviceScaleFactor = 2, mobile = true } = {}) {
|
||
await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor, mobile });
|
||
await this.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||
}
|
||
|
||
/** 原始触摸事件派发。type: touchStart|touchMove|touchEnd|touchCancel */
|
||
async dispatchTouch(type, touchPoints = []) {
|
||
await this.send('Input.dispatchTouchEvent', { type, touchPoints });
|
||
}
|
||
|
||
async close() {
|
||
await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
export class Browser {
|
||
constructor(proc, conn, profileDir) {
|
||
this.proc = proc;
|
||
this.conn = conn;
|
||
this.profileDir = profileDir;
|
||
}
|
||
|
||
/** 开新页签并挂 session。 */
|
||
async newPage({ width = 390, height = 844 } = {}) {
|
||
const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' });
|
||
const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true });
|
||
const page = new Page(this.conn, sessionId, targetId);
|
||
await page.send('Page.enable');
|
||
await page.send('Runtime.enable');
|
||
if (width && height) await page.setViewport(width, height);
|
||
return page;
|
||
}
|
||
|
||
on(method, fn) { this.conn.on(method, fn); }
|
||
|
||
/** 关浏览器 + 清 profile 目录。 */
|
||
async dispose() {
|
||
try { this.conn.close(); } catch (_) {}
|
||
if (this.proc && !this.proc.killed) {
|
||
this.proc.kill('SIGKILL');
|
||
}
|
||
if (this.profileDir) {
|
||
try { fs.rmSync(this.profileDir, { recursive: true, force: true }); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* spawn 无头 Chromium 并连上 CDP。
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.executable='chromium']
|
||
* @param {string} [opts.profileBase] 用户数据目录的父目录(snap 对 $HOME 内非隐藏路径可写)
|
||
* @returns {Promise<Browser>}
|
||
*/
|
||
export async function launch(opts = {}) {
|
||
const executable = opts.executable || 'chromium';
|
||
const profileBase = opts.profileBase || fs.mkdtempSync(path.join(os.tmpdir(), 'live-cdp-'));
|
||
fs.mkdirSync(profileBase, { recursive: true });
|
||
const profileDir = fs.mkdtempSync(path.join(profileBase, 'profile-'));
|
||
|
||
const args = [
|
||
'--headless=new',
|
||
'--remote-debugging-port=0',
|
||
`--user-data-dir=${profileDir}`,
|
||
'--no-first-run',
|
||
'--disable-gpu',
|
||
'--mute-audio',
|
||
'--autoplay-policy=no-user-gesture-required',
|
||
'about:blank',
|
||
];
|
||
const proc = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
|
||
const wsUrl = await new Promise((resolve, reject) => {
|
||
let buf = '';
|
||
const timer = setTimeout(() => reject(new Error(`等待 DevTools ws 地址超时。stderr 摘录:\n${buf.slice(-2000)}`)), 20000);
|
||
proc.stderr.on('data', (d) => {
|
||
buf += d.toString();
|
||
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
|
||
if (m) { clearTimeout(timer); resolve(m[1]); }
|
||
});
|
||
proc.on('exit', (code) => { clearTimeout(timer); reject(new Error(`chromium 提前退出 (code ${code})。stderr 摘录:\n${buf.slice(-2000)}`)); });
|
||
proc.on('error', (e) => { clearTimeout(timer); reject(e); });
|
||
});
|
||
|
||
const conn = await Connection.connect(wsUrl);
|
||
const browser = new Browser(proc, conn, profileDir);
|
||
// 忽略浏览器级噪声事件即可;需要时 browser.on(...) 订阅
|
||
return browser;
|
||
}
|
||
|
||
/** sleep 工具(手势编排用)。 */
|
||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|