#!/usr/bin/env node /** * cdp_capture.js —— 用 CDP 抓**全部目标**(含 OOPIF iframe)的请求 + 响应体, 存成 JSON。 * * 为什么需要它 (而不是 cdp_net_capture.js): * 1. 腾讯验证码 iframe (turing.captcha.gtimg.com) 与页面 (1ms.run) **不同站**, * Chrome 站点隔离下它是独立的 target; 只 attach 顶层页面会**漏掉**验证码自己的 * prehandle / getcapbysig / verify 请求。 * 2. 排查 errorCode=12 需要看**响应体**, cdp_net_capture.js 只记 requestWillBeSent。 * * 零依赖 (Node >= 22 内置 WebSocket / fetch)。 * * 用法: * node tools/cdp_capture.js --seconds 30 --url-substr cap_union --out /tmp/cap.json * node tools/cdp_capture.js --list * * 选项: * --port N 调试端口 (默认 9222) * --seconds N 抓取时长 (默认 20) * --url-substr S 只保留 url 含 S 的请求 (可选; 响应体只对命中的取) * --out PATH 输出 JSON (默认 /tmp/cdp_capture.json) * --bodies 额外抓响应体 (默认开) */ const fs = require('fs'); function arg(name, def) { const i = process.argv.indexOf('--' + name); if (i < 0) return def; const v = process.argv[i + 1]; return (v === undefined || v.startsWith('--')) ? true : v; } class Cdp { constructor(ws, tag) { this.ws = ws; this.tag = tag; this.id = 0; this.waits = new Map(); this.handlers = []; } static async connect(url, tag) { const ws = new WebSocket(url); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); const c = new Cdp(ws, tag); ws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.id && c.waits.has(m.id)) { const { res, rej } = c.waits.get(m.id); c.waits.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); } else if (m.method) { for (const h of c.handlers) h(m); } }; return c; } send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((res, rej) => this.waits.set(id, { res, rej })); } on(fn) { this.handlers.push(fn); } close() { try { this.ws.close(); } catch (e) {} } } const sleep = ms => new Promise(r => setTimeout(r, ms)); (async () => { const port = parseInt(arg('port', '9222'), 10); if (arg('list', false)) { const t = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 110)}\t${x.id}`); return; } const seconds = parseInt(arg('seconds', '20'), 10); const substrArg = arg('url-substr', null); const substrs = substrArg && substrArg !== true ? String(substrArg).split(',').filter(Boolean) : null; const out = arg('out', '/tmp/cdp_capture.json'); const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) .filter(t => t.type === 'page' || t.type === 'iframe'); const conns = []; const records = []; const byReq = new Map(); for (const t of targets) { let cdp; try { cdp = await Cdp.connect(t.webSocketDebuggerUrl, t.url || t.id); } catch (e) { continue; } conns.push(cdp); const key = (p) => `${cdp.tag}|${p.requestId}`; cdp.on(m => { if (m.method === 'Network.requestWillBeSent') { const r = m.params.request; if (substrs && !substrs.some(s => r.url.includes(s))) return; const rec = { target: cdp.tag, url: r.url, method: r.method, headers: r.headers, postData: r.postData || null, ts: Date.now(), }; records.push(rec); byReq.set(key(m.params), rec); } else if (m.method === 'Network.responseReceived') { const rec = byReq.get(key(m.params)); if (rec) { rec.status = m.params.response.status; rec.respHeaders = m.params.response.headers; rec.mimeType = m.params.response.mimeType; } } else if (m.method === 'Network.loadingFinished') { const rec = byReq.get(key(m.params)); if (rec && rec.status && !rec.body) { cdp.send('Network.getResponseBody', { requestId: m.params.requestId }) .then(b => { rec.body = b.body; rec.base64 = !!b.base64Encoded; }) .catch(() => {}); } } }); await cdp.send('Network.enable'); } console.error(`[cdp_capture] attached ${conns.length} targets, capturing ${seconds}s ...`); await sleep(seconds * 1000); for (const c of conns) c.close(); fs.writeFileSync(out, JSON.stringify({ capturedAt: new Date().toISOString(), records }, null, 2)); console.error(`[cdp_capture] wrote ${records.length} records -> ${out}`); })().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });