/** * 极简 CDP 客户端(零依赖,靠 Node 22+ 自带的全局 WebSocket)。 * * 只做三件事:拉起一个 Chromium/Edge、连上它的 browser target、在页面里求值。 * 够用来驱动酒馆前端把「真实发送」那一下点出去。 */ import { spawn } from "node:child_process"; import { existsSync, mkdtempSync } from "node:fs"; import os from "node:os"; import path from "node:path"; const BROWSER_CANDIDATES = [ "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe", ]; export function findBrowser(explicit) { if (explicit) return explicit; for (const p of BROWSER_CANDIDATES) { if (existsSync(p)) return p; } throw new Error("找不到 chrome.exe / msedge.exe,可用 --browser 指定"); } export async function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } export async function waitFor(fn, { timeoutMs = 60000, intervalMs = 300, label = "条件" } = {}) { const start = Date.now(); let lastErr; while (Date.now() - start < timeoutMs) { try { const value = await fn(); if (value) return value; } catch (err) { lastErr = err; } await sleep(intervalMs); } throw new Error(`等待「${label}」超时(${timeoutMs}ms)${lastErr ? `:${lastErr.message}` : ""}`); } export class CdpSession { #ws; #nextId = 1; #pending = new Map(); #listeners = new Map(); constructor(ws, label) { this.label = label; this.#ws = ws; ws.addEventListener("message", (event) => { let msg; try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; } if (msg.id !== undefined && this.#pending.has(msg.id)) { const { resolve, reject } = this.#pending.get(msg.id); this.#pending.delete(msg.id); if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)); else resolve(msg.result); return; } if (msg.method) { for (const cb of this.#listeners.get(msg.method) ?? []) cb(msg.params); } }); } static async connect(wsUrl, label = "cdp") { const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.addEventListener("open", () => resolve(), { once: true }); ws.addEventListener("error", (e) => reject(new Error(`ws 连接失败:${e.message ?? ""}`)), { once: true }); }); return new CdpSession(ws, label); } send(method, params = {}, sessionId) { const id = this.#nextId++; const payload = { id, method, params }; if (sessionId) payload.sessionId = sessionId; return new Promise((resolve, reject) => { this.#pending.set(id, { resolve, reject }); try { this.#ws.send(JSON.stringify(payload)); } catch (err) { this.#pending.delete(id); reject(err); } }); } on(method, cb) { const list = this.#listeners.get(method) ?? []; list.push(cb); this.#listeners.set(method, list); } close() { try { this.#ws.close(); } catch { /* ignore */ } } } /** 起浏览器(默认 headless),返回进程 + 调试端口。 */ export async function launchBrowser({ exe, port = 9222, headless = true, userDataDir } = {}) { const executable = findBrowser(exe); const profileDir = userDataDir ?? mkdtempSync(path.join(os.tmpdir(), "wa-cdp-")); const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check", "--disable-extensions", "--disable-background-networking", "--disable-sync", "--disable-gpu", "--mute-audio", "about:blank", ]; if (headless) args.unshift("--headless=new"); const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] }); child.stdout.on("data", () => {}); child.stderr.on("data", () => {}); const version = await waitFor( async () => (await fetch(`http://127.0.0.1:${port}/json/version`)).ok && (await fetch(`http://127.0.0.1:${port}/json/version`)).json(), { timeoutMs: 30000, label: `浏览器调试端口 ${port}` }, ); return { child, port, wsUrl: version.webSocketDebuggerUrl, exe: executable, profileDir }; } /** 在浏览器里开一个页面并返回可求值的会话。 */ export async function openPage(browser, url) { const cdp = await CdpSession.connect(browser.wsUrl, "browser"); const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); await cdp.send("Page.enable", {}, sessionId); await cdp.send("Runtime.enable", {}, sessionId); if (url) await cdp.send("Page.navigate", { url }, sessionId); return { cdp, targetId, sessionId }; } /** 页面内求值(支持 await)。非 byValue 友好的值请自行 JSON 化。 */ export async function evaluate(page, expression, { awaitPromise = true } = {}) { const result = await page.cdp.send( "Runtime.evaluate", { expression, awaitPromise, returnByValue: true, userGesture: true, allowUnsafeEvalBlockedByCSP: true, }, page.sessionId, ); if (result.exceptionDetails) { const detail = result.exceptionDetails; const text = detail.exception?.description ?? detail.exception?.value ?? detail.text ?? "页面内求值失败"; throw new Error(String(text)); } return result.result?.value; } /** 把页面的 console / 异常收进 page.logs,排查前端卡住用。 */ export function captureConsole(page) { page.logs = []; const push = (type, text) => { page.logs.push({ type, text: String(text).slice(0, 800) }); if (page.logs.length > 300) page.logs.shift(); }; page.cdp.on("Runtime.consoleAPICalled", (params) => { const text = (params.args || []).map((a) => a.value ?? a.description ?? a.type).join(" "); push(params.type, text); }); page.cdp.on("Runtime.exceptionThrown", (params) => { const d = params.exceptionDetails || {}; push("exception", (d.exception && d.exception.description) || d.text || ""); }); page.cdp.on("Log.entryAdded", (params) => { const e = params.entry || {}; push("log:" + e.level, e.text || ""); }); page.cdp.send("Log.enable", {}, page.sessionId).catch(() => {}); return page.logs; }