Files

208 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));