tt-help-cli-ycl 1.4.169 → 1.4.171
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-subscription-allocator.mjs +220 -0
- package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
- package/scripts/test-subscription-auto-alloc.mjs +259 -0
- package/scripts/test-subscription-center-e2e.mjs +134 -0
- package/scripts/test-subscription-center-smoke.mjs +154 -0
- package/scripts/test-subscription-center-ui.mjs +111 -0
- package/scripts/vnc-login.sh +68 -0
- package/src/cli/info.js +6 -1
- package/src/lib/browser/cdp.js +7 -1
- package/src/watch/public/app.js +586 -15
- package/src/watch/public/index.html +70 -3
- package/src/watch/subscription-allocator.js +255 -0
- package/src/watch/subscription-center.js +590 -0
- package/src/watch/watchdog-server.js +210 -0
- package/src/watchdog/agent.js +5 -0
- package/scripts/test-api-interceptor-429.mjs +0 -122
- package/scripts/test-api-interceptor-captcha.mjs +0 -176
- package/scripts/test-claim-fix.mjs +0 -216
- package/scripts/test-clash-discover.mjs +0 -126
- package/scripts/test-clash-hysteresis.mjs +0 -131
- package/scripts/test-clash-multichannel.mjs +0 -120
- package/scripts/test-client-error-monitor.mjs +0 -261
- package/scripts/test-country-fix.mjs +0 -341
- package/scripts/test-daemon-failover.mjs +0 -301
- package/scripts/test-daemon-switch-history.mjs +0 -141
- package/scripts/test-daemon-whitelist.mjs +0 -308
- package/scripts/test-explore-captcha-wait.mjs +0 -170
- package/scripts/test-photo-post.mjs +0 -156
- package/scripts/test-precheck-perf.mjs +0 -270
- package/scripts/test-precise-index.mjs +0 -114
- package/scripts/test-probe-live.mjs +0 -83
- package/scripts/test-probe.mjs +0 -315
- package/scripts/test-product-fill-cdp.mjs +0 -336
- package/scripts/test-product-fill-db.mjs +0 -216
- package/scripts/test-product-fill-http.mjs +0 -117
- package/scripts/test-proxy-switch-restart.mjs +0 -198
- package/scripts/test-refill-order.mjs +0 -218
- package/scripts/test-sample-all-fail.mjs +0 -142
- package/scripts/test-seller-any-country.mjs +0 -167
- package/scripts/test-stats-smoke.mjs +0 -43
- package/scripts/test-upgrade-spawn.mjs +0 -179
- package/scripts/test-webserver-error-classify.mjs +0 -409
- package/scripts/test-wmi-free-upgrade.mjs +0 -98
- package/src/cli/test-real-attach.js +0 -0
- 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;
|
package/src/watchdog/agent.js
CHANGED
|
@@ -915,6 +915,11 @@ export class WatchdogAgent {
|
|
|
915
915
|
);
|
|
916
916
|
}
|
|
917
917
|
}
|
|
918
|
+
// 服务端下发「立即重检」:清零上次检测时间,60s 调度器下一跳即执行
|
|
919
|
+
if (data.loginRecheck) {
|
|
920
|
+
this._lastLoginCheckAt = 0;
|
|
921
|
+
console.error("[watchdog] 服务端下发登录重检,下一跳立即执行");
|
|
922
|
+
}
|
|
918
923
|
|
|
919
924
|
// 心跳日志:仅状态变化或每 10 次打印一次
|
|
920
925
|
this._heartbeatSeq++;
|
|
@@ -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);
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
// 临时验证:被判重试期间弹出验证码 → fetchUserVideosAPI 立即抛 "验证码:",不再空转重试
|
|
2
|
-
// 运行:RM_BLOCKED_WAIT_MS=1 node scripts/test-api-interceptor-captcha.mjs
|
|
3
|
-
import { fetchUserVideosAPI } from "../src/lib/api-interceptor.js";
|
|
4
|
-
|
|
5
|
-
let pass = 0;
|
|
6
|
-
let fail = 0;
|
|
7
|
-
|
|
8
|
-
function check(name, ok, detail = "") {
|
|
9
|
-
if (ok) {
|
|
10
|
-
pass++;
|
|
11
|
-
console.log(`✓ ${name}${detail ? `: ${detail}` : ""}`);
|
|
12
|
-
} else {
|
|
13
|
-
fail++;
|
|
14
|
-
console.error(`✗ ${name}${detail ? `: ${detail}` : ""}`);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// 429 + 页面有验证码容器
|
|
19
|
-
// captchaPhase 控制验证码何时出现:
|
|
20
|
-
// "instant" → 首次检测时已渲染(进分支 5s 检测命中)
|
|
21
|
-
// "delayed" → 首次检测时未渲染,进入重试等待后才出现(sleep 醒来后 3s 检测命中)
|
|
22
|
-
// "never" → 不出现(走正常被封重试)
|
|
23
|
-
function make429CaptchaPage(username, captchaPhase = "instant") {
|
|
24
|
-
let gotoCount = 0;
|
|
25
|
-
let captchaDelayElapsed = false;
|
|
26
|
-
const page = {
|
|
27
|
-
_handlers: {},
|
|
28
|
-
on(event, fn) {
|
|
29
|
-
(page._handlers[event] ||= []).push(fn);
|
|
30
|
-
return page;
|
|
31
|
-
},
|
|
32
|
-
off(event) {
|
|
33
|
-
delete page._handlers[event];
|
|
34
|
-
return page;
|
|
35
|
-
},
|
|
36
|
-
url: () => `https://www.tiktok.com/@${username}`,
|
|
37
|
-
goto: async () => {
|
|
38
|
-
gotoCount++;
|
|
39
|
-
},
|
|
40
|
-
// checkCdpHealth 用 () => document.readyState(fn.length===0)→ "complete"
|
|
41
|
-
// detectCaptcha/detectCaptchaWithWait 通过 captchaDelayElapsed 开关控制
|
|
42
|
-
_captchaVisible() {
|
|
43
|
-
if (captchaPhase === "never") return false;
|
|
44
|
-
if (captchaPhase === "instant") return true;
|
|
45
|
-
return captchaDelayElapsed; // delayed:等待后才可见
|
|
46
|
-
},
|
|
47
|
-
evaluate: async (fn) => {
|
|
48
|
-
if (
|
|
49
|
-
fn.toString().includes("captcha-verify-container") &&
|
|
50
|
-
page._captchaVisible()
|
|
51
|
-
) {
|
|
52
|
-
return {
|
|
53
|
-
exists: true,
|
|
54
|
-
visible: true,
|
|
55
|
-
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
// detectPageError(fn.length===1)→ null
|
|
59
|
-
if (fn.length === 1) return null;
|
|
60
|
-
return "complete";
|
|
61
|
-
},
|
|
62
|
-
waitForFunction: async (fn, opts) => {
|
|
63
|
-
// 带等待检测:轮询语义——可见即返回 handle,否则等满 timeout 返回 null
|
|
64
|
-
const deadline = Date.now() + (opts?.timeout || 5000);
|
|
65
|
-
while (Date.now() < deadline) {
|
|
66
|
-
if (page._captchaVisible()) {
|
|
67
|
-
return { jsonValue: async () => true };
|
|
68
|
-
}
|
|
69
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
},
|
|
73
|
-
reload: async () => {},
|
|
74
|
-
waitForResponse: async (pred) => {
|
|
75
|
-
const resp = {
|
|
76
|
-
url: () => "https://www.tiktok.com/api/post/item_list/?...",
|
|
77
|
-
status: () => 429,
|
|
78
|
-
headers: () => ({ "content-length": "19" }),
|
|
79
|
-
json: async () => {
|
|
80
|
-
throw new Error("Unexpected token 'r'...");
|
|
81
|
-
},
|
|
82
|
-
body: async () => Buffer.from("ratelimit triggered"),
|
|
83
|
-
};
|
|
84
|
-
if (!pred(resp)) throw new Error("response 不匹配 item_list");
|
|
85
|
-
return resp;
|
|
86
|
-
},
|
|
87
|
-
get gotoCount() {
|
|
88
|
-
return gotoCount;
|
|
89
|
-
},
|
|
90
|
-
// 测试钩子:标记 delayed 验证码已出现
|
|
91
|
-
_markCaptchaDelayed() {
|
|
92
|
-
captchaDelayElapsed = true;
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
return page;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 用例1:429 + 验证码立即可见 → 首次检测命中,只 goto 1 次(无重试)
|
|
99
|
-
{
|
|
100
|
-
const page = make429CaptchaPage("fumolbbf08o", "instant");
|
|
101
|
-
try {
|
|
102
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
103
|
-
check("429+验证码(即时) 立即转验证码", false, "未抛错");
|
|
104
|
-
} catch (e) {
|
|
105
|
-
check(
|
|
106
|
-
"429+验证码(即时) 立即转验证码",
|
|
107
|
-
e.message === "验证码: fumolbbf08o",
|
|
108
|
-
`抛错 "${e.message}"`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
check(
|
|
112
|
-
"429+验证码(即时) 不再重试(仅 1 次 goto)",
|
|
113
|
-
page.gotoCount === 1,
|
|
114
|
-
`goto 次数 = ${page.gotoCount}`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 用例2(核心场景):429 首检时验证码未渲染 → 重试等待期间弹出
|
|
119
|
-
// → sleep 醒来后的检测必须抓住它(下次 goto 冲掉前),抛 "验证码:"
|
|
120
|
-
{
|
|
121
|
-
const page = make429CaptchaPage("fumolbbf08o", "delayed");
|
|
122
|
-
// 在 waitForFunction 轮询到 captchaDelayElapsed=false 一轮后标记出现,
|
|
123
|
-
// 模拟"等待期渲染":用 setTimeout 在重试 sleep 期间触发
|
|
124
|
-
setTimeout(() => page._markCaptchaDelayed(), 50);
|
|
125
|
-
try {
|
|
126
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
127
|
-
check("等待期间弹出的验证码 被醒来检测抓住", false, "未抛错");
|
|
128
|
-
} catch (e) {
|
|
129
|
-
check(
|
|
130
|
-
"等待期间弹出的验证码 被醒来检测抓住",
|
|
131
|
-
e.message === "验证码: fumolbbf08o",
|
|
132
|
-
`抛错 "${e.message}",goto ${page.gotoCount} 次`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
check(
|
|
136
|
-
"醒来检测抓住时未再发起下一轮 goto(goto ≤ 2)",
|
|
137
|
-
page.gotoCount <= 2,
|
|
138
|
-
`goto 次数 = ${page.gotoCount}`,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 用例3:429 + 验证码始终不出现 → 走正常被封重试,最终抛 "被封:"
|
|
143
|
-
{
|
|
144
|
-
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
145
|
-
try {
|
|
146
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
147
|
-
check("429无验证码 仍按被封重试", false, "未抛错");
|
|
148
|
-
} catch (e) {
|
|
149
|
-
check(
|
|
150
|
-
"429无验证码 仍按被封重试",
|
|
151
|
-
e.message === "被封: fumolbbf08o",
|
|
152
|
-
`抛错 "${e.message}",goto ${page.gotoCount} 次`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 用例4:429 + waitForFunction/检测本身抛异常(上下文销毁)→ 忽略,按被封重试
|
|
158
|
-
{
|
|
159
|
-
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
160
|
-
page.waitForFunction = async () => {
|
|
161
|
-
throw new Error("Execution context was destroyed");
|
|
162
|
-
};
|
|
163
|
-
try {
|
|
164
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
165
|
-
check("检测异常时忽略按被封重试", false, "未抛错");
|
|
166
|
-
} catch (e) {
|
|
167
|
-
check(
|
|
168
|
-
"检测异常时忽略按被封重试",
|
|
169
|
-
e.message === "被封: fumolbbf08o",
|
|
170
|
-
`抛错 "${e.message}"`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
176
|
-
process.exit(fail > 0 ? 1 : 0);
|