98 lines
5.3 KiB
JavaScript
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 };
|