tt-help-cli-ycl 1.4.139 → 1.4.142

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.
@@ -3,6 +3,13 @@ import os from "os";
3
3
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
4
4
  import { getProductInfo } from "../lib/shop-scraper.mjs";
5
5
  import { userId as configUserId } from "../lib/constants.js";
6
+ import { killListeningOnPort } from "../lib/port.js";
7
+ import {
8
+ getCurrentNode,
9
+ listGroupNodes,
10
+ getProxyDelay,
11
+ switchProxy,
12
+ } from "../clash/api.js";
6
13
 
7
14
  // ---- 工具函数 ----
8
15
 
@@ -64,6 +71,83 @@ function readBody(req) {
64
71
  });
65
72
  }
66
73
 
74
+ /**
75
+ * 请求错误分类,返回 { kind, status, retryable, countConsecutive }。
76
+ * countConsecutive=false 的错误不会累计 consecutiveErrors(不会触发浏览器重启):
77
+ * - cdn:IP/节点被 TikTok 风控,重启 scraper 无效 → 走 CDN 冷却自愈链路
78
+ * - shutdown:服务关闭中(restart 竞态)→ 503,等待/重试即可
79
+ * - browser:浏览器/页面崩溃或重启竞态产物(Target closed / ERR_ABORTED /
80
+ * newPage null 等)。竞态产物本身是重启的结果而非原因,若计入连续错误会
81
+ * 形成"重启→竞态错误→再重启"的死亡螺旋,故不计。
82
+ * - timeout/unknown:真实故障 → 计入,达阈值触发重启。
83
+ */
84
+ export function classifyError(e) {
85
+ const msg = e?.message || "";
86
+ if (e?.name === "CDNBlockedError") {
87
+ return {
88
+ kind: "cdn",
89
+ status: 503,
90
+ retryable: true,
91
+ countConsecutive: false,
92
+ };
93
+ }
94
+ if (e?.name === "TikTokStatusError" && e?.statusCode) {
95
+ // TikTok 明确 HTTP 状态码(404 不存在等):业务结果,返回 200 带 statusCode,
96
+ // 不计连续错误、不触发重启、nginx 不 failover(与 handleTiktokUser 业务结果一致)
97
+ return {
98
+ kind: "business",
99
+ status: 200,
100
+ retryable: false,
101
+ countConsecutive: false,
102
+ statusCode: e.statusCode,
103
+ };
104
+ }
105
+ if (msg.includes("Server is shutting down")) {
106
+ return {
107
+ kind: "shutdown",
108
+ status: 503,
109
+ retryable: true,
110
+ countConsecutive: false,
111
+ };
112
+ }
113
+ if (
114
+ msg.includes("Timeout") ||
115
+ msg.includes("timeout") ||
116
+ e?.name === "TimeoutError"
117
+ ) {
118
+ return {
119
+ kind: "timeout",
120
+ status: 504,
121
+ retryable: true,
122
+ countConsecutive: true,
123
+ };
124
+ }
125
+ if (
126
+ msg.includes("Protocol error") ||
127
+ msg.includes("Target closed") ||
128
+ msg.includes("Browser closed") ||
129
+ msg.includes("Session closed") ||
130
+ msg.includes("has been closed") ||
131
+ msg.includes("ERR_ABORTED") ||
132
+ msg.includes("frame was detached") ||
133
+ msg.includes("Execution context was destroyed") ||
134
+ msg.includes("Cannot read properties of null")
135
+ ) {
136
+ return {
137
+ kind: "browser",
138
+ status: 503,
139
+ retryable: true,
140
+ countConsecutive: false,
141
+ };
142
+ }
143
+ return {
144
+ kind: "unknown",
145
+ status: 500,
146
+ retryable: false,
147
+ countConsecutive: true,
148
+ };
149
+ }
150
+
67
151
  // ---- Scraper 生命周期 ----
68
152
 
69
153
  const SCRAPER_CONFIG = {
@@ -167,8 +251,8 @@ function recordRequest(success) {
167
251
  if (!success) recentErrors++;
168
252
  if (success) {
169
253
  consecutiveErrors = 0;
170
- // 请求成功(含探活)→ 视为 CDN 已恢复,清除自愈状态
171
- resetCdnRecovered();
254
+ // 注意:成功请求不影响 CDN 限流窗口计数(计数只由限流事件驱动,
255
+ // 由 handleCdnLimit 的 5 分钟窗口结算清零),避免交错限流下被反复打回。
172
256
  }
173
257
  }
174
258
 
@@ -211,67 +295,172 @@ async function restartScraper(reason) {
211
295
  }
212
296
  }
213
297
 
214
- // ---- CDN 限流自愈(冷却窗口 + 单次重启 + 躺平)----
215
- // 首次限流 → 进入冷却期,期间业务请求直接 503(让 IP 静默冷却,Akamai 窗口自动解除);
216
- // 冷却期满后由下一个请求"探活":成功 → 恢复;仍限流 → 重启一次 scraper 再冷却;
217
- // 重启后再限流 → 判定 IP/账号级,躺平(不再自动重启,持续 503 靠 nginx failover + 人工换 IP)。
298
+ // ---- CDN 限流自愈(限流窗口计数 + 5 分钟结算 + 切节点 + 躺平)----
299
+ // 模型(用户定稿):只数"限流窗口",成功请求完全不参与计数。
300
+ // 每次限流(403/429)→ 计一个限流窗口 + 进入 100s 冷却(静默,请求直接 503);
301
+ // 5 分钟窗口内累计 2 个限流窗口 → 立即切换 Clash 节点;
302
+ // 窗口到期(距第一个限流窗口超过 5 分钟)时结算:限流窗口 ≥2 → 切换并重新计数;<2 → 重新计数。
303
+ // 切节点成功 → 重新计数;连切 CDN_MAX_SWITCHES 个节点仍限流 → 躺平(持续 503 靠 nginx failover)。
218
304
  let cdnCooling = false; // 当前是否处于冷却静默期
219
305
  let cdnCooldownUntil = 0; // 冷却结束时间戳;0 = 从未进入冷却
220
- let cdnRestartedThisWindow = false; // 最近一个冷却窗口是否已执行过重启
221
- let cdnGiveUp = false; // 判定 IP/账号级,躺平不再自动重启
222
- let CDN_COOLDOWN_MS = 180000; // 冷却时长,默认 3 分钟(可被 startWebserver 覆盖)
306
+ let cdnCooldownCount = 0; // 5 分钟窗口内累计的限流窗口数
307
+ let cdnWindowStartAt = 0; // 当前 5 分钟窗口起点(第一个限流窗口的时间戳);0 = 无窗口
308
+ let cdnSwitchCount = 0; // 累计切换节点次数(防无限切节点循环)
309
+ let cdnSwitching = false; // 节点切换进行中(防重入)
310
+ let cdnGiveUp = false; // 判定 IP/账号级,躺平不再自动动作
311
+ let CDN_COOLDOWN_MS = 100000; // 单次冷却时长,默认 100 秒(可被 startWebserver 覆盖)
312
+ const CDN_SWITCH_COOLDOWN_COUNT = 2; // 5 分钟窗口内限流窗口数阈值,达到即切换节点
313
+ const CDN_SWITCH_WINDOW_MS = 5 * 60 * 1000; // 统计窗口:5 分钟
314
+ const CDN_MAX_SWITCHES = 3; // 连续切换节点次数上限,超过躺平
315
+ const CLASH_GROUP_NAME = "GLOBAL"; // Clash 选择器组名(与 clash/checker.js CONFIG 一致)
316
+
317
+ /**
318
+ * 5 分钟窗口结算(纯函数,便于单测):
319
+ * - 窗口未到期 → 原样返回
320
+ * - 窗口到期且限流窗口数 ≥ 阈值 → 应切换,并重新计数
321
+ * - 窗口到期但未达阈值 → 重新计数(重新计算)
322
+ */
323
+ export function settleCooldownWindow(
324
+ count,
325
+ windowStartAt,
326
+ now,
327
+ windowMs,
328
+ threshold,
329
+ ) {
330
+ if (windowStartAt && now - windowStartAt > windowMs) {
331
+ if (count >= threshold) {
332
+ return { shouldSwitch: true, count: 0, windowStartAt: 0 };
333
+ }
334
+ return { shouldSwitch: false, count: 0, windowStartAt: 0 };
335
+ }
336
+ return { shouldSwitch: false, count, windowStartAt };
337
+ }
223
338
 
224
- function startCdnCooldown() {
339
+ function startCdnCooldown(reason = "") {
225
340
  cdnCooling = true;
226
341
  cdnCooldownUntil = Date.now() + CDN_COOLDOWN_MS;
342
+ console.error(
343
+ `[webserver] CDN 冷却开始${reason ? ` (${reason})` : ""}:持续 ${Math.round(CDN_COOLDOWN_MS / 1000)}s,期间请求直接 503`,
344
+ );
227
345
  }
228
346
 
229
- // 冷却期是否生效;到期后自动解除静默,交给下一个请求探活
347
+ // 冷却期是否生效;到期后自动解除静默
230
348
  function shouldFailFast() {
231
349
  if (cdnGiveUp) return false; // 躺平后不再拦截,让请求尝试(成功即可自然恢复)
350
+ if (cdnSwitching) return true; // 切节点期间直接 503,避免继续打风控节点
232
351
  if (!cdnCooling) return false;
233
352
  if (Date.now() >= cdnCooldownUntil) {
234
353
  cdnCooling = false; // 冷却期满,解除静默
354
+ console.error(
355
+ `[webserver] CDN 冷却期满,解除静默(限流窗口累计 ${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT})`,
356
+ );
235
357
  return false;
236
358
  }
237
359
  return true;
238
360
  }
239
361
 
240
- // 成功恢复:清空自愈状态,下次限流重新走完整流程
241
- function resetCdnRecovered() {
242
- cdnCooling = false;
243
- cdnCooldownUntil = 0;
244
- cdnRestartedThisWindow = false;
245
- cdnGiveUp = false;
362
+ /**
363
+ * webserver 自己发起的节点 fallback:切换 Clash 组到第一个 delay 探测通过的候选节点。
364
+ * 由 CDN 自愈状态机在"5 分钟内累计 2 个完整限流窗口"后调用,不依赖看门狗/daemon。
365
+ */
366
+ export async function switchClashNode(reason) {
367
+ const current = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
368
+ const nodes = await listGroupNodes(CLASH_GROUP_NAME);
369
+ const candidates = nodes.filter((n) => n !== current);
370
+ if (!candidates.length) {
371
+ throw new Error(
372
+ `切节点失败:组 ${CLASH_GROUP_NAME} 无候选节点 (当前 ${current})`,
373
+ );
374
+ }
375
+ for (const cand of candidates) {
376
+ const d = await getProxyDelay(cand, "https://www.tiktok.com", 5000);
377
+ if (d.delay > 0 && !d.timeout) {
378
+ await switchProxy(CLASH_GROUP_NAME, cand);
379
+ console.error(
380
+ `[webserver] 已切换 Clash 节点: ${current} → ${cand} (delay=${d.delay}ms) [${reason}]`,
381
+ );
382
+ return cand;
383
+ }
384
+ console.error(
385
+ `[webserver] 候选节点探测不通,跳过: ${cand} (delay=${d.delay}ms)`,
386
+ );
387
+ }
388
+ throw new Error(`切节点失败:${candidates.length} 个候选均探测不通`);
389
+ }
390
+
391
+ // 触发切换 Clash 节点(防重入;连切上限;成功重置计数)
392
+ function startCdnSwitch() {
393
+ if (cdnSwitching || cdnGiveUp) return;
394
+ if (cdnSwitchCount >= CDN_MAX_SWITCHES) {
395
+ cdnGiveUp = true;
396
+ console.error(
397
+ `[webserver] 已连切 ${cdnSwitchCount} 个节点仍 CDN 限流,躺平不再自动动作(需人工处理)`,
398
+ );
399
+ return;
400
+ }
401
+ console.error(
402
+ `[webserver] 触发切换节点: 限流窗口 ${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT} 个,开始第 ${cdnSwitchCount + 1}/${CDN_MAX_SWITCHES} 次切节点...`,
403
+ );
404
+ cdnSwitching = true;
405
+ cdnSwitchCount++;
406
+ switchClashNode(
407
+ `5 分钟内累计 ${cdnCooldownCount} 个完整限流窗口,触发第 ${cdnSwitchCount} 次切节点`,
408
+ )
409
+ .then((node) => {
410
+ // 切换成功:重新计数(窗口起点与限流窗口数都清零),新节点重新累计
411
+ cdnCooldownCount = 0;
412
+ cdnWindowStartAt = 0;
413
+ consecutiveErrors = 0;
414
+ console.error(
415
+ `[webserver] 节点切换完成 (${node}),重新计数,进入冷却观察新节点`,
416
+ );
417
+ })
418
+ .catch((e) => {
419
+ cdnGiveUp = true;
420
+ console.error(`[webserver] 切节点失败,躺平: ${e.message}`);
421
+ })
422
+ .finally(() => {
423
+ cdnSwitching = false;
424
+ if (!cdnGiveUp) {
425
+ startCdnCooldown("观察新节点");
426
+ }
427
+ });
246
428
  }
247
429
 
248
430
  // 处理一次 CDN 限流(在 isCdnBlocked 分支调用)
249
431
  function handleCdnLimit() {
250
- if (cdnGiveUp) return;
432
+ if (cdnGiveUp || cdnSwitching) return;
251
433
  const now = Date.now();
252
- // 冷却窗口未到期(含并发到达):持续冷却,不重启
434
+ // 冷却窗口未到期(含并发到达):持续静默,不重复计数
253
435
  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
- );
436
+
437
+ // 5 分钟窗口结算:距窗口起点超过 5 分钟 → 检查是否达标
438
+ const settled = settleCooldownWindow(
439
+ cdnCooldownCount,
440
+ cdnWindowStartAt,
441
+ now,
442
+ CDN_SWITCH_WINDOW_MS,
443
+ CDN_SWITCH_COOLDOWN_COUNT,
444
+ );
445
+ cdnCooldownCount = settled.count;
446
+ cdnWindowStartAt = settled.windowStartAt;
447
+ if (settled.shouldSwitch) {
448
+ console.error(`[webserver] 5 分钟窗口到期,限流窗口数达标,触发切换节点`);
449
+ startCdnSwitch();
260
450
  return;
261
451
  }
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
- );
452
+
453
+ // 本次限流计一个窗口
454
+ if (cdnCooldownCount === 0) cdnWindowStartAt = now;
455
+ cdnCooldownCount++;
456
+ startCdnCooldown(`限流窗口 ${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT}`);
457
+ console.error(
458
+ `[webserver] 触发 CDN 限流自愈:限流窗口 ${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT} 个(5 分钟窗口),进入冷却`,
459
+ );
460
+
461
+ // 窗口内攒够阈值 → 立即切换
462
+ if (cdnCooldownCount >= CDN_SWITCH_COOLDOWN_COUNT) {
463
+ startCdnSwitch();
275
464
  }
276
465
  }
277
466
 
@@ -312,8 +501,17 @@ function logHealth() {
312
501
  const scraperState = scraperInstance && !scraperClosing ? "运行中" : "未就绪";
313
502
  const windowRate =
314
503
  recentTotal > 0 ? Math.round((recentErrors / recentTotal) * 100) : 0;
504
+ const cdnState = cdnGiveUp
505
+ ? "躺平"
506
+ : cdnSwitching
507
+ ? "切节点中"
508
+ : cdnCooling
509
+ ? `冷却中(窗口${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT})`
510
+ : cdnCooldownCount > 0
511
+ ? `窗口累计${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT}`
512
+ : "正常";
315
513
  console.error(
316
- `[webserver] 健康: scraper=${scraperState} | 连续错误=${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS} | 近1min=${recentErrors}/${recentTotal} (${windowRate}%)`,
514
+ `[webserver] 健康: scraper=${scraperState} | CDN=${cdnState} | 连续错误=${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS} | 近1min=${recentErrors}/${recentTotal} (${windowRate}%)`,
317
515
  );
318
516
  }
319
517
 
@@ -543,6 +741,7 @@ async function notifyCaptchaToWatch(url, maxRetries = 3) {
543
741
  "TikTok Shop captcha detected, browser opened - manual intervention required",
544
742
  stage: "product_info",
545
743
  errorStack: url,
744
+ hostname: os.hostname(),
546
745
  }),
547
746
  });
548
747
  console.error("[webserver] Captcha alert sent to watch server");
@@ -641,7 +840,7 @@ export async function startWebserver(
641
840
  enableLog = false,
642
841
  requestRatePerSecond = 0,
643
842
  requestBurst = 8,
644
- cdnCooldownSec = 180,
843
+ cdnCooldownSec = 100,
645
844
  ) {
646
845
  if (poolSize > 0) {
647
846
  SCRAPER_CONFIG.poolSize = poolSize;
@@ -663,7 +862,7 @@ export async function startWebserver(
663
862
  } else {
664
863
  SCRAPER_CONFIG.requestBurst = SCRAPER_CONFIG.poolSize;
665
864
  }
666
- // CDN 冷却时长:优先 CLI 传入(秒)> 环境变量 WEBSERVER_CDN_COOLDOWN_MS(毫秒)> 默认 180s
865
+ // CDN 冷却时长:优先 CLI 传入(秒)> 环境变量 WEBSERVER_CDN_COOLDOWN_MS(毫秒)> 默认 100s
667
866
  const envCooldownMs =
668
867
  parseInt(process.env.WEBSERVER_CDN_COOLDOWN_MS || "0", 10) || 0;
669
868
  if (cdnCooldownSec > 0) {
@@ -671,7 +870,7 @@ export async function startWebserver(
671
870
  } else if (envCooldownMs > 0) {
672
871
  CDN_COOLDOWN_MS = envCooldownMs;
673
872
  } else {
674
- CDN_COOLDOWN_MS = 180000;
873
+ CDN_COOLDOWN_MS = 100000;
675
874
  }
676
875
  const server = http.createServer(async (req, res) => {
677
876
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
@@ -715,47 +914,27 @@ export async function startWebserver(
715
914
  try {
716
915
  return await route.handler(req, res);
717
916
  } catch (e) {
718
- // 错误分类:CDN 限流是环境问题(代理/IP 被 TikTok CDN 拦截),重启 scraper 无法解决,
719
- // 故不累计 consecutiveErrors、不触发重启;但仍计入 recentErrors(/health 展示错误率)。
720
- const isCdnBlocked = e.name === "CDNBlockedError";
721
- if (!isCdnBlocked) consecutiveErrors++;
917
+ // 错误分类(详见 classifyError):
918
+ // - cdn/shutdown/browser(含重启竞态产物)不计 consecutiveErrors,不触发重启;
919
+ // 否则会形成"重启→竞态错误→再重启"的死亡螺旋,且重启对 IP 风控无效。
920
+ // - timeout/unknown 才算真实故障,累计达阈值才触发 scraper 重启。
921
+ const cls = classifyError(e);
922
+ if (cls.countConsecutive) consecutiveErrors++;
722
923
  recordRequest(false);
723
924
 
724
- // 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
725
- let status = 500;
726
- let retryable = false;
727
-
728
- if (isCdnBlocked) {
729
- // CDN 限流(Akamai Access Denied)→ 503 临时不可用(节点级,nginx 可 failover 到其他节点)
730
- status = 503;
731
- retryable = true;
925
+ if (cls.kind === "cdn") {
926
+ // CDN 限流(Akamai Access Denied / goto 非2xx)→ 503 临时不可用(节点级,nginx 可 failover)
732
927
  logErrorThrottled("request CDN限流", e.message);
733
928
  // 触发自愈:冷却 → 到期探活 → 仍限流则单次重启 → 再限流则躺平
734
929
  handleCdnLimit();
735
- } else if (e.message?.includes("Server is shutting down")) {
736
- // 服务正在关闭 → 503
737
- status = 503;
738
- retryable = true;
930
+ } else if (cls.kind === "shutdown") {
739
931
  console.error(`[webserver] request 服务关闭中: ${e.message}`);
740
- } else if (
741
- e.message?.includes("Timeout") ||
742
- e.message?.includes("timeout") ||
743
- e.name === "TimeoutError"
744
- ) {
745
- // 超时 → 504
746
- status = 504;
747
- retryable = true;
932
+ } else if (cls.kind === "timeout") {
748
933
  console.error(`[webserver] request 超时: ${e.message}`);
749
- } else if (
750
- e.message?.includes("Protocol error") ||
751
- e.message?.includes("Target closed") ||
752
- e.message?.includes("Browser closed") ||
753
- e.message?.includes("Session closed")
754
- ) {
755
- // 浏览器/页面崩溃 → 503
756
- status = 503;
757
- retryable = true;
934
+ } else if (cls.kind === "browser") {
758
935
  console.error(`[webserver] request 浏览器异常: ${e.message}`);
936
+ } else if (cls.kind === "business") {
937
+ logErrorThrottled("request 业务状态码", e.message);
759
938
  } else {
760
939
  console.error(`[webserver] request 未知错误: ${e.message}`);
761
940
  }
@@ -765,10 +944,12 @@ export async function startWebserver(
765
944
  }
766
945
 
767
946
  if (!res.headersSent) {
768
- return sendJSON(res, status, {
769
- error: e.message,
770
- retryable,
771
- });
947
+ const payload = { error: e.message, retryable: cls.retryable };
948
+ if (cls.statusCode) {
949
+ payload.statusCode = cls.statusCode;
950
+ payload.tiktokStatusCode = cls.statusCode;
951
+ }
952
+ return sendJSON(res, cls.status, payload);
772
953
  }
773
954
  // 响应已发送(流式等场景),记录日志并销毁 socket
774
955
  console.error(
@@ -792,6 +973,9 @@ export async function startWebserver(
792
973
  // 启动健康心跳日志(每 10 分钟一行,防假死误判)
793
974
  startHealthTimer();
794
975
 
976
+ // 启动前清理端口占用(旧孤儿 webserver 等直接杀掉),避免 EADDRINUSE
977
+ await killListeningOnPort(port);
978
+
795
979
  return new Promise((resolve, reject) => {
796
980
  server.listen(port, () => {
797
981
  const localIP = getLocalIP();