Files

98 lines
5.3 KiB
JavaScript

// 单连接 CDP 客户端,要求 Node.js 22+(内置 fetch / WebSocket)。
const RECONNECT_DELAY_MS = 3000;
class CdpClient {
constructor(port = 9223) {
this.port = port; this.ws = null; this.sessionId = null;
this._id = 0; this._pending = new Map(); this._connecting = null; this.generation = 0;
}
get base() { return `http://127.0.0.1:${this.port}`; }
async isAlive() {
try {
const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(1500) });
return r.ok && !!(await r.json()).webSocketDebuggerUrl;
} catch { return false; }
}
connect() {
if (this.ws?.readyState === 1 && this.sessionId) return Promise.resolve();
if (this._connecting) return this._connecting;
const generation = ++this.generation;
const attempt = this._connect(generation);
this._connecting = attempt;
attempt.finally(() => { if (this._connecting === attempt) this._connecting = null; }).catch(() => {});
return attempt;
}
async _connect(generation) {
let ws;
try {
const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(3000) });
if (!r.ok) throw new Error('调试端口没有返回 CDP 信息');
const v = await r.json();
if (this.generation !== generation) throw new Error('连接已取消');
ws = new WebSocket(v.webSocketDebuggerUrl);
this.ws = ws;
await new Promise((resolve, reject) => {
const timer = setTimeout(() => { reject(new Error('连接游戏超时')); try { ws.close(); } catch {} }, 4000);
const fail = () => { clearTimeout(timer); reject(new Error('无法建立游戏调试连接')); };
ws.onopen = () => { clearTimeout(timer); resolve(); };
ws.onerror = fail; ws.onclose = fail;
});
if (this.generation !== generation) throw new Error('连接已取消');
ws.onmessage = ev => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
const p = this._pending.get(msg.id);
if (p && p.ws === ws) {
this._pending.delete(msg.id); clearTimeout(p.timer);
if (msg.error) p.reject(new Error(JSON.stringify(msg.error))); else p.resolve(msg.result);
}
if (msg.method === 'Target.detachedFromTarget' && msg.params?.sessionId === this.sessionId && this.ws === ws) this.close();
};
ws.onclose = () => this._drop(ws, new Error('游戏连接已断开'));
ws.onerror = () => { this._drop(ws, new Error('游戏调试连接出错')); try { ws.close(); } catch {} };
const { targetInfos } = await this._send('Target.getTargets', {}, false);
const pages = targetInfos.filter(t => t.type === 'page' && /\/index\.html?(?:[?#]|$)/i.test(t.url || ''));
if (pages.length !== 1) throw new Error('无法唯一识别游戏主页面,请关闭多余游戏窗口');
const { sessionId } = await this._send('Target.attachToTarget', { targetId: pages[0].targetId, flatten: true }, false);
if (this.ws !== ws || this.generation !== generation) throw new Error('连接已更换');
this.sessionId = sessionId;
} catch (e) {
if (ws) { this._drop(ws, e); try { ws.close(); } catch {} }
throw e;
}
}
_drop(ws, error) {
for (const [id, p] of this._pending) if (p.ws === ws) {
clearTimeout(p.timer); p.reject(error); this._pending.delete(id);
}
if (this.ws === ws) { this.ws = null; this.sessionId = null; }
}
_send(method, params = {}, session = true) {
const ws = this.ws;
if (!ws || ws.readyState !== 1) return Promise.reject(new Error('游戏连接已断开'));
const id = ++this._id, payload = { id, method, params };
if (session && this.sessionId) payload.sessionId = this.sessionId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { this._pending.delete(id); reject(new Error(`CDP 调用超时: ${method};不要立即重复操作,请先检查游戏状态`)); }, 20000);
this._pending.set(id, { resolve, reject, timer, ws });
try { ws.send(JSON.stringify(payload)); }
catch (e) { clearTimeout(timer); this._pending.delete(id); reject(e); }
});
}
async eval(expression) {
await this.connect();
const res = await this._send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
return res.result?.value;
}
async evalJson(expression) {
const value = await this.eval(expression);
return typeof value === 'string' ? JSON.parse(value) : value;
}
async screenshot() { await this.connect(); return (await this._send('Page.captureScreenshot', { format: 'png' })).data; }
close() {
++this.generation; this._connecting = null;
const ws = this.ws;
if (ws) { this._drop(ws, new Error('游戏连接已关闭或切换')); try { ws.close(); } catch {} }
}
}
module.exports = { CdpClient, RECONNECT_DELAY_MS };