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.
@@ -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) consecutiveErrors = 0;
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
- // 错误分类:CDN 限流是环境问题(代理/IP 被 TikTok CDN 拦截),重启 scraper 无法解决,
589
- // 故不累计 consecutiveErrors、不触发重启;但仍计入 recentErrors(/health 展示错误率)。
590
- const isCdnBlocked = e.name === "CDNBlockedError";
591
- if (!isCdnBlocked) consecutiveErrors++;
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
- // 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
595
- let status = 500;
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
- } else if (e.message?.includes("Server is shutting down")) {
604
- // 服务正在关闭 → 503
605
- status = 503;
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
- return sendJSON(res, status, {
637
- error: e.message,
638
- retryable,
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();