| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- /**
- * 加载生产文件 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);
|