// 常驻 CDP 桥:只建立一次 WebSocket 连接,之后所有取数都复用这条连接 // 这样 Chrome 的「允许远程调试」只需批准一次。 import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; const OUT = path.resolve('.cdp/out'); fs.mkdirSync(OUT, { recursive: true }); const PORT_FILE = path.resolve('.cdp/bridge-port.txt'); const LOG_FILE = path.resolve('.cdp/bridge.log'); const log = (...a) => { const line = `[${new Date().toISOString()}] ${a.join(' ')}`; fs.appendFileSync(LOG_FILE, line + '\n', 'utf8'); console.log(line); }; const UDD = process.env.CHROME_USER_DATA_DIR || 'C:\\Users\\Administrator\\AppData\\Local\\Google\\Chrome\\User Data'; const lines = fs.readFileSync(path.join(UDD, 'DevToolsActivePort'), 'utf8').split('\n').map(s => s.trim()).filter(Boolean); // ---------- 唯一的 CDP 连接 ---------- let ws = null; let seq = 0; const pending = new Map(); function connect() { return new Promise((resolve, reject) => { log('正在连接 CDP(此时 Chrome 可能弹出一次「允许」对话框)…'); ws = new WebSocket(`ws://127.0.0.1:${lines[0]}${lines[1]}`); const timer = setTimeout(() => reject(new Error('CDP 握手超时(60 秒)——请确认已点「允许」')), 60000); ws.addEventListener('open', () => { clearTimeout(timer); log('CDP 已连接(常驻,后续不再需要批准)'); resolve(); }, { once: true }); ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP 连接失败')); }, { once: true }); ws.addEventListener('close', (e) => { log('CDP 连接已关闭 code=' + e.code); ws = null; for (const [, p] of pending) { clearTimeout(p.timer); p.rej(new Error('CDP 连接已关闭')); } pending.clear(); }); ws.addEventListener('message', (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); clearTimeout(p.timer); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }); }); } function send(method, params = {}, sessionId, timeoutMs = 180000) { if (!ws) return Promise.reject(new Error('CDP 未连接')); const id = ++seq; return new Promise((res, rej) => { const timer = setTimeout(() => { pending.delete(id); rej(new Error('timeout: ' + method)); }, timeoutMs); pending.set(id, { res, rej, timer }); const msg = { id, method, params }; if (sessionId) msg.sessionId = sessionId; ws.send(JSON.stringify(msg)); }); } // ---------- 高层动作:在指定页面执行 JS ---------- async function evalOnPage({ expr, match = 'seller.goofish.com', createUrl, waitMs = 4000, keepTab = false }) { const { targetInfos } = await send('Target.getTargets'); let target = targetInfos.find(t => t.type === 'page' && new RegExp(match, 'i').test(t.url)); let created = false; if (!target) { if (!createUrl) throw new Error(`没有匹配 /${match}/ 的标签页,且未提供 createUrl`); const r = await send('Target.createTarget', { url: createUrl, background: true }); target = { targetId: r.targetId, url: createUrl }; created = true; await new Promise(r2 => setTimeout(r2, waitMs)); } const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); try { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }, sessionId); if (r.exceptionDetails) { return { ok: false, error: r.exceptionDetails.text, detail: r.exceptionDetails.exception?.description || null }; } return { ok: true, value: r.result?.value, pageUrl: target.url }; } finally { await send('Target.detachFromTarget', { sessionId }).catch(() => {}); if (created && !keepTab) await send('Target.closeTarget', { targetId: target.targetId }).catch(() => {}); } } // ---------- HTTP 接口 ---------- const readBody = req => new Promise((res, rej) => { let b = ''; req.on('data', c => { b += c; }); req.on('end', () => { try { res(b ? JSON.parse(b) : {}); } catch (e) { rej(e); } }); req.on('error', rej); }); const server = http.createServer(async (req, res) => { const json = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }; try { if (req.url === '/health') { const ver = ws ? await send('Browser.getVersion').catch(() => null) : null; return json(200, { connected: !!ws, browser: ver?.product || null }); } if (req.url === '/eval' && req.method === 'POST') { const body = await readBody(req); const r = await evalOnPage(body); // 把结果落盘,避免 PowerShell 控制台中文乱码;只回文件路径 if (body.saveAs) { fs.writeFileSync(path.join(OUT, body.saveAs), JSON.stringify(r, null, 2), 'utf8'); return json(200, { ok: r.ok, savedAs: '.cdp/out/' + body.saveAs, error: r.error || null, pageUrl: r.pageUrl || null }); } return json(200, r); } if (req.url === '/raw' && req.method === 'POST') { const body = await readBody(req); const r = await send(body.method, body.params || {}); if (body.saveAs) { fs.writeFileSync(path.join(OUT, body.saveAs), JSON.stringify(r, null, 2), 'utf8'); return json(200, { savedAs: '.cdp/out/' + body.saveAs }); } return json(200, r); } return json(404, { error: 'not found' }); } catch (e) { return json(500, { ok: false, error: String(e && e.message || e) }); } }); try { fs.unlinkSync(LOG_FILE); } catch {} await connect(); server.listen(0, '127.0.0.1', () => { const port = server.address().port; fs.writeFileSync(PORT_FILE, String(port), 'utf8'); log('桥接已启动,端口 ' + port + '(已写入 .cdp/bridge-port.txt)'); }); // 保持进程存活 setInterval(() => { }, 1 << 30);