harness: 全拟真live试玩管线(CDP真机驱动chromium+WebAudio输出采集+FunASR/SenseVoice增量转写,判旁白OK/TRUNCATED/MISMATCH/SILENCE;每游戏独立控制端口8123-8128)+ serve.sh去硬编码路径 + gitignore屏蔽live/out产物

This commit is contained in:
xsl
2026-09-01 21:59:45 +08:00
parent 2de1fecbc6
commit 624fec8a23
7 changed files with 1297 additions and 1 deletions
+207
View File
@@ -0,0 +1,207 @@
/**
* cdp.mjs — 极简 Chrome DevTools Protocol 客户端(零依赖,Node >= 22 全局 WebSocket)。
*
* launch():spawn 无头 Chromium(--remote-debugging-port=0),从 stderr 解析
* `DevTools listening on ws://…`,连浏览器级 WebSocket。
* browser.newPage():Target.createTarget + attachToTarget(flatten) → Page 句柄,
* 之后所有命令带 sessionId 发送。
*
* 用法:
* const browser = await launch();
* const page = await browser.newPage({ width: 390, height: 844 });
* await page.addInitScript(source);
* await page.navigate('http://localhost:8001/rpg/');
* const v = await page.eval('1+1');
* await page.tap(195, 422);
* await browser.dispose();
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const CDP_TIMEOUT_MS = 30000;
/** 极简 CDP 连接:id 多路复用 + method 事件回调。 */
class Connection {
constructor(ws) {
this.ws = ws;
this.nextId = 1;
this.pending = new Map(); // id -> {resolve, reject, timer}
this.eventHandlers = []; // [{method, fn}],method 为 null 时收全部
ws.addEventListener('message', (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.id !== undefined) {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
clearTimeout(p.timer);
if (msg.error) p.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
else p.resolve(msg.result);
} else if (msg.method) {
for (const h of this.eventHandlers) {
if (h.method === null || h.method === msg.method) {
try { h.fn(msg.params, msg.sessionId || null); } catch (_) {}
}
}
}
});
ws.addEventListener('close', () => {
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('CDP websocket closed')); }
this.pending.clear();
});
}
static async connect(wsUrl) {
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true });
ws.addEventListener('error', () => reject(new Error(`WebSocket 连接失败: ${wsUrl}`)), { once: true });
});
return new Connection(ws);
}
/** @param {string} method @param {object} [params] @param {string} [sessionId] */
send(method, params = {}, sessionId = undefined, timeoutMs = CDP_TIMEOUT_MS) {
const id = this.nextId++;
const msg = { id, method, params };
if (sessionId) msg.sessionId = sessionId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP 超时: ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(msg));
});
}
on(method, fn) { this.eventHandlers.push({ method, fn }); }
close() { try { this.ws.close(); } catch (_) {} }
}
/** 页面句柄:所有命令自动带 sessionId。 */
export class Page {
constructor(conn, sessionId, targetId) {
this.conn = conn;
this.sessionId = sessionId;
this.targetId = targetId;
}
send(method, params = {}, timeoutMs) { return this.conn.send(method, params, this.sessionId, timeoutMs); }
/** 页面脚本之前执行(每次导航都注入)。 */
async addInitScript(source) {
const r = await this.send('Page.addScriptToEvaluateOnNewDocument', { source });
return r.identifier;
}
async navigate(url) { return this.send('Page.navigate', { url }); }
/** Runtime.evaluate,returnByValue;可选 awaitPromise。异常会 reject。 */
async eval(expression, { awaitPromise = false, timeoutMs } = {}) {
const r = await this.send('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise,
}, timeoutMs);
if (r.exceptionDetails) {
const d = r.exceptionDetails;
throw new Error(`页面内求值异常: ${d.text} ${(d.exception && d.exception.description) || ''}`);
}
return r.result ? r.result.value : undefined;
}
/** 视口与触摸仿真(在 navigate 前设置)。 */
async setViewport(width, height, { deviceScaleFactor = 2, mobile = true } = {}) {
await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor, mobile });
await this.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
}
/** 原始触摸事件派发。type: touchStart|touchMove|touchEnd|touchCancel */
async dispatchTouch(type, touchPoints = []) {
await this.send('Input.dispatchTouchEvent', { type, touchPoints });
}
async close() {
await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {});
}
}
export class Browser {
constructor(proc, conn, profileDir) {
this.proc = proc;
this.conn = conn;
this.profileDir = profileDir;
}
/** 开新页签并挂 session。 */
async newPage({ width = 390, height = 844 } = {}) {
const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true });
const page = new Page(this.conn, sessionId, targetId);
await page.send('Page.enable');
await page.send('Runtime.enable');
if (width && height) await page.setViewport(width, height);
return page;
}
on(method, fn) { this.conn.on(method, fn); }
/** 关浏览器 + 清 profile 目录。 */
async dispose() {
try { this.conn.close(); } catch (_) {}
if (this.proc && !this.proc.killed) {
this.proc.kill('SIGKILL');
}
if (this.profileDir) {
try { fs.rmSync(this.profileDir, { recursive: true, force: true }); } catch (_) {}
}
}
}
/**
* spawn 无头 Chromium 并连上 CDP。
* @param {object} [opts]
* @param {string} [opts.executable='chromium']
* @param {string} [opts.profileBase] 用户数据目录的父目录(snap 对 $HOME 内非隐藏路径可写)
* @returns {Promise<Browser>}
*/
export async function launch(opts = {}) {
const executable = opts.executable || 'chromium';
const profileBase = opts.profileBase || fs.mkdtempSync(path.join(os.tmpdir(), 'live-cdp-'));
fs.mkdirSync(profileBase, { recursive: true });
const profileDir = fs.mkdtempSync(path.join(profileBase, 'profile-'));
const args = [
'--headless=new',
'--remote-debugging-port=0',
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--disable-gpu',
'--mute-audio',
'--autoplay-policy=no-user-gesture-required',
'about:blank',
];
const proc = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const wsUrl = await new Promise((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => reject(new Error(`等待 DevTools ws 地址超时。stderr 摘录:\n${buf.slice(-2000)}`)), 20000);
proc.stderr.on('data', (d) => {
buf += d.toString();
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
if (m) { clearTimeout(timer); resolve(m[1]); }
});
proc.on('exit', (code) => { clearTimeout(timer); reject(new Error(`chromium 提前退出 (code ${code})。stderr 摘录:\n${buf.slice(-2000)}`)); });
proc.on('error', (e) => { clearTimeout(timer); reject(e); });
});
const conn = await Connection.connect(wsUrl);
const browser = new Browser(proc, conn, profileDir);
// 忽略浏览器级噪声事件即可;需要时 browser.on(...) 订阅
return browser;
}
/** sleep 工具(手势编排用)。 */
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));