/** * 加载生产文件 tiktok_stats.js 本身,剥离末尾的 main() 调用, * 注入可控的 sleep / console / clock,直接验证节流与批量冷却逻辑。 * * 覆盖模型(2026-09-25 改版为两级限速): * ① 令牌桶 targetReqPerHour —— 跨视频累计、不因换视频重置 → 小时级硬上界 * ② 视频间动态冷却 —— videoCooldownMinMs + 上一视频请求数 × videoCooldownPerReqMs * ③ 命中 code=-1 时,页间隔与令牌桶必须「同步」退避,恢复时也必须同步回落 */ const fs = require('fs'); const path = require('path'); const SRC = path.join(__dirname, '..', 'tiktok_stats.js'); let src = fs.readFileSync(SRC, 'utf8'); // 剥掉入口调用,只留函数定义(同时保留 CONFIG 以便按需覆盖) src = src.replace(/\nmain\(\);\s*$/, '\n'); if (/^\s*main\(\);/m.test(src)) { console.error('✗ 未能剥离 main() 调用'); process.exit(1); } const sleeps = []; const logs = []; let now = 0; const fakeSleep = (ms) => { sleeps.push(ms); now += ms; }; const fakeConsole = { log: (...a) => logs.push(a.join(' ')), show: () => {} }; const wrapper = new Function('console', 'sleep', src + '\n;return { CONFIG, PACING, CLOCK, pacingInit, pacingDelay, pacingTokenGap, ' + 'pacingWait, pacingResetVideo, pacingNoteRateLimit, pacingNoteSuccess, cooldownBetweenVideos };'); const api = wrapper(fakeConsole, fakeSleep); api.CLOCK.now = () => now; // 时钟可控,才能验证令牌桶 let pass = 0, fail = 0; function check(name, cond, detail) { if (cond) { pass++; console.log(' ✓ ' + name + (detail ? ' ' + detail : '')); } else { fail++; console.log(' ✗ ' + name + ' → ' + detail); } } const resetClock = (t) => { now = t; sleeps.length = 0; }; const TB = api.PACING.baseInterval; // 令牌桶基准间隔(ms/请求) const TARGET = api.CONFIG.targetReqPerHour; // ---------------------------------------------------------------- 1. 抖动范围 console.log('\n[1] 页间间隔抖动 jitterRatio=' + api.CONFIG.jitterRatio + '(基准 ' + api.CONFIG.requestDelay + 'ms)'); api.pacingInit(); let min = Infinity, max = -Infinity; for (let i = 0; i < 20000; i++) { const d = api.pacingDelay(); min = Math.min(min, d); max = Math.max(max, d); } const lo = api.CONFIG.requestDelay * (1 - api.CONFIG.jitterRatio); const hi = api.CONFIG.requestDelay * (1 + api.CONFIG.jitterRatio); check('下界 >= ' + lo, min >= lo, '实测 min=' + min); check('上界 <= ' + hi, max <= hi, '实测 max=' + max); check('确实是随机的(max>min)', max > min, 'min=' + min + ' max=' + max); // ------------------------------------------------- 2. 退避:页间隔与令牌桶同步 console.log('\n[2] 命中 code=-1 时退避(基准 ' + TB + 'ms/请求,上限 ' + api.CONFIG.throttleMaxInterval / 1000 + 's/请求)'); api.pacingInit(); const curSeq = [api.PACING.cur], intSeq = [api.PACING.interval]; for (let i = 0; i < 6; i++) { api.pacingNoteRateLimit(); curSeq.push(api.PACING.cur); intSeq.push(api.PACING.interval); } console.log(' 页间隔 : ' + curSeq.join(' → ')); console.log(' 令牌桶 : ' + intSeq.join(' → ')); check('页间隔逐级放大', curSeq[1] === 800 && curSeq[2] === 1600, '前两级 ' + curSeq[1] + '/' + curSeq[2]); check('页间隔封顶 throttleMaxDelay', api.PACING.cur <= api.CONFIG.throttleMaxDelay, 'cur=' + api.PACING.cur); check('令牌桶同步放大', intSeq[1] === TB * 2 && intSeq[2] === TB * 4, '前两级 ' + intSeq[1] + '/' + intSeq[2]); check('令牌桶封顶 throttleMaxInterval', api.PACING.interval <= api.CONFIG.throttleMaxInterval, 'interval=' + api.PACING.interval); check('令牌桶确实被退避放大', api.PACING.interval > TB, TB + ' → ' + api.PACING.interval); check('rateLimited 计数正确', api.PACING.rateLimited === 6, '=' + api.PACING.rateLimited); // ------------------------------------------------------------------ 3. 恢复 console.log('\n[3] 连续成功 ' + api.CONFIG.throttleRecoverPages + ' 页后回落(两者须同步)'); api.pacingNoteRateLimit(); for (let i = 0; i < api.CONFIG.throttleRecoverPages; i++) api.pacingNoteSuccess(); check('页间隔落到基准', api.PACING.cur === api.CONFIG.requestDelay, 'cur=' + api.PACING.cur); check('令牌桶落到基准', api.PACING.interval === api.PACING.baseInterval, 'interval=' + api.PACING.interval); for (let i = 0; i < 20; i++) api.pacingNoteSuccess(); check('重复触发也稳定在基准', api.PACING.cur === api.CONFIG.requestDelay && api.PACING.interval === api.PACING.baseInterval); // ------------------------------------------------------ 4. 令牌桶真的在限速 console.log('\n[4] 令牌桶限速验证(目标 ' + TARGET + ' 请求/小时 → ' + (TB / 1000).toFixed(1) + 's/请求)'); resetClock(1000000); api.pacingInit(); const N = 100; for (let i = 0; i < N; i++) api.pacingWait(); const elapsed = now - 1000000; const rate = Math.round(N * 3600000 / elapsed); console.log(' ' + N + ' 次请求共耗时 ' + elapsed + 'ms → 实测 ' + rate + ' 请求/小时'); check('requests 计数正确', api.PACING.requests === N, '=' + api.PACING.requests); check('耗时下界 >= (N-1)×间隔', elapsed >= (N - 1) * TB, elapsed + ' >= ' + (N - 1) * TB); check('速率不超过目标上界(+2%)', rate <= TARGET * 1.02, rate + ' <= ' + Math.round(TARGET * 1.02)); check('确实远慢于页间间隔(令牌桶生效)', elapsed > 200000, elapsed + 'ms ≫ ' + (N * api.CONFIG.requestDelay) + 'ms(仅页间隔)'); // ------------------------------------------- 5. 跨视频不重置 + videoReqs 归零 console.log('\n[5] 令牌桶跨视频累计(不因换视频重置)'); resetClock(2000000); api.pacingInit(); api.pacingWait(); api.pacingWait(); const naBefore = api.PACING.nextAllowed; const reqsBefore = api.PACING.videoReqs; api.pacingResetVideo(); // 模拟进入下一个视频 check('nextAllowed 未被重置', api.PACING.nextAllowed === naBefore, '=' + naBefore); check('videoReqs 已归零', api.PACING.videoReqs === 0, '此前 ' + reqsBefore + ' → 0'); check('requests 总数不受影响', api.PACING.requests === 2, '=' + api.PACING.requests); // ------------------------------------------------- 6. 视频间动态冷却(按请求数伸缩) console.log('\n[6] 视频间动态冷却(底 ' + (api.CONFIG.videoCooldownMinMs / 1000) + 's + 请求数 × ' + api.CONFIG.videoCooldownPerReqMs + 'ms)'); function sampleCooldown(idx, total, reqs) { const out = []; for (let i = 0; i < 400; i++) { api.PACING.nextAllowed = 0; // 隔离令牌桶,单测冷却公式本身 sleeps.length = 0; api.cooldownBetweenVideos(idx, total, reqs); out.push(sleeps[0]); } return { min: Math.min(...out), max: Math.max(...out), avg: Math.round(out.reduce((a, b) => a + b, 0) / out.length) }; } const light = sampleCooldown(1, 10, 10); // 10 请求的轻视频 const heavy = sampleCooldown(2, 10, 199); // 199 请求的重视频 const long = sampleCooldown(5, 10, 10); // 第 5 个 → 长休 const expLight = api.CONFIG.videoCooldownMinMs + 10 * api.CONFIG.videoCooldownPerReqMs; const expHeavy = api.CONFIG.videoCooldownMinMs + 199 * api.CONFIG.videoCooldownPerReqMs; console.log(' 轻视频(10 请求) : min=' + light.min + ' avg=' + light.avg + ' max=' + light.max + '(期望 ' + expLight + ')'); console.log(' 重视频(199 请求): min=' + heavy.min + ' avg=' + heavy.avg + ' max=' + heavy.max + '(期望 ' + expHeavy + ')'); console.log(' 长休(idx=5) : min=' + long.min + ' avg=' + long.avg + ' max=' + long.max); check('轻视频冷却 ≈ ' + expLight, Math.abs(light.avg - expLight) < 1500, 'avg=' + light.avg); check('重视频冷却 ≈ ' + expHeavy, Math.abs(heavy.avg - expHeavy) < 3000, 'avg=' + heavy.avg); check('★ 重视频冷却显著大于轻视频', heavy.avg > light.avg * 3, Math.round(light.avg / 1000) + 's vs ' + Math.round(heavy.avg / 1000) + 's'); check('冷却抖动在 ±50% 内', light.min >= expLight * 0.5 && light.max <= expLight * 1.5, light.min + '~' + light.max); check('长休均值 ≈ 600s', Math.abs(long.avg - 600000) < 30000, 'avg=' + long.avg); check('长休幅度更小(±20%)', long.min >= 480000 && long.max <= 720000, long.min + '~' + long.max); // ------------------------------------------- 7. 端到端速率模拟(两级限速协同) console.log('\n[7] 端到端速率模拟:轻(10) + 重(199) + 轻(10) 三个视频'); resetClock(500000); api.pacingInit(); const t0 = now; const runVideo = (reqs) => { for (let i = 0; i < reqs; i++) api.pacingWait(); }; runVideo(10); api.cooldownBetweenVideos(1, 10, api.PACING.videoReqs); api.pacingResetVideo(); runVideo(199); api.cooldownBetweenVideos(2, 10, api.PACING.videoReqs); api.pacingResetVideo(); runVideo(10); const simElapsed = now - t0; const simReq = 10 + 199 + 10; const simRate = Math.round(simReq * 3600000 / simElapsed); console.log(' ' + simReq + ' 请求 / ' + Math.round(simElapsed / 1000) + 's → ' + simRate + ' 请求/小时(不含真实网络耗时,属偏严估计)'); check('端到端速率 <= 目标上界(+5%)', simRate <= TARGET * 1.05, simRate + ' <= ' + Math.round(TARGET * 1.05)); check('端到端确实被限速', simElapsed > 400000, simElapsed + 'ms'); // --------------------------------------------- 8. 死配置与死链路已彻底移除 console.log('\n[8] 死配置 / 死链路移除校验'); check('CONFIG.cookie 已移除', api.CONFIG.cookie === undefined, String(api.CONFIG.cookie)); check('CONFIG.cookieFile 已移除', api.CONFIG.cookieFile === undefined, String(api.CONFIG.cookieFile)); check('CONFIG.tryOfficial 已移除', api.CONFIG.tryOfficial === undefined, String(api.CONFIG.tryOfficial)); check('CONFIG.videoCooldownMs 已移除(被动态冷却取代)', api.CONFIG.videoCooldownMs === undefined, String(api.CONFIG.videoCooldownMs)); check('源码不含 buildCookie 定义', !/function\s+buildCookie/.test(src)); check('源码不含 getCommentsViaOfficial 定义', !/function\s+getCommentsViaOfficial/.test(src)); check('源码不再引用 CONFIG.cookie', !/CONFIG\.cookie/.test(src)); check('源码不再引用 CONFIG.tryOfficial', !/CONFIG\.tryOfficial/.test(src)); // --------------------------------------------- 9. 批量配置与 skipExisting 语义 console.log('\n[9] 批量配置默认值'); check('batchUrls 默认为空(单视频模式)', Array.isArray(api.CONFIG.batchUrls) && api.CONFIG.batchUrls.length === 0); check('skipExisting 默认开启', api.CONFIG.skipExisting === true); check('fetchReplies 默认关闭', api.CONFIG.fetchReplies === false); check('targetReqPerHour 已配置', typeof api.CONFIG.targetReqPerHour === 'number' && api.CONFIG.targetReqPerHour > 0, '=' + api.CONFIG.targetReqPerHour); check('两级限速参数齐备', typeof api.CONFIG.videoCooldownMinMs === 'number' && typeof api.CONFIG.videoCooldownPerReqMs === 'number' && typeof api.CONFIG.throttleMaxInterval === 'number'); // --------------------------------------------------- 10. 单视频耗时/请求数估算 console.log('\n[10] 单视频成本估算(令牌桶 ' + (TB / 1000).toFixed(1) + 's/请求 + 动态冷却;' + '真实网络另需约 1.8s/页)'); [500, 2000, 5000, 9931].forEach(c => { const reqs = Math.ceil(c / 50) + 1; // +1 = 计数请求 const cool = api.CONFIG.videoCooldownMinMs + reqs * api.CONFIG.videoCooldownPerReqMs; const sec = Math.round((reqs * TB + cool) / 1000); console.log(' 评论 ' + String(c).padStart(5) + ' 条 → ' + String(reqs).padStart(4) + ' 请求,冷却 ' + String(Math.round(cool / 1000)).padStart(3) + 's ≈ ' + String(sec).padStart(4) + 's (' + (sec / 60).toFixed(1) + ' 分钟/视频)'); }); console.log(' → 换算:约 ' + Math.round(3600000 / (TB + api.CONFIG.videoCooldownPerReqMs)) + ' 请求/小时为实际可达上界,落在建议的 ≤1000~1500 区间内'); console.log('\n================ 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ================'); process.exit(fail > 0 ? 1 : 0);