tt-help-cli-ycl 1.4.136 → 1.4.140
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-webserver-error-classify.mjs +379 -0
- package/src/clash/checker.js +14 -2
- package/src/clash/daemon.js +7 -1
- package/src/cli/comments.js +40 -125
- package/src/cli/explore.js +24 -0
- package/src/cli/tag.js +261 -210
- package/src/cli/webserver.js +6 -0
- package/src/lib/api-interceptor-comment.js +14 -2
- package/src/lib/args.js +24 -0
- package/src/lib/browser/page.js +34 -6
- package/src/lib/browser-memory.js +95 -0
- package/src/lib/client-error-report.js +82 -0
- package/src/lib/constants.js +4 -0
- package/src/lib/parse-ssr.mjs +13 -1
- package/src/lib/port.js +72 -0
- package/src/lib/tiktok-scraper.mjs +104 -4
- package/src/watch/public/app.js +67 -23
- package/src/watch/public/style.css +18 -0
- package/src/webserver/server.mjs +354 -39
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 = {
|
|
@@ -71,6 +155,8 @@ const SCRAPER_CONFIG = {
|
|
|
71
155
|
wafTTL: 120000,
|
|
72
156
|
maxRequestsPerPage: 50,
|
|
73
157
|
proxyServer: null,
|
|
158
|
+
requestRatePerSecond: 0, // 0 = 不限制对 TikTok 的请求速率(默认安全值由 CLI 注入)
|
|
159
|
+
requestBurst: 8,
|
|
74
160
|
captchaNotifyUrl: null,
|
|
75
161
|
enableLog: false,
|
|
76
162
|
};
|
|
@@ -121,6 +207,14 @@ let recentWindowStart = Date.now();
|
|
|
121
207
|
const RECENT_WINDOW_MS = 60000; // 60 秒滑动窗口
|
|
122
208
|
|
|
123
209
|
async function getScraper() {
|
|
210
|
+
// 重启进行中:等待重启完成,避免拿到 context 已被关闭的半死实例
|
|
211
|
+
if (restartPromise) {
|
|
212
|
+
try {
|
|
213
|
+
await restartPromise;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error(`[webserver] 等待重启失败: ${e.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
124
218
|
if (scraperInstance && !scraperClosing) {
|
|
125
219
|
return scraperInstance;
|
|
126
220
|
}
|
|
@@ -155,7 +249,11 @@ function recordRequest(success) {
|
|
|
155
249
|
}
|
|
156
250
|
recentTotal++;
|
|
157
251
|
if (!success) recentErrors++;
|
|
158
|
-
if (success)
|
|
252
|
+
if (success) {
|
|
253
|
+
consecutiveErrors = 0;
|
|
254
|
+
// 注意:成功请求不影响 CDN 限流窗口计数(计数只由限流事件驱动,
|
|
255
|
+
// 由 handleCdnLimit 的 5 分钟窗口结算清零),避免交错限流下被反复打回。
|
|
256
|
+
}
|
|
159
257
|
}
|
|
160
258
|
|
|
161
259
|
function isDegraded() {
|
|
@@ -181,6 +279,13 @@ async function restartScraper(reason) {
|
|
|
181
279
|
}
|
|
182
280
|
scraperInitPromise = null;
|
|
183
281
|
consecutiveErrors = 0;
|
|
282
|
+
// 真正重建:init 全新 scraper 实例,而非仅清空(否则重启期间并发请求会拿到
|
|
283
|
+
// context 为 null 的半死实例,抛 "Cannot read properties of null (reading 'newPage')")
|
|
284
|
+
console.error(`[webserver] 重建 scraper 实例...`);
|
|
285
|
+
const scraper = new TikTokScraper(SCRAPER_CONFIG);
|
|
286
|
+
await scraper.init();
|
|
287
|
+
scraperInstance = scraper;
|
|
288
|
+
lastWarmTime = Date.now();
|
|
184
289
|
console.error(`[webserver] 重启 scraper 完成`);
|
|
185
290
|
})();
|
|
186
291
|
try {
|
|
@@ -190,6 +295,175 @@ async function restartScraper(reason) {
|
|
|
190
295
|
}
|
|
191
296
|
}
|
|
192
297
|
|
|
298
|
+
// ---- CDN 限流自愈(限流窗口计数 + 5 分钟结算 + 切节点 + 躺平)----
|
|
299
|
+
// 模型(用户定稿):只数"限流窗口",成功请求完全不参与计数。
|
|
300
|
+
// 每次限流(403/429)→ 计一个限流窗口 + 进入 100s 冷却(静默,请求直接 503);
|
|
301
|
+
// 5 分钟窗口内累计 2 个限流窗口 → 立即切换 Clash 节点;
|
|
302
|
+
// 窗口到期(距第一个限流窗口超过 5 分钟)时结算:限流窗口 ≥2 → 切换并重新计数;<2 → 重新计数。
|
|
303
|
+
// 切节点成功 → 重新计数;连切 CDN_MAX_SWITCHES 个节点仍限流 → 躺平(持续 503 靠 nginx failover)。
|
|
304
|
+
let cdnCooling = false; // 当前是否处于冷却静默期
|
|
305
|
+
let cdnCooldownUntil = 0; // 冷却结束时间戳;0 = 从未进入冷却
|
|
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
|
+
}
|
|
338
|
+
|
|
339
|
+
function startCdnCooldown(reason = "") {
|
|
340
|
+
cdnCooling = true;
|
|
341
|
+
cdnCooldownUntil = Date.now() + CDN_COOLDOWN_MS;
|
|
342
|
+
console.error(
|
|
343
|
+
`[webserver] CDN 冷却开始${reason ? ` (${reason})` : ""}:持续 ${Math.round(CDN_COOLDOWN_MS / 1000)}s,期间请求直接 503`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 冷却期是否生效;到期后自动解除静默
|
|
348
|
+
function shouldFailFast() {
|
|
349
|
+
if (cdnGiveUp) return false; // 躺平后不再拦截,让请求尝试(成功即可自然恢复)
|
|
350
|
+
if (cdnSwitching) return true; // 切节点期间直接 503,避免继续打风控节点
|
|
351
|
+
if (!cdnCooling) return false;
|
|
352
|
+
if (Date.now() >= cdnCooldownUntil) {
|
|
353
|
+
cdnCooling = false; // 冷却期满,解除静默
|
|
354
|
+
console.error(
|
|
355
|
+
`[webserver] CDN 冷却期满,解除静默(限流窗口累计 ${cdnCooldownCount}/${CDN_SWITCH_COOLDOWN_COUNT})`,
|
|
356
|
+
);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
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
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 处理一次 CDN 限流(在 isCdnBlocked 分支调用)
|
|
431
|
+
function handleCdnLimit() {
|
|
432
|
+
if (cdnGiveUp || cdnSwitching) return;
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
// 冷却窗口未到期(含并发到达):持续静默,不重复计数
|
|
435
|
+
if (cdnCooling && now < cdnCooldownUntil) return;
|
|
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();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
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();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
193
467
|
async function warmScraper() {
|
|
194
468
|
if (scraperClosing || !scraperInstance) return;
|
|
195
469
|
const now = Date.now();
|
|
@@ -227,8 +501,17 @@ function logHealth() {
|
|
|
227
501
|
const scraperState = scraperInstance && !scraperClosing ? "运行中" : "未就绪";
|
|
228
502
|
const windowRate =
|
|
229
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
|
+
: "正常";
|
|
230
513
|
console.error(
|
|
231
|
-
`[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}%)`,
|
|
232
515
|
);
|
|
233
516
|
}
|
|
234
517
|
|
|
@@ -519,6 +802,11 @@ function handleHealth(req, res) {
|
|
|
519
802
|
lastWarmSec: lastWarmTime
|
|
520
803
|
? Math.round((Date.now() - lastWarmTime) / 1000)
|
|
521
804
|
: null,
|
|
805
|
+
cdnCooling: cdnCooling,
|
|
806
|
+
cdnCooldownSec: cdnCooling
|
|
807
|
+
? Math.max(0, Math.ceil((cdnCooldownUntil - Date.now()) / 1000))
|
|
808
|
+
: 0,
|
|
809
|
+
cdnGiveUp,
|
|
522
810
|
uptime: process.uptime(),
|
|
523
811
|
});
|
|
524
812
|
}
|
|
@@ -549,6 +837,9 @@ export async function startWebserver(
|
|
|
549
837
|
proxyServer = null,
|
|
550
838
|
captchaNotifyUrl = null,
|
|
551
839
|
enableLog = false,
|
|
840
|
+
requestRatePerSecond = 0,
|
|
841
|
+
requestBurst = 8,
|
|
842
|
+
cdnCooldownSec = 100,
|
|
552
843
|
) {
|
|
553
844
|
if (poolSize > 0) {
|
|
554
845
|
SCRAPER_CONFIG.poolSize = poolSize;
|
|
@@ -560,6 +851,26 @@ export async function startWebserver(
|
|
|
560
851
|
SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
|
|
561
852
|
}
|
|
562
853
|
SCRAPER_CONFIG.enableLog = !!enableLog;
|
|
854
|
+
// 限速参数:显式传入才覆盖,否则用 SCRAPER_CONFIG 默认值(0=不限速)。
|
|
855
|
+
if (requestRatePerSecond > 0) {
|
|
856
|
+
SCRAPER_CONFIG.requestRatePerSecond = requestRatePerSecond;
|
|
857
|
+
}
|
|
858
|
+
// 突发容量:显式传入则使用;否则自动跟随最终生效的 poolSize。
|
|
859
|
+
if (requestBurst > 0) {
|
|
860
|
+
SCRAPER_CONFIG.requestBurst = requestBurst;
|
|
861
|
+
} else {
|
|
862
|
+
SCRAPER_CONFIG.requestBurst = SCRAPER_CONFIG.poolSize;
|
|
863
|
+
}
|
|
864
|
+
// CDN 冷却时长:优先 CLI 传入(秒)> 环境变量 WEBSERVER_CDN_COOLDOWN_MS(毫秒)> 默认 100s
|
|
865
|
+
const envCooldownMs =
|
|
866
|
+
parseInt(process.env.WEBSERVER_CDN_COOLDOWN_MS || "0", 10) || 0;
|
|
867
|
+
if (cdnCooldownSec > 0) {
|
|
868
|
+
CDN_COOLDOWN_MS = cdnCooldownSec * 1000;
|
|
869
|
+
} else if (envCooldownMs > 0) {
|
|
870
|
+
CDN_COOLDOWN_MS = envCooldownMs;
|
|
871
|
+
} else {
|
|
872
|
+
CDN_COOLDOWN_MS = 100000;
|
|
873
|
+
}
|
|
563
874
|
const server = http.createServer(async (req, res) => {
|
|
564
875
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
565
876
|
|
|
@@ -582,48 +893,47 @@ export async function startWebserver(
|
|
|
582
893
|
});
|
|
583
894
|
}
|
|
584
895
|
|
|
896
|
+
// CDN 冷却期:业务 API 直接 503,不真正去抓(让 IP 静默冷却,Akamai 窗口可自动解除)
|
|
897
|
+
if (route.path.startsWith("/api/tiktok") && shouldFailFast()) {
|
|
898
|
+
const remainSec = Math.max(
|
|
899
|
+
0,
|
|
900
|
+
Math.ceil((cdnCooldownUntil - Date.now()) / 1000),
|
|
901
|
+
);
|
|
902
|
+
logErrorThrottled(
|
|
903
|
+
"request CDN冷却",
|
|
904
|
+
`冷却中,直接返回 503 (剩余 ${remainSec}s)`,
|
|
905
|
+
);
|
|
906
|
+
return sendJSON(res, 503, {
|
|
907
|
+
error: "CDN cooldown active, cooling IP",
|
|
908
|
+
retryable: true,
|
|
909
|
+
cdnCooldownSec: remainSec,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
585
913
|
try {
|
|
586
914
|
return await route.handler(req, res);
|
|
587
915
|
} catch (e) {
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
916
|
+
// 错误分类(详见 classifyError):
|
|
917
|
+
// - cdn/shutdown/browser(含重启竞态产物)不计 consecutiveErrors,不触发重启;
|
|
918
|
+
// 否则会形成"重启→竞态错误→再重启"的死亡螺旋,且重启对 IP 风控无效。
|
|
919
|
+
// - timeout/unknown 才算真实故障,累计达阈值才触发 scraper 重启。
|
|
920
|
+
const cls = classifyError(e);
|
|
921
|
+
if (cls.countConsecutive) consecutiveErrors++;
|
|
592
922
|
recordRequest(false);
|
|
593
923
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
let retryable = false;
|
|
597
|
-
|
|
598
|
-
if (isCdnBlocked) {
|
|
599
|
-
// CDN 限流(Akamai Access Denied)→ 503 临时不可用(节点级,nginx 可 failover 到其他节点)
|
|
600
|
-
status = 503;
|
|
601
|
-
retryable = true;
|
|
924
|
+
if (cls.kind === "cdn") {
|
|
925
|
+
// CDN 限流(Akamai Access Denied / goto 非2xx)→ 503 临时不可用(节点级,nginx 可 failover)
|
|
602
926
|
logErrorThrottled("request CDN限流", e.message);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
retryable = true;
|
|
927
|
+
// 触发自愈:冷却 → 到期探活 → 仍限流则单次重启 → 再限流则躺平
|
|
928
|
+
handleCdnLimit();
|
|
929
|
+
} else if (cls.kind === "shutdown") {
|
|
607
930
|
console.error(`[webserver] request 服务关闭中: ${e.message}`);
|
|
608
|
-
} else if (
|
|
609
|
-
e.message?.includes("Timeout") ||
|
|
610
|
-
e.message?.includes("timeout") ||
|
|
611
|
-
e.name === "TimeoutError"
|
|
612
|
-
) {
|
|
613
|
-
// 超时 → 504
|
|
614
|
-
status = 504;
|
|
615
|
-
retryable = true;
|
|
931
|
+
} else if (cls.kind === "timeout") {
|
|
616
932
|
console.error(`[webserver] request 超时: ${e.message}`);
|
|
617
|
-
} else if (
|
|
618
|
-
e.message?.includes("Protocol error") ||
|
|
619
|
-
e.message?.includes("Target closed") ||
|
|
620
|
-
e.message?.includes("Browser closed") ||
|
|
621
|
-
e.message?.includes("Session closed")
|
|
622
|
-
) {
|
|
623
|
-
// 浏览器/页面崩溃 → 503
|
|
624
|
-
status = 503;
|
|
625
|
-
retryable = true;
|
|
933
|
+
} else if (cls.kind === "browser") {
|
|
626
934
|
console.error(`[webserver] request 浏览器异常: ${e.message}`);
|
|
935
|
+
} else if (cls.kind === "business") {
|
|
936
|
+
logErrorThrottled("request 业务状态码", e.message);
|
|
627
937
|
} else {
|
|
628
938
|
console.error(`[webserver] request 未知错误: ${e.message}`);
|
|
629
939
|
}
|
|
@@ -633,10 +943,12 @@ export async function startWebserver(
|
|
|
633
943
|
}
|
|
634
944
|
|
|
635
945
|
if (!res.headersSent) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
946
|
+
const payload = { error: e.message, retryable: cls.retryable };
|
|
947
|
+
if (cls.statusCode) {
|
|
948
|
+
payload.statusCode = cls.statusCode;
|
|
949
|
+
payload.tiktokStatusCode = cls.statusCode;
|
|
950
|
+
}
|
|
951
|
+
return sendJSON(res, cls.status, payload);
|
|
640
952
|
}
|
|
641
953
|
// 响应已发送(流式等场景),记录日志并销毁 socket
|
|
642
954
|
console.error(
|
|
@@ -660,6 +972,9 @@ export async function startWebserver(
|
|
|
660
972
|
// 启动健康心跳日志(每 10 分钟一行,防假死误判)
|
|
661
973
|
startHealthTimer();
|
|
662
974
|
|
|
975
|
+
// 启动前清理端口占用(旧孤儿 webserver 等直接杀掉),避免 EADDRINUSE
|
|
976
|
+
await killListeningOnPort(port);
|
|
977
|
+
|
|
663
978
|
return new Promise((resolve, reject) => {
|
|
664
979
|
server.listen(port, () => {
|
|
665
980
|
const localIP = getLocalIP();
|