tt-help-cli-ycl 1.4.136 → 1.4.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -228,14 +228,42 @@ export async function isLoggedInByDom(page) {
228
228
 
229
229
  export async function closeCommentPanel(page) {
230
230
  await page.evaluate(() => {
231
- const rightPanel = document.querySelector('[class*="RightPanelContainer"]');
232
- if (rightPanel) {
233
- const tabContainer = rightPanel.querySelector('[class*="TabContainer"]');
234
- if (tabContainer) {
235
- const closeOverlay = tabContainer.querySelector("div:last-child");
236
- if (closeOverlay) closeOverlay.click();
231
+ // 覆盖新版短视频面板容器(DivVideoDetailContainer)与旧版容器
232
+ const panels = document.querySelectorAll(
233
+ '[class*="RightPanelContainer"], [class*="VideoDetailContainer"]',
234
+ );
235
+ if (panels.length === 0) return;
236
+
237
+ // 明确的关闭按钮选择器(按优先级)——绝不点击任何 tab 子元素,避免误开"商品"等 tab
238
+ const closeSelectors = [
239
+ '[data-e2e="comment-close"]',
240
+ '[data-e2e="close"]',
241
+ '[data-e2e="modal-close"]',
242
+ '[class*="CloseButton"]',
243
+ '[class*="close-button"]',
244
+ ];
245
+
246
+ for (const panel of panels) {
247
+ for (const sel of closeSelectors) {
248
+ const btn = panel.querySelector(sel);
249
+ if (btn && btn.offsetParent !== null) {
250
+ btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
251
+ btn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
252
+ btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
253
+ return;
254
+ }
237
255
  }
238
256
  }
257
+
258
+ // 无明确关闭按钮时:只点面板外的遮罩层,避免误点 tab
259
+ const mask = document.querySelector(
260
+ '[class*="DivModalMask"], [class*="modal-mask"], [class*="ModalMask"]',
261
+ );
262
+ if (mask && mask.offsetParent !== null) {
263
+ mask.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
264
+ mask.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
265
+ mask.dispatchEvent(new MouseEvent("click", { bubbles: true }));
266
+ }
239
267
  });
240
268
  }
241
269
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 浏览器 / Node 内存统计 helper(tag / comments 等业务命令共享)。
3
+ * 跨平台:macOS / Linux 用 ps RSS,Windows 用 PowerShell 查 msedge 工作集。
4
+ */
5
+ import { writeFileSync, unlinkSync } from "fs";
6
+ import { execSync } from "child_process";
7
+ import os from "os";
8
+ import path from "path";
9
+
10
+ export function formatMemoryUsage(mem = process.memoryUsage()) {
11
+ const mb = (n) => (n / 1024 / 1024).toFixed(0);
12
+ return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
13
+ }
14
+
15
+ /**
16
+ * 获取 Edge 浏览器进程的内存占用(跨平台:macOS/Linux/Windows)。
17
+ * @param {number|null} [port] - 按 CDP 端口筛选;不传则统计全部 msedge 进程
18
+ * @returns {number|null} 内存 MB,获取失败返回 null
19
+ */
20
+ export function getBrowserMemory(port = null) {
21
+ try {
22
+ const platform = os.platform();
23
+ let cmd;
24
+ if (platform === "darwin") {
25
+ if (port) {
26
+ cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
27
+ } else {
28
+ cmd =
29
+ 'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
30
+ }
31
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
32
+ const mb = parseInt(result, 10);
33
+ return mb > 0 ? mb : null;
34
+ } else if (platform === "linux") {
35
+ if (port) {
36
+ cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
37
+ } else {
38
+ cmd =
39
+ 'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
40
+ }
41
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
42
+ const mb = parseInt(result, 10);
43
+ return mb > 0 ? mb : null;
44
+ } else if (platform === "win32") {
45
+ // 用临时 .ps1 文件避免命令行转义问题
46
+ const ps1Path = path.join(os.tmpdir(), `edge-mem-${Date.now()}.ps1`);
47
+ try {
48
+ let ps1Content;
49
+ if (port) {
50
+ ps1Content =
51
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
52
+ `$sum = 0; ` +
53
+ `foreach ($p in $procs) { ` +
54
+ ` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
55
+ ` $sum += $p.WorkingSetSize ` +
56
+ ` } ` +
57
+ `}; ` +
58
+ `Write-Output $sum`;
59
+ } else {
60
+ // 无端口场景:Get-Process(.NET API,不依赖 WMI)统计全部 msedge 工作集
61
+ ps1Content =
62
+ `$procs = Get-Process msedge -ErrorAction SilentlyContinue; ` +
63
+ `$sum = 0; ` +
64
+ `foreach ($p in $procs) { $sum += $p.WorkingSet64 }; ` +
65
+ `Write-Output $sum`;
66
+ }
67
+ writeFileSync(ps1Path, ps1Content);
68
+ cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`;
69
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
70
+ try {
71
+ unlinkSync(ps1Path);
72
+ } catch {}
73
+ const bytes = parseInt(result, 10);
74
+ const mb = bytes > 0 ? Math.round(bytes / 1048576) : 0;
75
+ return mb > 0 ? mb : null;
76
+ } catch {
77
+ try {
78
+ unlinkSync(ps1Path);
79
+ } catch {}
80
+ return null;
81
+ }
82
+ }
83
+ return null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function formatMemoryWithBrowser(port = null) {
90
+ const nodeMem = formatMemoryUsage();
91
+ const edgeMB = getBrowserMemory(port);
92
+ if (!edgeMB) return nodeMem;
93
+ const portLabel = port ? `:${port}` : "";
94
+ return `${nodeMem} edge${portLabel}:${edgeMB}MB`;
95
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 客户端错误上报 + Clash daemon 故障切换 的共享 helper。
3
+ * 供 tag / comments 等业务命令复用,避免重复实现。
4
+ */
5
+
6
+ /**
7
+ * 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
8
+ * 复用 ../clash/ipc.js 现成的 writeCommand 接口,不改动 daemon/ipc 模块。
9
+ * @param {string} reason - failover 原因(会写入 daemon 日志)
10
+ * @param {(s: string) => void} [log] - 日志函数,默认 console.error
11
+ */
12
+ export async function triggerClashFailover(reason, log = console.error) {
13
+ try {
14
+ const ipc = await import("../clash/ipc.js");
15
+ ipc.writeCommand({ action: "failover", reason });
16
+ log(` 🔀 已触发 daemon failover (${reason})`);
17
+ } catch (e) {
18
+ log(` ⚠️ 触发 daemon failover 失败: ${e.message}`);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 上报单条客户端错误到服务端,并触发 daemon fallback 换节点。
24
+ * @param {object} opts
25
+ * @param {(url: string, body: object) => Promise} opts.post - 发 POST 的函数(返回 promise)
26
+ * @param {string} opts.baseUrl - 服务端地址
27
+ * @param {string} opts.userId
28
+ * @param {string} opts.errorType - 错误类型(如 consecutive_task_errors / consecutive_blocked)
29
+ * @param {string} opts.message - 用作 errorMessage 与 failover reason
30
+ * @param {string} opts.stage - stage 标识(如 tag-scoring / comments)
31
+ * @param {(s: string) => void} [opts.log] - 日志函数,默认 console.error
32
+ */
33
+ export async function reportClientErrorAndFailover({
34
+ post,
35
+ baseUrl,
36
+ userId,
37
+ errorType,
38
+ message,
39
+ stage,
40
+ log = console.error,
41
+ }) {
42
+ try {
43
+ await post(`${baseUrl}/api/error-report`, {
44
+ userId,
45
+ username: "",
46
+ errorType,
47
+ errorMessage: message,
48
+ stage,
49
+ errorStack: "",
50
+ });
51
+ } catch {}
52
+ await triggerClashFailover(message, log);
53
+ }
54
+
55
+ /**
56
+ * 连续任务错误达到阈值后:上报服务端 + 触发 daemon fallback 换节点。
57
+ * @param {object} opts
58
+ * @param {(url: string, body: object) => Promise} opts.post - 发 POST 的函数(返回 promise)
59
+ * @param {string} opts.baseUrl - 服务端地址
60
+ * @param {string} opts.userId
61
+ * @param {string} opts.message - 用作 errorMessage 与 failover reason
62
+ * @param {string} opts.stage - stage 标识(如 tag-scoring / comments)
63
+ * @param {(s: string) => void} [opts.log] - 日志函数,默认 console.error
64
+ */
65
+ export async function reportConsecutiveTaskErrors({
66
+ post,
67
+ baseUrl,
68
+ userId,
69
+ message,
70
+ stage,
71
+ log = console.error,
72
+ }) {
73
+ return reportClientErrorAndFailover({
74
+ post,
75
+ baseUrl,
76
+ userId,
77
+ errorType: "consecutive_task_errors",
78
+ message,
79
+ stage,
80
+ log,
81
+ });
82
+ }
@@ -289,6 +289,9 @@ const HELP_TEXT = [
289
289
  " -p, --port <端口号> 监听端口,默认 3000",
290
290
  " --pool-size <数量> 浏览器页面池大小,默认 8",
291
291
  " --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
292
+ " --rate <请求/秒> 对 TikTok 的全局请求速率限制,默认 3(0=不限制)",
293
+ " (限速突发容量自动跟随 --pool-size,无需单独设置)",
294
+ " --cooldown <秒> CDN 限流自动冷却时长:首次限流后冷却,到期仍限流则单次重启,默认 180",
292
295
  " --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
293
296
  " --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
294
297
  " API:",
@@ -470,6 +473,7 @@ const HELP_TEXT = [
470
473
  " tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
471
474
  " tt-help refresh --server http://127.0.0.1:3001 --port 9222",
472
475
  " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
476
+ " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897 --rate 3",
473
477
  " tt-help webserver -p 3000 --log",
474
478
  " tt-help config set server http://127.0.0.1:3001",
475
479
  " tt-help attach -p 5 -i 10",
@@ -18,6 +18,38 @@ function delay(ms) {
18
18
  return new Promise((r) => setTimeout(r, ms));
19
19
  }
20
20
 
21
+ // 令牌桶:平均 rate 个请求/秒,允许最多 capacity 个突发。
22
+ // rate <= 0 表示不限流(兼容旧行为)。用于限制对 TikTok 的全局 QPS,
23
+ // 避免 8 页并行时短时间高频访问触发 Akamai CDN 限流(Access Denied)。
24
+ class TokenBucket {
25
+ constructor({ ratePerSecond, capacity }) {
26
+ this.rate = ratePerSecond;
27
+ this.capacity = capacity;
28
+ this.tokens = capacity;
29
+ this.lastRefill = Date.now();
30
+ }
31
+ _refill() {
32
+ const now = Date.now();
33
+ const elapsed = (now - this.lastRefill) / 1000;
34
+ this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate);
35
+ this.lastRefill = now;
36
+ if (this.tokens < 0) this.tokens = 0;
37
+ }
38
+ async acquire() {
39
+ if (this.rate <= 0) return; // 不限流
40
+ while (true) {
41
+ this._refill();
42
+ if (this.tokens >= 1) {
43
+ this.tokens -= 1;
44
+ return;
45
+ }
46
+ // 距下一个令牌可用还需多少毫秒
47
+ const waitMs = Math.ceil(((1 - this.tokens) / this.rate) * 1000);
48
+ await delay(Math.max(waitMs, 40));
49
+ }
50
+ }
51
+ }
52
+
21
53
  class PageSlot {
22
54
  constructor(page) {
23
55
  this.page = page;
@@ -60,12 +92,18 @@ export class TikTokScraper {
60
92
  warmUrl = DEFAULT_WARM_URL,
61
93
  maxRequestsPerPage = DEFAULT_MAX_REQUESTS_PER_PAGE,
62
94
  proxyServer = null,
95
+ requestRatePerSecond = 0, // 0 = 不限制对 TikTok 的请求速率
96
+ requestBurst = 8, // 限速器突发容量(允许一次小突发热身/单次重试)
63
97
  } = {}) {
64
98
  this.poolSize = poolSize;
65
99
  this.wafTtl = wafTtl;
66
100
  this.warmUrl = warmUrl;
67
101
  this.maxRequestsPerPage = maxRequestsPerPage;
68
102
  this.proxyServer = proxyServer;
103
+ this.rateLimiter = new TokenBucket({
104
+ ratePerSecond: requestRatePerSecond,
105
+ capacity: requestBurst,
106
+ });
69
107
  this.browser = null;
70
108
  this.context = null;
71
109
  this.slots = [];
@@ -190,6 +228,10 @@ export class TikTokScraper {
190
228
  }
191
229
 
192
230
  async _fetchViewSource(url, slot) {
231
+ // 全局限速:所有对外请求(含重试路径)统一在这里节流,
232
+ // 避免 8 页并行时短时间高频访问触发 TikTok CDN 限流。
233
+ await this.rateLimiter.acquire();
234
+
193
235
  const page = await this._ensurePage(slot);
194
236
 
195
237
  await page.goto("view-source:" + url, {
@@ -71,6 +71,8 @@ const SCRAPER_CONFIG = {
71
71
  wafTTL: 120000,
72
72
  maxRequestsPerPage: 50,
73
73
  proxyServer: null,
74
+ requestRatePerSecond: 0, // 0 = 不限制对 TikTok 的请求速率(默认安全值由 CLI 注入)
75
+ requestBurst: 8,
74
76
  captchaNotifyUrl: null,
75
77
  enableLog: false,
76
78
  };
@@ -121,6 +123,14 @@ let recentWindowStart = Date.now();
121
123
  const RECENT_WINDOW_MS = 60000; // 60 秒滑动窗口
122
124
 
123
125
  async function getScraper() {
126
+ // 重启进行中:等待重启完成,避免拿到 context 已被关闭的半死实例
127
+ if (restartPromise) {
128
+ try {
129
+ await restartPromise;
130
+ } catch (e) {
131
+ console.error(`[webserver] 等待重启失败: ${e.message}`);
132
+ }
133
+ }
124
134
  if (scraperInstance && !scraperClosing) {
125
135
  return scraperInstance;
126
136
  }
@@ -155,7 +165,11 @@ function recordRequest(success) {
155
165
  }
156
166
  recentTotal++;
157
167
  if (!success) recentErrors++;
158
- if (success) consecutiveErrors = 0;
168
+ if (success) {
169
+ consecutiveErrors = 0;
170
+ // 请求成功(含探活)→ 视为 CDN 已恢复,清除自愈状态
171
+ resetCdnRecovered();
172
+ }
159
173
  }
160
174
 
161
175
  function isDegraded() {
@@ -181,6 +195,13 @@ async function restartScraper(reason) {
181
195
  }
182
196
  scraperInitPromise = null;
183
197
  consecutiveErrors = 0;
198
+ // 真正重建:init 全新 scraper 实例,而非仅清空(否则重启期间并发请求会拿到
199
+ // context 为 null 的半死实例,抛 "Cannot read properties of null (reading 'newPage')")
200
+ console.error(`[webserver] 重建 scraper 实例...`);
201
+ const scraper = new TikTokScraper(SCRAPER_CONFIG);
202
+ await scraper.init();
203
+ scraperInstance = scraper;
204
+ lastWarmTime = Date.now();
184
205
  console.error(`[webserver] 重启 scraper 完成`);
185
206
  })();
186
207
  try {
@@ -190,6 +211,70 @@ async function restartScraper(reason) {
190
211
  }
191
212
  }
192
213
 
214
+ // ---- CDN 限流自愈(冷却窗口 + 单次重启 + 躺平)----
215
+ // 首次限流 → 进入冷却期,期间业务请求直接 503(让 IP 静默冷却,Akamai 窗口自动解除);
216
+ // 冷却期满后由下一个请求"探活":成功 → 恢复;仍限流 → 重启一次 scraper 再冷却;
217
+ // 重启后再限流 → 判定 IP/账号级,躺平(不再自动重启,持续 503 靠 nginx failover + 人工换 IP)。
218
+ let cdnCooling = false; // 当前是否处于冷却静默期
219
+ let cdnCooldownUntil = 0; // 冷却结束时间戳;0 = 从未进入冷却
220
+ let cdnRestartedThisWindow = false; // 最近一个冷却窗口是否已执行过重启
221
+ let cdnGiveUp = false; // 判定 IP/账号级,躺平不再自动重启
222
+ let CDN_COOLDOWN_MS = 180000; // 冷却时长,默认 3 分钟(可被 startWebserver 覆盖)
223
+
224
+ function startCdnCooldown() {
225
+ cdnCooling = true;
226
+ cdnCooldownUntil = Date.now() + CDN_COOLDOWN_MS;
227
+ }
228
+
229
+ // 冷却期是否生效;到期后自动解除静默,交给下一个请求探活
230
+ function shouldFailFast() {
231
+ if (cdnGiveUp) return false; // 躺平后不再拦截,让请求尝试(成功即可自然恢复)
232
+ if (!cdnCooling) return false;
233
+ if (Date.now() >= cdnCooldownUntil) {
234
+ cdnCooling = false; // 冷却期满,解除静默
235
+ return false;
236
+ }
237
+ return true;
238
+ }
239
+
240
+ // 成功恢复:清空自愈状态,下次限流重新走完整流程
241
+ function resetCdnRecovered() {
242
+ cdnCooling = false;
243
+ cdnCooldownUntil = 0;
244
+ cdnRestartedThisWindow = false;
245
+ cdnGiveUp = false;
246
+ }
247
+
248
+ // 处理一次 CDN 限流(在 isCdnBlocked 分支调用)
249
+ function handleCdnLimit() {
250
+ if (cdnGiveUp) return;
251
+ const now = Date.now();
252
+ // 冷却窗口未到期(含并发到达):持续冷却,不重启
253
+ if (cdnCooling && now < cdnCooldownUntil) return;
254
+ // 从未进入冷却:首次限流
255
+ if (!cdnCooling && cdnCooldownUntil === 0) {
256
+ startCdnCooldown();
257
+ console.error(
258
+ `[webserver] 触发 CDN 限流自愈:进入 ${Math.round(CDN_COOLDOWN_MS / 1000)}s 冷却期,期间请求直接 503,到期后探活`,
259
+ );
260
+ return;
261
+ }
262
+ // 冷却已结束(探活失败)
263
+ if (!cdnRestartedThisWindow) {
264
+ cdnRestartedThisWindow = true;
265
+ console.error(
266
+ `[webserver] 冷却期后仍 CDN 限流,第 1 次重启 scraper 并再次冷却...`,
267
+ );
268
+ restartScraper("CDN 限流冷却后仍限流").catch(() => {});
269
+ startCdnCooldown();
270
+ } else {
271
+ cdnGiveUp = true;
272
+ console.error(
273
+ `[webserver] 冷却+重启后仍 CDN 限流,判定 IP/账号级,躺平不再自动重启(需人工换 IP/代理)`,
274
+ );
275
+ }
276
+ }
277
+
193
278
  async function warmScraper() {
194
279
  if (scraperClosing || !scraperInstance) return;
195
280
  const now = Date.now();
@@ -519,6 +604,11 @@ function handleHealth(req, res) {
519
604
  lastWarmSec: lastWarmTime
520
605
  ? Math.round((Date.now() - lastWarmTime) / 1000)
521
606
  : null,
607
+ cdnCooling: cdnCooling,
608
+ cdnCooldownSec: cdnCooling
609
+ ? Math.max(0, Math.ceil((cdnCooldownUntil - Date.now()) / 1000))
610
+ : 0,
611
+ cdnGiveUp,
522
612
  uptime: process.uptime(),
523
613
  });
524
614
  }
@@ -549,6 +639,9 @@ export async function startWebserver(
549
639
  proxyServer = null,
550
640
  captchaNotifyUrl = null,
551
641
  enableLog = false,
642
+ requestRatePerSecond = 0,
643
+ requestBurst = 8,
644
+ cdnCooldownSec = 180,
552
645
  ) {
553
646
  if (poolSize > 0) {
554
647
  SCRAPER_CONFIG.poolSize = poolSize;
@@ -560,6 +653,26 @@ export async function startWebserver(
560
653
  SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
561
654
  }
562
655
  SCRAPER_CONFIG.enableLog = !!enableLog;
656
+ // 限速参数:显式传入才覆盖,否则用 SCRAPER_CONFIG 默认值(0=不限速)。
657
+ if (requestRatePerSecond > 0) {
658
+ SCRAPER_CONFIG.requestRatePerSecond = requestRatePerSecond;
659
+ }
660
+ // 突发容量:显式传入则使用;否则自动跟随最终生效的 poolSize。
661
+ if (requestBurst > 0) {
662
+ SCRAPER_CONFIG.requestBurst = requestBurst;
663
+ } else {
664
+ SCRAPER_CONFIG.requestBurst = SCRAPER_CONFIG.poolSize;
665
+ }
666
+ // CDN 冷却时长:优先 CLI 传入(秒)> 环境变量 WEBSERVER_CDN_COOLDOWN_MS(毫秒)> 默认 180s
667
+ const envCooldownMs =
668
+ parseInt(process.env.WEBSERVER_CDN_COOLDOWN_MS || "0", 10) || 0;
669
+ if (cdnCooldownSec > 0) {
670
+ CDN_COOLDOWN_MS = cdnCooldownSec * 1000;
671
+ } else if (envCooldownMs > 0) {
672
+ CDN_COOLDOWN_MS = envCooldownMs;
673
+ } else {
674
+ CDN_COOLDOWN_MS = 180000;
675
+ }
563
676
  const server = http.createServer(async (req, res) => {
564
677
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
565
678
 
@@ -582,6 +695,23 @@ export async function startWebserver(
582
695
  });
583
696
  }
584
697
 
698
+ // CDN 冷却期:业务 API 直接 503,不真正去抓(让 IP 静默冷却,Akamai 窗口可自动解除)
699
+ if (route.path.startsWith("/api/tiktok") && shouldFailFast()) {
700
+ const remainSec = Math.max(
701
+ 0,
702
+ Math.ceil((cdnCooldownUntil - Date.now()) / 1000),
703
+ );
704
+ logErrorThrottled(
705
+ "request CDN冷却",
706
+ `冷却中,直接返回 503 (剩余 ${remainSec}s)`,
707
+ );
708
+ return sendJSON(res, 503, {
709
+ error: "CDN cooldown active, cooling IP",
710
+ retryable: true,
711
+ cdnCooldownSec: remainSec,
712
+ });
713
+ }
714
+
585
715
  try {
586
716
  return await route.handler(req, res);
587
717
  } catch (e) {
@@ -600,6 +730,8 @@ export async function startWebserver(
600
730
  status = 503;
601
731
  retryable = true;
602
732
  logErrorThrottled("request CDN限流", e.message);
733
+ // 触发自愈:冷却 → 到期探活 → 仍限流则单次重启 → 再限流则躺平
734
+ handleCdnLimit();
603
735
  } else if (e.message?.includes("Server is shutting down")) {
604
736
  // 服务正在关闭 → 503
605
737
  status = 503;