139 lines
5.8 KiB
JavaScript
139 lines
5.8 KiB
JavaScript
// 常驻 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);
|