test_pacing.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * 加载生产文件 tiktok_stats.js 本身,剥离末尾的 main() 调用,
  3. * 注入可控的 sleep / console / clock,直接验证节流与批量冷却逻辑。
  4. *
  5. * 覆盖模型(2026-09-25 改版为两级限速):
  6. * ① 令牌桶 targetReqPerHour —— 跨视频累计、不因换视频重置 → 小时级硬上界
  7. * ② 视频间动态冷却 —— videoCooldownMinMs + 上一视频请求数 × videoCooldownPerReqMs
  8. * ③ 命中 code=-1 时,页间隔与令牌桶必须「同步」退避,恢复时也必须同步回落
  9. */
  10. const fs = require('fs');
  11. const path = require('path');
  12. const SRC = path.join(__dirname, '..', 'tiktok_stats.js');
  13. let src = fs.readFileSync(SRC, 'utf8');
  14. // 剥掉入口调用,只留函数定义(同时保留 CONFIG 以便按需覆盖)
  15. src = src.replace(/\nmain\(\);\s*$/, '\n');
  16. if (/^\s*main\(\);/m.test(src)) {
  17. console.error('✗ 未能剥离 main() 调用');
  18. process.exit(1);
  19. }
  20. const sleeps = [];
  21. const logs = [];
  22. let now = 0;
  23. const fakeSleep = (ms) => { sleeps.push(ms); now += ms; };
  24. const fakeConsole = { log: (...a) => logs.push(a.join(' ')), show: () => {} };
  25. const wrapper = new Function('console', 'sleep',
  26. src + '\n;return { CONFIG, PACING, CLOCK, pacingInit, pacingDelay, pacingTokenGap, ' +
  27. 'pacingWait, pacingResetVideo, pacingNoteRateLimit, pacingNoteSuccess, cooldownBetweenVideos };');
  28. const api = wrapper(fakeConsole, fakeSleep);
  29. api.CLOCK.now = () => now; // 时钟可控,才能验证令牌桶
  30. let pass = 0, fail = 0;
  31. function check(name, cond, detail) {
  32. if (cond) { pass++; console.log(' ✓ ' + name + (detail ? ' ' + detail : '')); }
  33. else { fail++; console.log(' ✗ ' + name + ' → ' + detail); }
  34. }
  35. const resetClock = (t) => { now = t; sleeps.length = 0; };
  36. const TB = api.PACING.baseInterval; // 令牌桶基准间隔(ms/请求)
  37. const TARGET = api.CONFIG.targetReqPerHour;
  38. // ---------------------------------------------------------------- 1. 抖动范围
  39. console.log('\n[1] 页间间隔抖动 jitterRatio=' + api.CONFIG.jitterRatio +
  40. '(基准 ' + api.CONFIG.requestDelay + 'ms)');
  41. api.pacingInit();
  42. let min = Infinity, max = -Infinity;
  43. for (let i = 0; i < 20000; i++) { const d = api.pacingDelay(); min = Math.min(min, d); max = Math.max(max, d); }
  44. const lo = api.CONFIG.requestDelay * (1 - api.CONFIG.jitterRatio);
  45. const hi = api.CONFIG.requestDelay * (1 + api.CONFIG.jitterRatio);
  46. check('下界 >= ' + lo, min >= lo, '实测 min=' + min);
  47. check('上界 <= ' + hi, max <= hi, '实测 max=' + max);
  48. check('确实是随机的(max>min)', max > min, 'min=' + min + ' max=' + max);
  49. // ------------------------------------------------- 2. 退避:页间隔与令牌桶同步
  50. console.log('\n[2] 命中 code=-1 时退避(基准 ' + TB + 'ms/请求,上限 ' +
  51. api.CONFIG.throttleMaxInterval / 1000 + 's/请求)');
  52. api.pacingInit();
  53. const curSeq = [api.PACING.cur], intSeq = [api.PACING.interval];
  54. for (let i = 0; i < 6; i++) {
  55. api.pacingNoteRateLimit();
  56. curSeq.push(api.PACING.cur);
  57. intSeq.push(api.PACING.interval);
  58. }
  59. console.log(' 页间隔 : ' + curSeq.join(' → '));
  60. console.log(' 令牌桶 : ' + intSeq.join(' → '));
  61. check('页间隔逐级放大', curSeq[1] === 800 && curSeq[2] === 1600, '前两级 ' + curSeq[1] + '/' + curSeq[2]);
  62. check('页间隔封顶 throttleMaxDelay', api.PACING.cur <= api.CONFIG.throttleMaxDelay, 'cur=' + api.PACING.cur);
  63. check('令牌桶同步放大', intSeq[1] === TB * 2 && intSeq[2] === TB * 4, '前两级 ' + intSeq[1] + '/' + intSeq[2]);
  64. check('令牌桶封顶 throttleMaxInterval',
  65. api.PACING.interval <= api.CONFIG.throttleMaxInterval, 'interval=' + api.PACING.interval);
  66. check('令牌桶确实被退避放大', api.PACING.interval > TB, TB + ' → ' + api.PACING.interval);
  67. check('rateLimited 计数正确', api.PACING.rateLimited === 6, '=' + api.PACING.rateLimited);
  68. // ------------------------------------------------------------------ 3. 恢复
  69. console.log('\n[3] 连续成功 ' + api.CONFIG.throttleRecoverPages + ' 页后回落(两者须同步)');
  70. api.pacingNoteRateLimit();
  71. for (let i = 0; i < api.CONFIG.throttleRecoverPages; i++) api.pacingNoteSuccess();
  72. check('页间隔落到基准', api.PACING.cur === api.CONFIG.requestDelay, 'cur=' + api.PACING.cur);
  73. check('令牌桶落到基准', api.PACING.interval === api.PACING.baseInterval, 'interval=' + api.PACING.interval);
  74. for (let i = 0; i < 20; i++) api.pacingNoteSuccess();
  75. check('重复触发也稳定在基准', api.PACING.cur === api.CONFIG.requestDelay &&
  76. api.PACING.interval === api.PACING.baseInterval);
  77. // ------------------------------------------------------ 4. 令牌桶真的在限速
  78. console.log('\n[4] 令牌桶限速验证(目标 ' + TARGET + ' 请求/小时 → ' + (TB / 1000).toFixed(1) + 's/请求)');
  79. resetClock(1000000);
  80. api.pacingInit();
  81. const N = 100;
  82. for (let i = 0; i < N; i++) api.pacingWait();
  83. const elapsed = now - 1000000;
  84. const rate = Math.round(N * 3600000 / elapsed);
  85. console.log(' ' + N + ' 次请求共耗时 ' + elapsed + 'ms → 实测 ' + rate + ' 请求/小时');
  86. check('requests 计数正确', api.PACING.requests === N, '=' + api.PACING.requests);
  87. check('耗时下界 >= (N-1)×间隔', elapsed >= (N - 1) * TB, elapsed + ' >= ' + (N - 1) * TB);
  88. check('速率不超过目标上界(+2%)', rate <= TARGET * 1.02, rate + ' <= ' + Math.round(TARGET * 1.02));
  89. check('确实远慢于页间间隔(令牌桶生效)', elapsed > 200000,
  90. elapsed + 'ms ≫ ' + (N * api.CONFIG.requestDelay) + 'ms(仅页间隔)');
  91. // ------------------------------------------- 5. 跨视频不重置 + videoReqs 归零
  92. console.log('\n[5] 令牌桶跨视频累计(不因换视频重置)');
  93. resetClock(2000000);
  94. api.pacingInit();
  95. api.pacingWait(); api.pacingWait();
  96. const naBefore = api.PACING.nextAllowed;
  97. const reqsBefore = api.PACING.videoReqs;
  98. api.pacingResetVideo(); // 模拟进入下一个视频
  99. check('nextAllowed 未被重置', api.PACING.nextAllowed === naBefore, '=' + naBefore);
  100. check('videoReqs 已归零', api.PACING.videoReqs === 0, '此前 ' + reqsBefore + ' → 0');
  101. check('requests 总数不受影响', api.PACING.requests === 2, '=' + api.PACING.requests);
  102. // ------------------------------------------------- 6. 视频间动态冷却(按请求数伸缩)
  103. console.log('\n[6] 视频间动态冷却(底 ' + (api.CONFIG.videoCooldownMinMs / 1000) + 's + 请求数 × ' +
  104. api.CONFIG.videoCooldownPerReqMs + 'ms)');
  105. function sampleCooldown(idx, total, reqs) {
  106. const out = [];
  107. for (let i = 0; i < 400; i++) {
  108. api.PACING.nextAllowed = 0; // 隔离令牌桶,单测冷却公式本身
  109. sleeps.length = 0;
  110. api.cooldownBetweenVideos(idx, total, reqs);
  111. out.push(sleeps[0]);
  112. }
  113. return { min: Math.min(...out), max: Math.max(...out), avg: Math.round(out.reduce((a, b) => a + b, 0) / out.length) };
  114. }
  115. const light = sampleCooldown(1, 10, 10); // 10 请求的轻视频
  116. const heavy = sampleCooldown(2, 10, 199); // 199 请求的重视频
  117. const long = sampleCooldown(5, 10, 10); // 第 5 个 → 长休
  118. const expLight = api.CONFIG.videoCooldownMinMs + 10 * api.CONFIG.videoCooldownPerReqMs;
  119. const expHeavy = api.CONFIG.videoCooldownMinMs + 199 * api.CONFIG.videoCooldownPerReqMs;
  120. console.log(' 轻视频(10 请求) : min=' + light.min + ' avg=' + light.avg + ' max=' + light.max + '(期望 ' + expLight + ')');
  121. console.log(' 重视频(199 请求): min=' + heavy.min + ' avg=' + heavy.avg + ' max=' + heavy.max + '(期望 ' + expHeavy + ')');
  122. console.log(' 长休(idx=5) : min=' + long.min + ' avg=' + long.avg + ' max=' + long.max);
  123. check('轻视频冷却 ≈ ' + expLight, Math.abs(light.avg - expLight) < 1500, 'avg=' + light.avg);
  124. check('重视频冷却 ≈ ' + expHeavy, Math.abs(heavy.avg - expHeavy) < 3000, 'avg=' + heavy.avg);
  125. check('★ 重视频冷却显著大于轻视频', heavy.avg > light.avg * 3,
  126. Math.round(light.avg / 1000) + 's vs ' + Math.round(heavy.avg / 1000) + 's');
  127. check('冷却抖动在 ±50% 内', light.min >= expLight * 0.5 && light.max <= expLight * 1.5,
  128. light.min + '~' + light.max);
  129. check('长休均值 ≈ 600s', Math.abs(long.avg - 600000) < 30000, 'avg=' + long.avg);
  130. check('长休幅度更小(±20%)', long.min >= 480000 && long.max <= 720000, long.min + '~' + long.max);
  131. // ------------------------------------------- 7. 端到端速率模拟(两级限速协同)
  132. console.log('\n[7] 端到端速率模拟:轻(10) + 重(199) + 轻(10) 三个视频');
  133. resetClock(500000);
  134. api.pacingInit();
  135. const t0 = now;
  136. const runVideo = (reqs) => { for (let i = 0; i < reqs; i++) api.pacingWait(); };
  137. runVideo(10); api.cooldownBetweenVideos(1, 10, api.PACING.videoReqs); api.pacingResetVideo();
  138. runVideo(199); api.cooldownBetweenVideos(2, 10, api.PACING.videoReqs); api.pacingResetVideo();
  139. runVideo(10);
  140. const simElapsed = now - t0;
  141. const simReq = 10 + 199 + 10;
  142. const simRate = Math.round(simReq * 3600000 / simElapsed);
  143. console.log(' ' + simReq + ' 请求 / ' + Math.round(simElapsed / 1000) + 's → ' +
  144. simRate + ' 请求/小时(不含真实网络耗时,属偏严估计)');
  145. check('端到端速率 <= 目标上界(+5%)', simRate <= TARGET * 1.05, simRate + ' <= ' + Math.round(TARGET * 1.05));
  146. check('端到端确实被限速', simElapsed > 400000, simElapsed + 'ms');
  147. // --------------------------------------------- 8. 死配置与死链路已彻底移除
  148. console.log('\n[8] 死配置 / 死链路移除校验');
  149. check('CONFIG.cookie 已移除', api.CONFIG.cookie === undefined, String(api.CONFIG.cookie));
  150. check('CONFIG.cookieFile 已移除', api.CONFIG.cookieFile === undefined, String(api.CONFIG.cookieFile));
  151. check('CONFIG.tryOfficial 已移除', api.CONFIG.tryOfficial === undefined, String(api.CONFIG.tryOfficial));
  152. check('CONFIG.videoCooldownMs 已移除(被动态冷却取代)', api.CONFIG.videoCooldownMs === undefined,
  153. String(api.CONFIG.videoCooldownMs));
  154. check('源码不含 buildCookie 定义', !/function\s+buildCookie/.test(src));
  155. check('源码不含 getCommentsViaOfficial 定义', !/function\s+getCommentsViaOfficial/.test(src));
  156. check('源码不再引用 CONFIG.cookie', !/CONFIG\.cookie/.test(src));
  157. check('源码不再引用 CONFIG.tryOfficial', !/CONFIG\.tryOfficial/.test(src));
  158. // --------------------------------------------- 9. 批量配置与 skipExisting 语义
  159. console.log('\n[9] 批量配置默认值');
  160. check('batchUrls 默认为空(单视频模式)', Array.isArray(api.CONFIG.batchUrls) && api.CONFIG.batchUrls.length === 0);
  161. check('skipExisting 默认开启', api.CONFIG.skipExisting === true);
  162. check('fetchReplies 默认关闭', api.CONFIG.fetchReplies === false);
  163. check('targetReqPerHour 已配置', typeof api.CONFIG.targetReqPerHour === 'number' && api.CONFIG.targetReqPerHour > 0,
  164. '=' + api.CONFIG.targetReqPerHour);
  165. check('两级限速参数齐备',
  166. typeof api.CONFIG.videoCooldownMinMs === 'number' &&
  167. typeof api.CONFIG.videoCooldownPerReqMs === 'number' &&
  168. typeof api.CONFIG.throttleMaxInterval === 'number');
  169. // --------------------------------------------------- 10. 单视频耗时/请求数估算
  170. console.log('\n[10] 单视频成本估算(令牌桶 ' + (TB / 1000).toFixed(1) + 's/请求 + 动态冷却;' +
  171. '真实网络另需约 1.8s/页)');
  172. [500, 2000, 5000, 9931].forEach(c => {
  173. const reqs = Math.ceil(c / 50) + 1; // +1 = 计数请求
  174. const cool = api.CONFIG.videoCooldownMinMs + reqs * api.CONFIG.videoCooldownPerReqMs;
  175. const sec = Math.round((reqs * TB + cool) / 1000);
  176. console.log(' 评论 ' + String(c).padStart(5) + ' 条 → ' + String(reqs).padStart(4) + ' 请求,冷却 ' +
  177. String(Math.round(cool / 1000)).padStart(3) + 's ≈ ' + String(sec).padStart(4) + 's (' +
  178. (sec / 60).toFixed(1) + ' 分钟/视频)');
  179. });
  180. console.log(' → 换算:约 ' + Math.round(3600000 / (TB + api.CONFIG.videoCooldownPerReqMs)) +
  181. ' 请求/小时为实际可达上界,落在建议的 ≤1000~1500 区间内');
  182. console.log('\n================ 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ================');
  183. process.exit(fail > 0 ? 1 : 0);