// 最小 CDP 客户端:用真实时间等待页面就绪,再截图 / 取状态。 // 为什么不用 msedge --screenshot:那个走的是"虚拟时间",而贴图解码与 WebGL 初始化吃的是真实 CPU 时间, // 虚拟时间预算会在骨骼真正加载完之前就耗尽,截出来的是还没画内容的帧(这个坑已经踩过一次)。 import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { resolve } from "node:path"; const EDGE = [ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", ].find((p) => existsSync(p)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function withPage(opts, fn) { const { width = 1920, height = 1080, profile = "tools/.cache/edge-cdp", extraArgs = [] } = opts; if (!EDGE) throw new Error("找不到 msedge.exe"); await mkdir(resolve(profile), { recursive: true }); const child = spawn( EDGE, [ "--headless=new", "--remote-debugging-port=0", `--user-data-dir=${resolve(profile)}`, "--no-first-run", "--no-default-browser-check", "--disable-extensions", "--hide-scrollbars", "--enable-unsafe-swiftshader", "--autoplay-policy=no-user-gesture-required", "--mute-audio", `--window-size=${width},${height}`, ...extraArgs, "about:blank", ], { stdio: ["ignore", "pipe", "pipe"] }, ); let stderr = ""; const wsUrl = await new Promise((res, rej) => { const timer = setTimeout(() => rej(new Error("等待 DevTools 端点超时")), 20000); child.stderr.on("data", (d) => { stderr += d; const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/); if (m) { clearTimeout(timer); res(m[1]); } }); child.on("close", () => { clearTimeout(timer); rej(new Error("Edge 提前退出:\n" + stderr.slice(-800))); }); }); const port = new URL(wsUrl).port; const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); const page = list.find((t) => t.type === "page"); if (!page) throw new Error("没有 page target"); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = (e) => rej(new Error("CDP WebSocket 连接失败: " + (e?.message ?? ""))); }); let nextId = 1; const pending = new Map(); const consoleLines = []; const exceptions = []; ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(`${msg.error.message} (${JSON.stringify(msg.error.data ?? "")})`)) : res(msg.result); return; } if (msg.method === "Runtime.consoleAPICalled") { const text = (msg.params.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(" "); consoleLines.push(`${msg.params.type}: ${text}`); } if (msg.method === "Runtime.exceptionThrown") { const d = msg.params.exceptionDetails; exceptions.push(d.exception?.description ?? d.text); } if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") { consoleLines.push(`log-error: ${msg.params.entry.text}`); } }; const send = (method, params = {}) => new Promise((res, rej) => { const id = nextId++; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); }); const evaluate = async (expression) => { const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "evaluate 失败"); return r.result?.value; }; try { await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable"); await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false, }); return await fn({ send, evaluate, consoleLines, exceptions, stderr: () => stderr }); } finally { try { ws.close(); } catch {} child.kill(); await sleep(120); } } export { sleep };