Files
ObsidianHome/小鱼/.cdp/bridge.mjs
T

139 lines
5.8 KiB
JavaScript
Raw 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 桥:只建立一次 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);