/** * JS 取址链路(maidong-ktv/app/src/main/assets/mobile/ktv_api.js)**真实脚本**的 * 127.0.0.1 stub 对照测量工具(优化前 / 优化后跑同一份脚本)。 * * 为什么能这么测:ktv_api.js 走 `global.android.httpGet(url, headers, timeout)` 与 * `global.android.appToken(host)`(Android JavascriptInterface 是**同步**调用), * 且 `android.appToken` 背后就是 Kotlin 的 `AppTokenClient`(按 host 缓存 10 分钟)。 * Node 里把这两个方法换成"记录时间戳 + 同步 sleep(一次往返耗时) + 按 host 缓存"的 stub, * 就能在**不改一字**的真实脚本上量出: * ① 点歌 → 首个请求发出; * ② 点歌 → 拿到地址(= 用户在界面上"点了没反应"的时长); * ③ 每首歌实际打几次 /i.php、几次 /music/do.php。 * * 用法:node maidong-ktv/tools/measure_ktv_js.js (仓库内) * STUB_LATENCY_MS=200 node maidong-ktv/tools/measure_ktv_js.js * STUB_PREHEAT=1 node maidong-ktv/tools/measure_ktv_js.js (额外模拟一轮后台预热) */ 'use strict'; const fs = require('fs'); const path = require('path'); // 直接跑(仓库内 tools/measure_ktv_js.js)与从工作区根跑(tmp/measure_ktv_js.js)都要能找到脚本。 const JS_FILE = [ path.resolve(__dirname, '../app/src/main/assets/mobile/ktv_api.js'), path.resolve(__dirname, '../maidong-ktv/app/src/main/assets/mobile/ktv_api.js'), ].find((candidate) => fs.existsSync(candidate)); function syncSleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function hostOf(u) { const m = /^[a-z]+:\/\/([^/]+)/i.exec(String(u)); return m ? m[1] : ''; } const RTT_MS = Number(process.env.STUB_LATENCY_MS || 150); const OFFICIAL = 'http://zk.ktv1.top:6677'; const SELF = 'https://mdmuse.kasugano.cn'; const MUSICNO = '7303235'; const SELF_URL = 'http://md.kasugano.cn/media/' + MUSICNO + '.ts'; const OFFICIAL_URL = 'http://txog.ktvsky.com/E/ts/xxx/' + MUSICNO + '.ts'; function makeScenarios() { const forbidden = { status: 200, body: JSON.stringify({ code: 403, msg: '请求被拒绝:检测到异常行为' }) }; const adOnly = { status: 200, body: JSON.stringify({ code: 200, data: 'https://wb66.cn/f/d/7b7c9/' + MUSICNO + '.ts' }) }; const notFound = { status: 200, body: JSON.stringify({ code: 404, msg: 'not in library' }) }; return { forbidden: { officialOkTimes: 0, officialFail: forbidden }, adOnly: { officialOkTimes: 0, officialFail: adOnly }, notFound: { officialOkTimes: 0, officialFail: notFound }, quota2: { officialOkTimes: 2, officialFail: forbidden }, }; } function newHarness(scenario) { const log = []; const calls = { iphp: 0, dophp: 0, appToken: 0, appTokenMisses: 0 }; const tokenCache = new Map(); let officialDoPhpCount = 0; const t0 = Date.now(); const rel = () => Date.now() - t0; const api = { httpGet(url) { const u = String(url); const at = rel(); if (u.indexOf('/i.php') >= 0) { calls.iphp++; log.push({ t: at, kind: 'i.php', host: hostOf(u) }); syncSleep(RTT_MS); return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, token: 'tok-' + hostOf(u), heart_time: 5400 }) }); } if (u.indexOf('/music/do.php') >= 0) { calls.dophp++; const host = hostOf(u); log.push({ t: at, kind: 'do.php', host: host }); syncSleep(RTT_MS); if (host === hostOf(OFFICIAL)) { officialDoPhpCount++; if (officialDoPhpCount <= scenario.officialOkTimes) { return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, msg: 'SUCCESS', data: OFFICIAL_URL }) }); } const r = scenario.officialFail; return JSON.stringify({ status: r.status, body: r.body }); } return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, msg: 'SUCCESS', data: SELF_URL }) }); } throw new Error('unexpected url ' + u); }, appToken(host) { const key = String(host); calls.appToken++; if (tokenCache.has(key)) return tokenCache.get(key); calls.appTokenMisses++; log.push({ t: rel(), kind: 'appToken(首次)', host: key }); syncSleep(RTT_MS); tokenCache.set(key, 'app-token'); return 'app-token'; }, invalidateAppToken(host) { tokenCache.delete(String(host)); }, onSongSource() {}, reportSongResult() {}, log() {}, }; return { api, log, calls, rel }; } async function runScenario(name, scenario, songs) { delete require.cache[require.resolve(JS_FILE)]; const fakeWindow = {}; const prev = global.window; global.window = fakeWindow; const mod = require(JS_FILE); if (prev === undefined) delete global.window; else global.window = prev; const KtvApi = fakeWindow.KtvApi || mod.KtvApi; const h = newHarness(scenario); fakeWindow.android = h.api; mod.android = h.api; const api = new KtvApi({ mac: '1ca410000001', sn: 'aabbccddeeff', maxDeviceRetries: 0, debug: false, hosts: [OFFICIAL, SELF], }); // 可选:模拟 App 启动 / 进入点歌列表时的**后台预热**(SourcePreheat 在 JS 路径上做的事): // ① 构造 window.api 并逐个源取一次 /i.php token(同时热好 DNS/TCP/TLS); // ② 逐个源问一次 App 令牌(android.appToken → Kotlin 侧按 host 缓存)。 // 用 STUB_PREHEAT=1 打开,用于量"预热把首次点歌的哪些往返省掉了"。 let preheatMs = null; if (process.env.STUB_PREHEAT) { const preheatAt = h.rel(); const warmHosts = [OFFICIAL, SELF]; for (const host of warmHosts) { await api.getToken(host, false); h.api.appToken(host); } preheatMs = h.rel() - preheatAt; } const songsOut = []; for (let i = 0; i < songs; i++) { const start = h.rel(); const before = h.log.length; const url = await api.getSongUrl(MUSICNO, '720', false); const end = h.rel(); const reqs = h.log.slice(before); const okReq = reqs.filter(r => r.kind === 'do.php' && r.host === hostOf(SELF))[0]; songsOut.push({ song: i + 1, totalMs: end - start, firstRequestSentAtMs: reqs.length ? reqs[0].t - start : null, successSourceRequestSentAtMs: okReq ? okReq.t - start : null, url: url, requests: reqs.map(r => ((r.t - start) + 'ms ' + r.kind + ' ' + r.host)), }); } return { scenario: name, stub: { httpRoundTripMs: RTT_MS, preheat: !!process.env.STUB_PREHEAT, preheatMs: preheatMs }, totals: h.calls, songs: songsOut, }; } (async () => { const scenarios = makeScenarios(); const out = []; for (const name of Object.keys(scenarios)) out.push(await runScenario(name, scenarios[name], 4)); console.log(JSON.stringify({ jsFile: JS_FILE, results: out }, null, 2)); })();