/** * 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} */ 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));