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.
- package/package.json +1 -1
- package/scripts/test-client-error-monitor.mjs +176 -0
- package/scripts/test-daemon-failover.mjs +13 -0
- package/scripts/test-proxy-switch-restart.mjs +198 -0
- package/scripts/test-webserver-error-classify.mjs +379 -0
- package/src/clash/checker.js +14 -2
- package/src/clash/daemon.js +33 -11
- package/src/cli/comments.js +5 -4
- package/src/cli/tag.js +26 -17
- package/src/lib/api-client.js +11 -5
- package/src/lib/args.js +4 -4
- package/src/lib/client-error-report.js +3 -0
- package/src/lib/constants.js +1 -1
- package/src/lib/parse-ssr.mjs +13 -1
- package/src/lib/port.js +72 -0
- package/src/lib/tiktok-scraper.mjs +62 -4
- package/src/watch/data-store.js +3 -0
- package/src/watch/public/app.js +249 -31
- package/src/watch/public/index.html +41 -1
- package/src/watch/public/style.css +18 -0
- package/src/watch/server.js +1 -0
- package/src/watch/watchdog-server.js +100 -0
- package/src/watchdog/agent.js +157 -1
- package/src/watchdog/client-error-monitor.js +131 -0
- package/src/watchdog/process-manager.js +49 -0
- package/src/webserver/server.mjs +263 -79
package/src/webserver/server.mjs
CHANGED
|
@@ -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
|
-
//
|
|
171
|
-
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
|
221
|
-
let
|
|
222
|
-
let
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 =
|
|
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(毫秒)> 默认
|
|
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 =
|
|
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
|
-
//
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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 (
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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();
|