tt-help-cli-ycl 1.4.169 → 1.4.172

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-subscription-allocator.mjs +220 -0
  3. package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
  4. package/scripts/test-subscription-auto-alloc.mjs +259 -0
  5. package/scripts/test-subscription-center-e2e.mjs +134 -0
  6. package/scripts/test-subscription-center-smoke.mjs +154 -0
  7. package/scripts/test-subscription-center-ui.mjs +111 -0
  8. package/scripts/vnc-login.sh +68 -0
  9. package/src/cli/info.js +6 -1
  10. package/src/lib/browser/cdp.js +7 -1
  11. package/src/watch/public/app.js +713 -39
  12. package/src/watch/public/index.html +74 -3
  13. package/src/watch/subscription-allocator.js +255 -0
  14. package/src/watch/subscription-center.js +590 -0
  15. package/src/watch/watchdog-server.js +210 -0
  16. package/src/watchdog/agent.js +104 -10
  17. package/scripts/test-api-interceptor-429.mjs +0 -122
  18. package/scripts/test-api-interceptor-captcha.mjs +0 -176
  19. package/scripts/test-claim-fix.mjs +0 -216
  20. package/scripts/test-clash-discover.mjs +0 -126
  21. package/scripts/test-clash-hysteresis.mjs +0 -131
  22. package/scripts/test-clash-multichannel.mjs +0 -120
  23. package/scripts/test-client-error-monitor.mjs +0 -261
  24. package/scripts/test-country-fix.mjs +0 -341
  25. package/scripts/test-daemon-failover.mjs +0 -301
  26. package/scripts/test-daemon-switch-history.mjs +0 -141
  27. package/scripts/test-daemon-whitelist.mjs +0 -308
  28. package/scripts/test-explore-captcha-wait.mjs +0 -170
  29. package/scripts/test-photo-post.mjs +0 -156
  30. package/scripts/test-precheck-perf.mjs +0 -270
  31. package/scripts/test-precise-index.mjs +0 -114
  32. package/scripts/test-probe-live.mjs +0 -83
  33. package/scripts/test-probe.mjs +0 -315
  34. package/scripts/test-product-fill-cdp.mjs +0 -336
  35. package/scripts/test-product-fill-db.mjs +0 -216
  36. package/scripts/test-product-fill-http.mjs +0 -117
  37. package/scripts/test-proxy-switch-restart.mjs +0 -198
  38. package/scripts/test-refill-order.mjs +0 -218
  39. package/scripts/test-sample-all-fail.mjs +0 -142
  40. package/scripts/test-seller-any-country.mjs +0 -167
  41. package/scripts/test-stats-smoke.mjs +0 -43
  42. package/scripts/test-upgrade-spawn.mjs +0 -179
  43. package/scripts/test-webserver-error-classify.mjs +0 -409
  44. package/scripts/test-wmi-free-upgrade.mjs +0 -98
  45. package/src/cli/test-real-attach.js +0 -0
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -19,6 +19,17 @@ import {
19
19
  } from "fs";
20
20
  import { join } from "path";
21
21
  import { homedir } from "os";
22
+ import {
23
+ initSubscriptionCenter,
24
+ ensureWorker,
25
+ getSubscriptionView,
26
+ ingestNodeReport,
27
+ addSubscription,
28
+ updateSubscription,
29
+ deleteSubscription,
30
+ probeSubscription,
31
+ } from "./subscription-center.js";
32
+ import { reconcileAutoAlloc, ALLOC_MAX_INSTANCES } from "./subscription-allocator.js";
22
33
 
23
34
  const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
24
35
  const WATCHDOG_CONFIG_PATH = join(homeDir, ".tt-help-watchdog.json");
@@ -44,6 +55,9 @@ const LLM_CACHE_TTL = 15 * 60 * 1000; // 15 分钟过期
44
55
  // 看门狗客户端下次心跳时取走,取走后清空
45
56
  const clashCmdQueue = new Map();
46
57
 
58
+ // 登录检测手动重检队列:clientId → true(后台页面点「重检」下发,随心跳取走即清空,一次性)
59
+ const loginRecheckQueue = new Map();
60
+
47
61
  // 国家过滤配置幂等下发记录:userId → JSON.stringify(countries)
48
62
  // 仅当配置变化时才入队,避免每次心跳都触发 daemon 刷新候选集
49
63
  const lastSentCountries = new Map();
@@ -495,6 +509,22 @@ async function handleHeartbeat(params, body, res) {
495
509
  // 按 userId 返回该客户端的专属命令列表(先处理到点自动恢复)
496
510
  const userId = (body.config && body.config.userId) || "";
497
511
  autoResumeCommands(userId);
512
+
513
+ // 订阅中心: 被动采集节点上报的 provider 快照(探测 worker 的兜底数据源)+ 惰性启动 worker
514
+ // (单写者: worker 只在接收心跳的进程运行,见 subscription-center.js 头注释)
515
+ try {
516
+ ingestNodeReport(userId, body.clashHealth || null, body.clashHealthChannels || []);
517
+ ensureWorker();
518
+ } catch (e) {
519
+ console.error(`[watchdog] 订阅中心采集异常: ${e.message}`);
520
+ }
521
+ // 订阅自动分配(Phase 2): auto 模式节点惰性重算 alloc,变化才写回;
522
+ // 必须在下方构造响应(读取 proxySettings)之前执行,同一个心跳就把新 alloc 下发
523
+ try {
524
+ reconcileAutoAlloc(userId);
525
+ } catch (e) {
526
+ console.error(`[watchdog] 订阅自动分配异常: ${e.message}`);
527
+ }
498
528
  const commands = getClientCommands(userId);
499
529
 
500
530
  // 幂等下发节点国家配置(默认 US)
@@ -524,6 +554,10 @@ async function handleHeartbeat(params, body, res) {
524
554
  );
525
555
  if (logReqQueue.has(clientId)) logReqQueue.delete(clientId);
526
556
 
557
+ // 取出待下发的登录重检请求(一次性,取走即清空)
558
+ const loginRecheck = loginRecheckQueue.get(clientId) || false;
559
+ if (loginRecheck) loginRecheckQueue.delete(clientId);
560
+
527
561
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
528
562
  console.error(
529
563
  `[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令${clashCommands.length ? ", " + clashCommands.length + "条Clash命令" : ""}`,
@@ -540,6 +574,7 @@ async function handleHeartbeat(params, body, res) {
540
574
  proxySettings: getProxySettingsStored(userId),
541
575
  autoUpgrade: getAutoUpgradeStored(userId),
542
576
  loginPorts: getLoginPortsStored(userId),
577
+ loginRecheck: loginRecheck || undefined,
543
578
  serverTime: Date.now(),
544
579
  });
545
580
  }
@@ -558,9 +593,56 @@ async function handleGetClients(params, body, res) {
558
593
  autoUpgrades: getAllAutoUpgrades(),
559
594
  loginPorts: getAllLoginPorts(),
560
595
  configPath: WATCHDOG_CONFIG_PATH,
596
+ // 订阅中心视图(探测/代报快照 + 状态推导 + 引用关系;独立文件,读多写少)
597
+ subscriptions: getSubscriptionViewSafe(),
561
598
  });
562
599
  }
563
600
 
601
+ function getSubscriptionViewSafe() {
602
+ try {
603
+ return getSubscriptionView();
604
+ } catch (e) {
605
+ console.error(`[watchdog] 订阅视图构建失败: ${e.message}`);
606
+ return [];
607
+ }
608
+ }
609
+
610
+ // ── 订阅中心 API(Phase 1: 池子 CRUD + 主动探测 + 只读引用展示,不改任何节点行为) ──
611
+
612
+ async function handleSubscriptionsList(params, body, res) {
613
+ sendJSON(res, 200, { ok: true, subscriptions: getSubscriptionViewSafe() });
614
+ }
615
+
616
+ async function handleSubscriptionsAdd(params, body, res) {
617
+ const r = addSubscription(body || {});
618
+ sendJSON(res, r.ok ? 200 : 400, r);
619
+ }
620
+
621
+ async function handleSubscriptionsUpdate(params, body, res) {
622
+ const r = updateSubscription(body || {});
623
+ sendJSON(res, r.ok ? 200 : 400, r);
624
+ }
625
+
626
+ async function handleSubscriptionsDelete(params, body, res) {
627
+ const r = deleteSubscription((body || {}).id, { force: !!(body || {}).force });
628
+ sendJSON(res, r.ok ? 200 : r.needForce ? 409 : 400, r);
629
+ }
630
+
631
+ async function handleSubscriptionsProbe(params, body, res) {
632
+ const id = (body || {}).id;
633
+ if (!id) {
634
+ sendJSON(res, 400, { ok: false, error: "缺少 id" });
635
+ return;
636
+ }
637
+ const r = await probeSubscription(id, { manual: true });
638
+ if (r.ok) {
639
+ const view = getSubscriptionViewSafe().find((s) => s.id === id) || null;
640
+ sendJSON(res, 200, { ok: true, subscription: view });
641
+ } else {
642
+ sendJSON(res, 400, r);
643
+ }
644
+ }
645
+
564
646
  // ── 按需日志拉取(点"日志"按钮 → 入队 → 心跳下发 → 客户端回传 → 覆盖写盘) ──
565
647
 
566
648
  /** 后台页面请求某客户端日志:请求入队,等该客户端下次心跳取走 */
@@ -836,6 +918,7 @@ async function handleSetErrorTrigger(params, body, res) {
836
918
  * 允许同一订阅地址配置多行(每行一个独立实例,可各自使用不同节点)
837
919
  * 上限 8 条: 与 discover.js RUNTIME_MAX_INSTANCES=8 对齐(第 9 条实例端口会超出
838
920
  * 巡检枚举段 9097-9104,永远进不了 daemon 巡检列表)
921
+ * 手动模式语义: 整体覆写(无 mode 字段)——存量 auto 节点在此编辑列表即回到手动模式
839
922
  */
840
923
  async function handleSetProxySettings(params, body, res) {
841
924
  const { userId, subscriptions } = body || {};
@@ -866,6 +949,86 @@ async function handleSetProxySettings(params, body, res) {
866
949
  sendJSON(res, 200, { ok: true, subscriptions: cleaned });
867
950
  }
868
951
 
952
+ /**
953
+ * 订阅自动分配(Phase 2): 切换/配置 auto 模式。
954
+ * 迁移 = 方案 A: 存量手动列表原样转成 alloc(count 默认 = 现状条数),
955
+ * 切换瞬间分配结果 = 现状节点无感;之后心跳惰性重算(死订阅换血/跨机器摊开)。
956
+ * 已在 auto 模式时传 count = 调整数量(粘性重分配,砍尾部/补尾部)。
957
+ */
958
+ async function handleSetProxyAuto(params, body, res) {
959
+ const { userId, count } = body || {};
960
+ if (!userId) {
961
+ sendJSON(res, 400, { error: "缺少 userId" });
962
+ return;
963
+ }
964
+ const cur = getProxySettingsStored(userId);
965
+ const curList =
966
+ cur && Array.isArray(cur.subscriptions)
967
+ ? cur.subscriptions.filter((s) => s && (typeof s === "string" || s.url))
968
+ : [];
969
+ let n;
970
+ let capped = false;
971
+ if (count !== undefined && count !== null && count !== "") {
972
+ n = parseInt(count, 10);
973
+ if (!Number.isInteger(n) || n <= 0) {
974
+ sendJSON(res, 400, { error: "count 须为正整数" });
975
+ return;
976
+ }
977
+ if (n > ALLOC_MAX_INSTANCES) {
978
+ n = ALLOC_MAX_INSTANCES; // 超上限截断 + 标红(实例端口段只有 8 个)
979
+ capped = true;
980
+ }
981
+ } else {
982
+ // 迁移 Plan A: count = 现状条数(切换瞬间分配 = 现状,节点无感)
983
+ n = Math.min(Math.max(1, curList.length || 1), ALLOC_MAX_INSTANCES);
984
+ capped = curList.length > ALLOC_MAX_INSTANCES;
985
+ }
986
+ setProxySettingsStored(userId, {
987
+ mode: "auto",
988
+ count: n,
989
+ subscriptions: curList.slice(0, n).map((s) =>
990
+ typeof s === "string" ? { name: "", url: s } : { name: s.name || "", url: s.url },
991
+ ),
992
+ // count-capped 由 countCapped 标志持久化(落盘 count 已截断为 8,若按 count>MAX
993
+ // 判断,下轮 reconcile 会立刻丢失标红)
994
+ countCapped: capped ? true : undefined,
995
+ allocWarn: capped ? "count-capped" : null,
996
+ allocatedAt: Date.now(),
997
+ updatedAt: new Date().toISOString(),
998
+ });
999
+ // 立即跑一遍分配器(死订阅换血/从池子补齐),客户端经下个心跳拿到
1000
+ let reconciled = null;
1001
+ try {
1002
+ reconciled = reconcileAutoAlloc(userId);
1003
+ } catch (e) {
1004
+ console.error(`[watchdog] 订阅自动分配异常: ${e.message}`);
1005
+ }
1006
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1007
+ console.error(
1008
+ `[watchdog ${ts}] ${userId} 订阅切换为自动分配: count=${n}${capped ? `(超上限截断为 ${ALLOC_MAX_INSTANCES})` : ""}`,
1009
+ );
1010
+ sendJSON(res, 200, {
1011
+ ok: true,
1012
+ proxySettings: getProxySettingsStored(userId),
1013
+ changed: !!(reconciled && reconciled.changed),
1014
+ });
1015
+ }
1016
+
1017
+ /** 切回手动模式: 丢弃 mode/count/allocWarn,当前 alloc 原样变成手动列表(分配器不再触碰) */
1018
+ async function handleSetProxyManual(params, body, res) {
1019
+ const { userId } = body || {};
1020
+ if (!userId) {
1021
+ sendJSON(res, 400, { error: "缺少 userId" });
1022
+ return;
1023
+ }
1024
+ const cur = getProxySettingsStored(userId);
1025
+ const list = cur && Array.isArray(cur.subscriptions) ? cur.subscriptions : [];
1026
+ setProxySettingsStored(userId, { subscriptions: list });
1027
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1028
+ console.error(`[watchdog ${ts}] ${userId} 订阅切换为手动模式: ${list.length} 条`);
1029
+ sendJSON(res, 200, { ok: true, proxySettings: { subscriptions: list } });
1030
+ }
1031
+
869
1032
  /** 配置某 agent 的代理切换重启(代理切换后强制重启全部命令;默认不启用) */
870
1033
  async function handleSetSwitchRestart(params, body, res) {
871
1034
  const { userId, config } = body || {};
@@ -942,6 +1105,19 @@ async function handleSetLoginPorts(params, body, res) {
942
1105
  });
943
1106
  }
944
1107
 
1108
+ /** 后台页面手动触发某客户端立即重检一次登录状态(一次性队列随心跳下发,无冷却限频) */
1109
+ async function handleRecheckLogin(params, body, res) {
1110
+ const { clientId } = body || {};
1111
+ if (!clientId || !clients.has(clientId)) {
1112
+ sendJSON(res, 400, { error: "clientId 不存在或客户端未连接" });
1113
+ return;
1114
+ }
1115
+ loginRecheckQueue.set(clientId, true);
1116
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1117
+ console.error(`[watchdog ${ts}] 登录重检请求: clientId=${clientId}`);
1118
+ sendJSON(res, 200, { ok: true, message: "已下发重检,客户端下次心跳时执行" });
1119
+ }
1120
+
945
1121
  // ── 公开 ──
946
1122
 
947
1123
  export function registerWatchdogRoutes(httpServer) {
@@ -950,6 +1126,8 @@ export function registerWatchdogRoutes(httpServer) {
950
1126
  console.error(`[watchdog] 日志目录: ${LOGS_DIR}`);
951
1127
  // 自动迁移:为已有命令补充唯一 ID
952
1128
  migrateCommandIds();
1129
+ // 订阅中心: 仅加载订阅池(worker 由心跳惰性启动,保证单进程探测)
1130
+ initSubscriptionCenter();
953
1131
  }
954
1132
 
955
1133
  // 为现有命令中未分配 ID 的自动补充 ID
@@ -1028,6 +1206,14 @@ export async function tryHandleWatchdogRoute(
1028
1206
  await handleSetProxySettings(params, body, res);
1029
1207
  return true;
1030
1208
  }
1209
+ if (method === "POST" && routePath === "/api/watchdog/set-proxy-auto") {
1210
+ await handleSetProxyAuto(params, body, res);
1211
+ return true;
1212
+ }
1213
+ if (method === "POST" && routePath === "/api/watchdog/set-proxy-manual") {
1214
+ await handleSetProxyManual(params, body, res);
1215
+ return true;
1216
+ }
1031
1217
  if (method === "POST" && routePath === "/api/watchdog/set-auto-upgrade") {
1032
1218
  await handleSetAutoUpgrade(params, body, res);
1033
1219
  return true;
@@ -1036,6 +1222,10 @@ export async function tryHandleWatchdogRoute(
1036
1222
  await handleSetLoginPorts(params, body, res);
1037
1223
  return true;
1038
1224
  }
1225
+ if (method === "POST" && routePath === "/api/watchdog/recheck-login") {
1226
+ await handleRecheckLogin(params, body, res);
1227
+ return true;
1228
+ }
1039
1229
  if (method === "POST" && routePath === "/api/watchdog/request-logs") {
1040
1230
  await handleRequestLogs(params, body, res);
1041
1231
  return true;
@@ -1044,6 +1234,26 @@ export async function tryHandleWatchdogRoute(
1044
1234
  await handleReportLogs(params, body, res);
1045
1235
  return true;
1046
1236
  }
1237
+ if (method === "GET" && routePath === "/api/watchdog/subscriptions") {
1238
+ await handleSubscriptionsList(params, body, res);
1239
+ return true;
1240
+ }
1241
+ if (method === "POST" && routePath === "/api/watchdog/subscriptions") {
1242
+ await handleSubscriptionsAdd(params, body, res);
1243
+ return true;
1244
+ }
1245
+ if (method === "POST" && routePath === "/api/watchdog/subscriptions/update") {
1246
+ await handleSubscriptionsUpdate(params, body, res);
1247
+ return true;
1248
+ }
1249
+ if (method === "POST" && routePath === "/api/watchdog/subscriptions/delete") {
1250
+ await handleSubscriptionsDelete(params, body, res);
1251
+ return true;
1252
+ }
1253
+ if (method === "POST" && routePath === "/api/watchdog/subscriptions/probe") {
1254
+ await handleSubscriptionsProbe(params, body, res);
1255
+ return true;
1256
+ }
1047
1257
  if (method === "GET" && routePath === "/api/watchdog/logs-file") {
1048
1258
  await handleGetLogsFile(params, body, res);
1049
1259
  return true;
@@ -284,6 +284,7 @@ export class WatchdogAgent {
284
284
  this.loginReport = null; // 最近一次登录检测结果(随心跳上报)
285
285
  this._loginChecking = false; // 防重入
286
286
  this._lastLoginCheckAt = 0; // 上次检测时间戳
287
+ this._loginRecheckPending = false; // 服务端下发的手动重检待执行标记
287
288
  this.loginCheckTimer = null;
288
289
  this.running = false;
289
290
  this.heartbeatTimer = null;
@@ -915,6 +916,15 @@ export class WatchdogAgent {
915
916
  );
916
917
  }
917
918
  }
919
+ // 服务端下发「立即重检」:标记待重检 + 清零上次检测时间,60s 调度器下一跳执行
920
+ // (重检只检上次未通过的端口,范围在调度器消费时按最新 loginReport 计算)
921
+ if (data.loginRecheck) {
922
+ this._loginRecheckPending = true;
923
+ this._lastLoginCheckAt = 0;
924
+ console.error(
925
+ "[watchdog] 服务端下发登录重检,下一跳立即执行(仅上次未通过的端口)",
926
+ );
927
+ }
918
928
 
919
929
  // 心跳日志:仅状态变化或每 10 次打印一次
920
930
  this._heartbeatSeq++;
@@ -975,18 +985,102 @@ export class WatchdogAgent {
975
985
  if (!this.running || this._loginChecking) return;
976
986
  if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
977
987
  if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
988
+
989
+ // 手动重检:只检上次未通过的端口;无有效历史结果退化为全量,全部已登录则跳过
990
+ let ports = this.loginPorts;
991
+ if (this._loginRecheckPending) {
992
+ this._loginRecheckPending = false;
993
+ const resolved = this._resolveRecheckPorts();
994
+ if (resolved) {
995
+ if (resolved.length === 0) {
996
+ console.error("[watchdog] 重检跳过: 上次全部已登录,无需重检");
997
+ return; // 不占位,2h 周期照常
998
+ }
999
+ ports = resolved;
1000
+ console.error(
1001
+ `[watchdog] 手动重检: 仅检测上次未通过的端口 (${ports.join(",")})`,
1002
+ );
1003
+ }
1004
+ // resolved === null → 无有效历史结果,保持全量
1005
+ }
1006
+
978
1007
  this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
979
- this._runLoginCheck().catch((e) => {
1008
+ this._runLoginCheck(ports).catch((e) => {
980
1009
  console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
981
1010
  });
982
1011
  }
983
1012
 
1013
+ /**
1014
+ * 计算手动重检端口范围:上次检测未通过(status !== "logged_in")的端口,
1015
+ * 含配置了但上次没检到的端口(同样视为未通过)。
1016
+ * @returns {number[]|null} 端口子集;null = 无有效历史结果(调用方退化为全量)
1017
+ */
1018
+ _resolveRecheckPorts() {
1019
+ const report = this.loginReport;
1020
+ if (
1021
+ !report ||
1022
+ report.error ||
1023
+ !Array.isArray(report.ports) ||
1024
+ report.ports.length === 0
1025
+ ) {
1026
+ return null;
1027
+ }
1028
+ const last = new Map(report.ports.map((r) => [Number(r.port), r.status]));
1029
+ return this.loginPorts.filter((p) => last.get(Number(p)) !== "logged_in");
1030
+ }
1031
+
1032
+ /**
1033
+ * 合并登录检测结果:本次检测过的端口用新结果,未检端口保留上次结果。
1034
+ * 全量检测时未检端口为空,行为等同整体替换(与合并前一致)。
1035
+ * @param {Array|null} results 本次端口级结果(null = 无结果,仅保留旧数据)
1036
+ * @param {number[]} checkedPorts 本次实际检测的端口
1037
+ * @param {string} [error] 本次检测的整级错误(可选)
1038
+ */
1039
+ _mergeLoginReport(results, checkedPorts, error) {
1040
+ const prev =
1041
+ this.loginReport && Array.isArray(this.loginReport.ports)
1042
+ ? this.loginReport.ports
1043
+ : [];
1044
+ const checked = new Set(checkedPorts.map(Number));
1045
+ const order = this.loginPorts.map(Number);
1046
+ const byPort = new Map();
1047
+ // 未被本次覆盖的配置端口保留旧结果(配置外残留丢弃,全量检测时行为等同整体替换)
1048
+ for (const r of prev) {
1049
+ const key = Number(r.port);
1050
+ if (!checked.has(key) && order.includes(key)) byPort.set(key, r);
1051
+ }
1052
+ if (Array.isArray(results)) {
1053
+ for (const r of results) byPort.set(Number(r.port), r);
1054
+ }
1055
+ const ports = [...byPort.values()].sort((a, b) => {
1056
+ const ia = order.indexOf(Number(a.port));
1057
+ const ib = order.indexOf(Number(b.port));
1058
+ return (ia < 0 ? order.length : ia) - (ib < 0 ? order.length : ib);
1059
+ });
1060
+ const report = { ports, checkedAt: Date.now() };
1061
+ if (error) report.error = error;
1062
+ return report;
1063
+ }
1064
+
1065
+ /**
1066
+ * 整级失败时的 loginReport:子集重检失败保留未检端口历史结果,全量检测失败整体记错(原行为)
1067
+ */
1068
+ _failedLoginReport(checkedPorts, error) {
1069
+ if (checkedPorts.length < this.loginPorts.length) {
1070
+ return this._mergeLoginReport(null, checkedPorts, error);
1071
+ }
1072
+ return { error, checkedAt: Date.now() };
1073
+ }
1074
+
984
1075
  /**
985
1076
  * 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
986
1077
  */
987
- async _runLoginCheck() {
1078
+ async _runLoginCheck(portsOverride) {
988
1079
  this._loginChecking = true;
989
- const ports = this.loginPorts;
1080
+ const ports =
1081
+ Array.isArray(portsOverride) && portsOverride.length > 0
1082
+ ? portsOverride
1083
+ : this.loginPorts;
990
1084
  console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
991
1085
  try {
992
1086
  // 全局安装用 tt-help 命令;源码模式用 node cli.js
@@ -1045,7 +1139,7 @@ export class WatchdogAgent {
1045
1139
  // stdout 不是 JSON → 走错误分支
1046
1140
  }
1047
1141
  if (partial) {
1048
- this.loginReport = { ports: partial, checkedAt: Date.now() };
1142
+ this.loginReport = this._mergeLoginReport(partial, ports);
1049
1143
  console.error(
1050
1144
  `[watchdog] 登录检测完成(部分失败): ${
1051
1145
  String(stderr)
@@ -1058,16 +1152,16 @@ export class WatchdogAgent {
1058
1152
  );
1059
1153
  return;
1060
1154
  }
1061
- this.loginReport = {
1062
- error: `exit ${code}: ${String(stderr).slice(-500)}`,
1063
- checkedAt: Date.now(),
1064
- };
1155
+ this.loginReport = this._failedLoginReport(
1156
+ ports,
1157
+ `exit ${code}: ${String(stderr).slice(-500)}`,
1158
+ );
1065
1159
  console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
1066
1160
  return;
1067
1161
  }
1068
1162
  const parsed = JSON.parse(String(stdout).trim());
1069
1163
  if (!Array.isArray(parsed)) throw new Error("结果不是数组");
1070
- this.loginReport = { ports: parsed, checkedAt: Date.now() };
1164
+ this.loginReport = this._mergeLoginReport(parsed, ports);
1071
1165
  const summary = parsed
1072
1166
  .map(
1073
1167
  (r) =>
@@ -1076,7 +1170,7 @@ export class WatchdogAgent {
1076
1170
  .join(" ");
1077
1171
  console.error(`[watchdog] 登录检测完成: ${summary}`);
1078
1172
  } catch (e) {
1079
- this.loginReport = { error: e.message, checkedAt: Date.now() };
1173
+ this.loginReport = this._failedLoginReport(ports, e.message);
1080
1174
  console.error(`[watchdog] 登录检测异常: ${e.message}`);
1081
1175
  } finally {
1082
1176
  this._loginChecking = false;
@@ -1,122 +0,0 @@
1
- // 临时验证:fetchUserVideosAPI 对 HTTP 429 / ratelimit triggered 判定为"被封"
2
- // 运行:RM_BLOCKED_WAIT_MS=1 node scripts/test-api-interceptor-429.mjs
3
- import { fetchUserVideosAPI } from "../src/lib/api-interceptor.js";
4
-
5
- // 构造一个可被 waitForResponse 返回的 429 响应
6
- function make429Response() {
7
- return {
8
- url: () => "https://www.tiktok.com/api/post/item_list/?...",
9
- status: () => 429,
10
- headers: () => ({ "content-length": "19" }),
11
- json: async () => {
12
- throw new Error("Unexpected token 'r'...");
13
- },
14
- body: async () => Buffer.from("ratelimit triggered"),
15
- };
16
- }
17
-
18
- // 构造一个正常响应(itemList 非空)
19
- function make200Response(username) {
20
- const itemList = [
21
- { id: "111", stats: { playCount: 1 }, isECVideo: 0, isAd: 0 },
22
- ];
23
- return {
24
- url: () => "https://www.tiktok.com/api/post/item_list/?...",
25
- status: () => 200,
26
- headers: () => ({}),
27
- json: async () => ({ itemList, cursor: 0, hasMore: false }),
28
- body: async () => Buffer.from(JSON.stringify({ itemList })),
29
- username,
30
- };
31
- }
32
-
33
- // 构造 mock page:封禁场景时 waitForResponse 返回 429 响应
34
- function makePage(username, respFactory) {
35
- const page = {
36
- _handlers: {},
37
- on(event, fn) {
38
- (page._handlers[event] ||= []).push(fn);
39
- return page;
40
- },
41
- off(event) {
42
- delete page._handlers[event];
43
- return page;
44
- },
45
- url: () => `https://www.tiktok.com/@${username}`,
46
- goto: async () => {},
47
- // checkCdpHealth 用 () => document.readyState(fn.length===0)→ 返回 "complete";
48
- // detectPageError 用 (patterns)=>...(fn.length===1)→ 返回 null(页面无错误文案)
49
- evaluate: async (fn) => (fn.length === 1 ? null : "complete"),
50
- reload: async () => {},
51
- waitForFunction: async () => null,
52
- waitForResponse: async (pred, opts) => {
53
- const resp = respFactory();
54
- if (!pred(resp)) throw new Error("response 不匹配 item_list");
55
- return resp;
56
- },
57
- };
58
- return page;
59
- }
60
-
61
- let pass = 0;
62
- let fail = 0;
63
-
64
- async function expectReject(name, fn, expectedMsg) {
65
- try {
66
- await fn();
67
- fail++;
68
- console.error(`✗ ${name}: 未抛错(期望 "${expectedMsg}")`);
69
- } catch (e) {
70
- if (e.message === expectedMsg || e.message.includes(expectedMsg)) {
71
- pass++;
72
- console.log(`✓ ${name}: 抛错 "${e.message}"`);
73
- } else {
74
- fail++;
75
- console.error(
76
- `✗ ${name}: 抛错 "${e.message}"(期望含 "${expectedMsg}")`,
77
- );
78
- }
79
- }
80
- }
81
-
82
- async function expectResolve(name, fn) {
83
- try {
84
- const r = await fn();
85
- if (r instanceof Map && r.size === 1) {
86
- pass++;
87
- console.log(`✓ ${name}: 正常返回 ${r.size} 个视频`);
88
- } else {
89
- fail++;
90
- console.error(`✗ ${name}: 返回异常 ${JSON.stringify(r)}`);
91
- }
92
- } catch (e) {
93
- fail++;
94
- console.error(`✗ ${name}: 抛错 ${e.message}`);
95
- }
96
- }
97
-
98
- // 用例1:429 + ratelimit triggered → 判定为"被封"
99
- await expectReject(
100
- "HTTP 429 ratelimit triggered 判为被封",
101
- () =>
102
- fetchUserVideosAPI(
103
- makePage("fumolbbf08o", make429Response),
104
- "fumolbbf08o",
105
- 5,
106
- () => {},
107
- ),
108
- "被封: fumolbbf08o",
109
- );
110
-
111
- // 用例2:正常 200 itemList → 不误判,正常返回视频
112
- await expectResolve("HTTP 200 正常 itemList 不被误伤", () =>
113
- fetchUserVideosAPI(
114
- makePage("fumolbbf08o", make200Response),
115
- "fumolbbf08o",
116
- 5,
117
- () => {},
118
- ),
119
- );
120
-
121
- console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
122
- process.exit(fail > 0 ? 1 : 0);