tt-help-cli-ycl 1.4.153 → 1.4.155
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-api-interceptor-captcha.mjs +64 -22
- package/scripts/test-daemon-whitelist.mjs +308 -0
- package/scripts/test-explore-captcha-wait.mjs +170 -0
- package/src/clash/daemon.js +125 -35
- package/src/cli/clash-daemon.js +1 -0
- package/src/cli/explore.js +70 -20
- package/src/lib/api-interceptor.js +26 -13
- package/src/lib/args.js +8 -0
- package/src/lib/constants.js +3 -0
- package/src/scraper/modules/captcha-handler.js +57 -18
- package/src/watch/public/app.js +97 -13
- package/src/watch/public/index.html +38 -1
- package/src/watch/watchdog-server.js +39 -1
package/package.json
CHANGED
|
@@ -16,8 +16,13 @@ function check(name, ok, detail = "") {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// 429 + 页面有验证码容器
|
|
19
|
-
|
|
19
|
+
// captchaPhase 控制验证码何时出现:
|
|
20
|
+
// "instant" → 首次检测时已渲染(进分支 5s 检测命中)
|
|
21
|
+
// "delayed" → 首次检测时未渲染,进入重试等待后才出现(sleep 醒来后 3s 检测命中)
|
|
22
|
+
// "never" → 不出现(走正常被封重试)
|
|
23
|
+
function make429CaptchaPage(username, captchaPhase = "instant") {
|
|
20
24
|
let gotoCount = 0;
|
|
25
|
+
let captchaDelayElapsed = false;
|
|
21
26
|
const page = {
|
|
22
27
|
_handlers: {},
|
|
23
28
|
on(event, fn) {
|
|
@@ -33,12 +38,16 @@ function make429CaptchaPage(username) {
|
|
|
33
38
|
gotoCount++;
|
|
34
39
|
},
|
|
35
40
|
// checkCdpHealth 用 () => document.readyState(fn.length===0)→ "complete"
|
|
36
|
-
// detectCaptcha
|
|
37
|
-
_captchaVisible
|
|
41
|
+
// detectCaptcha/detectCaptchaWithWait 通过 captchaDelayElapsed 开关控制
|
|
42
|
+
_captchaVisible() {
|
|
43
|
+
if (captchaPhase === "never") return false;
|
|
44
|
+
if (captchaPhase === "instant") return true;
|
|
45
|
+
return captchaDelayElapsed; // delayed:等待后才可见
|
|
46
|
+
},
|
|
38
47
|
evaluate: async (fn) => {
|
|
39
48
|
if (
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
fn.toString().includes("captcha-verify-container") &&
|
|
50
|
+
page._captchaVisible()
|
|
42
51
|
) {
|
|
43
52
|
return {
|
|
44
53
|
exists: true,
|
|
@@ -50,8 +59,18 @@ function make429CaptchaPage(username) {
|
|
|
50
59
|
if (fn.length === 1) return null;
|
|
51
60
|
return "complete";
|
|
52
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
|
+
},
|
|
53
73
|
reload: async () => {},
|
|
54
|
-
waitForFunction: async () => null,
|
|
55
74
|
waitForResponse: async (pred) => {
|
|
56
75
|
const resp = {
|
|
57
76
|
url: () => "https://www.tiktok.com/api/post/item_list/?...",
|
|
@@ -68,34 +87,61 @@ function make429CaptchaPage(username) {
|
|
|
68
87
|
get gotoCount() {
|
|
69
88
|
return gotoCount;
|
|
70
89
|
},
|
|
90
|
+
// 测试钩子:标记 delayed 验证码已出现
|
|
91
|
+
_markCaptchaDelayed() {
|
|
92
|
+
captchaDelayElapsed = true;
|
|
93
|
+
},
|
|
71
94
|
};
|
|
72
95
|
return page;
|
|
73
96
|
}
|
|
74
97
|
|
|
75
|
-
// 用例1:429 +
|
|
98
|
+
// 用例1:429 + 验证码立即可见 → 首次检测命中,只 goto 1 次(无重试)
|
|
76
99
|
{
|
|
77
|
-
const page = make429CaptchaPage("fumolbbf08o");
|
|
100
|
+
const page = make429CaptchaPage("fumolbbf08o", "instant");
|
|
78
101
|
try {
|
|
79
102
|
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
80
|
-
check("429+验证码 立即转验证码", false, "未抛错");
|
|
103
|
+
check("429+验证码(即时) 立即转验证码", false, "未抛错");
|
|
81
104
|
} catch (e) {
|
|
82
105
|
check(
|
|
83
|
-
"429+验证码 立即转验证码",
|
|
106
|
+
"429+验证码(即时) 立即转验证码",
|
|
84
107
|
e.message === "验证码: fumolbbf08o",
|
|
85
108
|
`抛错 "${e.message}"`,
|
|
86
109
|
);
|
|
87
110
|
}
|
|
88
111
|
check(
|
|
89
|
-
"429+验证码 不再重试(仅 1 次 goto)",
|
|
112
|
+
"429+验证码(即时) 不再重试(仅 1 次 goto)",
|
|
90
113
|
page.gotoCount === 1,
|
|
91
114
|
`goto 次数 = ${page.gotoCount}`,
|
|
92
115
|
);
|
|
93
116
|
}
|
|
94
117
|
|
|
95
|
-
// 用例2
|
|
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 + 验证码始终不出现 → 走正常被封重试,最终抛 "被封:"
|
|
96
143
|
{
|
|
97
|
-
const page = make429CaptchaPage("fumolbbf08o");
|
|
98
|
-
page._captchaVisible = false;
|
|
144
|
+
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
99
145
|
try {
|
|
100
146
|
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
101
147
|
check("429无验证码 仍按被封重试", false, "未抛错");
|
|
@@ -108,15 +154,11 @@ function make429CaptchaPage(username) {
|
|
|
108
154
|
}
|
|
109
155
|
}
|
|
110
156
|
|
|
111
|
-
// 用例
|
|
157
|
+
// 用例4:429 + waitForFunction/检测本身抛异常(上下文销毁)→ 忽略,按被封重试
|
|
112
158
|
{
|
|
113
|
-
const page = make429CaptchaPage("fumolbbf08o");
|
|
114
|
-
page.
|
|
115
|
-
|
|
116
|
-
throw new Error("Execution context was destroyed");
|
|
117
|
-
}
|
|
118
|
-
if (fn.length === 1) return null;
|
|
119
|
-
return "complete";
|
|
159
|
+
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
160
|
+
page.waitForFunction = async () => {
|
|
161
|
+
throw new Error("Execution context was destroyed");
|
|
120
162
|
};
|
|
121
163
|
try {
|
|
122
164
|
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon 白名单守卫 _enforceWhitelist 单测(2026-09-21,无需 Clash 运行)
|
|
3
|
+
*
|
|
4
|
+
* 验证:
|
|
5
|
+
* - whitelistGuard 默认开启,显式 false 才关闭
|
|
6
|
+
* - 守卫关闭 / node 空(?) / 候选集空 / 节点在列表内 / dry-run → 放行不切
|
|
7
|
+
* - 越界节点 → 按候选排序逐个切换+内容验证,第一个满足即切回
|
|
8
|
+
* - 首个候选验证失败 → 落到下一候选
|
|
9
|
+
* - 候选全部不可用 → 恢复原节点(与 failover 口径一致,返回 false)
|
|
10
|
+
* - 切回成功 → 返回 true + 优雅断连 + 更新切换锚点 + health 写入 lastSwitch
|
|
11
|
+
* - setWhitelistGuard IPC 命令动态开关(boolean/"false" 字符串/缺省)
|
|
12
|
+
* - 集成:壳页分支 / 复测恢复分支同样触发守卫(列表外节点不允许)
|
|
13
|
+
*
|
|
14
|
+
* 运行: node --experimental-test-module-mocks --test scripts/test-daemon-whitelist.mjs
|
|
15
|
+
*/
|
|
16
|
+
import { test, mock, before } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
|
|
19
|
+
// ── 可配置假依赖状态 ──
|
|
20
|
+
const state = {
|
|
21
|
+
// 巡检探针结果(healthy 分支用);healthSeq 非空时按序消费(复测恢复场景)
|
|
22
|
+
health: {
|
|
23
|
+
healthy: true,
|
|
24
|
+
httpCode: 200,
|
|
25
|
+
timeTotal: 1.2,
|
|
26
|
+
sizeDownload: 100000,
|
|
27
|
+
numRedirects: 0,
|
|
28
|
+
title: "TikTok - Make Your Day",
|
|
29
|
+
error: null,
|
|
30
|
+
},
|
|
31
|
+
healthSeq: null,
|
|
32
|
+
// 候选集(咱们节点列表)
|
|
33
|
+
nodes: ["🇺🇸 美国01", "🇬🇧 英国02"],
|
|
34
|
+
// 当前正在使用的节点(默认 = 列表外越界节点)
|
|
35
|
+
currentNode: "列表外节点X",
|
|
36
|
+
switches: [],
|
|
37
|
+
healthWrites: [],
|
|
38
|
+
idleCloses: 0,
|
|
39
|
+
// 切到这些节点后内容验证失败(模拟候选也不可用)
|
|
40
|
+
failSwitchTargets: null,
|
|
41
|
+
proxyMap: {},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function resetState() {
|
|
45
|
+
state.health = {
|
|
46
|
+
healthy: true,
|
|
47
|
+
httpCode: 200,
|
|
48
|
+
timeTotal: 1.2,
|
|
49
|
+
sizeDownload: 100000,
|
|
50
|
+
numRedirects: 0,
|
|
51
|
+
title: "TikTok - Make Your Day",
|
|
52
|
+
error: null,
|
|
53
|
+
};
|
|
54
|
+
state.healthSeq = null;
|
|
55
|
+
state.nodes = ["🇺🇸 美国01", "🇬🇧 英国02"];
|
|
56
|
+
state.currentNode = "列表外节点X";
|
|
57
|
+
state.switches = [];
|
|
58
|
+
state.healthWrites = [];
|
|
59
|
+
state.idleCloses = 0;
|
|
60
|
+
state.failSwitchTargets = null;
|
|
61
|
+
state.proxyMap = {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 巡检探针:healthSeq 优先,否则固定 state.health */
|
|
65
|
+
function probeHealth() {
|
|
66
|
+
if (state.healthSeq && state.healthSeq.length) {
|
|
67
|
+
return { ...state.healthSeq.shift() };
|
|
68
|
+
}
|
|
69
|
+
return { ...state.health };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
73
|
+
before(async () => {
|
|
74
|
+
mock.module("../src/clash/checker.js", {
|
|
75
|
+
namedExports: {
|
|
76
|
+
normalizeCountries: (v) => (Array.isArray(v) ? [...v] : v ? [v] : []),
|
|
77
|
+
CONFIG: {
|
|
78
|
+
groupName: "GLOBAL",
|
|
79
|
+
testUrl: "https://www.tiktok.com/@tiktok",
|
|
80
|
+
proxyUrl: "http://127.0.0.1:7897",
|
|
81
|
+
nodeCountryFilter: null,
|
|
82
|
+
},
|
|
83
|
+
checkNodeHealth: () => probeHealth(),
|
|
84
|
+
loadHealthyList: () => ({ nodes: [...state.nodes] }),
|
|
85
|
+
switchToNode: async (n) => {
|
|
86
|
+
state.switches.push(n);
|
|
87
|
+
state.currentNode = n;
|
|
88
|
+
if (state.failSwitchTargets && state.failSwitchTargets.has(n)) {
|
|
89
|
+
// 切过去了但内容验证失败(非壳页异常)
|
|
90
|
+
state.health = {
|
|
91
|
+
...state.health,
|
|
92
|
+
healthy: false,
|
|
93
|
+
error: "timeout",
|
|
94
|
+
numRedirects: 0,
|
|
95
|
+
title: "",
|
|
96
|
+
};
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
state.health = { ...state.health, healthy: true, error: null };
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
applyDiscovered: () => {},
|
|
103
|
+
checkClashAlive: () => true,
|
|
104
|
+
pruneHealthyListByCurrentProxies: () => ({
|
|
105
|
+
kept: state.nodes.length,
|
|
106
|
+
removed: 0,
|
|
107
|
+
allGone: false,
|
|
108
|
+
}),
|
|
109
|
+
runDelayProbe: async () => ({
|
|
110
|
+
nodes: [...state.nodes],
|
|
111
|
+
total: state.nodes.length,
|
|
112
|
+
}),
|
|
113
|
+
saveHealthyList: () => {},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
mock.module("../src/clash/api.js", {
|
|
117
|
+
namedExports: {
|
|
118
|
+
configure: () => {},
|
|
119
|
+
getProxies: async () => ({ proxies: { ...state.proxyMap } }),
|
|
120
|
+
getCurrentNode: async () => state.currentNode,
|
|
121
|
+
closeAllConnections: async () => {},
|
|
122
|
+
closeIdleConnections: async () => {
|
|
123
|
+
state.idleCloses += 1;
|
|
124
|
+
return { closed: 3, active: 0 };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
mock.module("../src/clash/ipc.js", {
|
|
129
|
+
namedExports: {
|
|
130
|
+
writeHealth: (...args) => {
|
|
131
|
+
const data = args.find(
|
|
132
|
+
(a) => a && typeof a === "object" && "node" in a,
|
|
133
|
+
);
|
|
134
|
+
if (data) state.healthWrites.push(data);
|
|
135
|
+
},
|
|
136
|
+
listCommandChannels: () => [],
|
|
137
|
+
readCommand: () => null,
|
|
138
|
+
clearCommand: () => {},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
mock.module("../src/clash/discover.js", {
|
|
142
|
+
namedExports: {
|
|
143
|
+
discoverAll: async () => [],
|
|
144
|
+
invalidateDiscoverCache: () => {},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
({ ClashDaemon } = await import("../src/clash/daemon.js"));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let ClashDaemon = null;
|
|
151
|
+
|
|
152
|
+
function makeCtx(opts = {}) {
|
|
153
|
+
const d = new ClashDaemon(opts);
|
|
154
|
+
const c = { proxyPort: 7897 };
|
|
155
|
+
return { d, c };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 取最后一次 health 写入 */
|
|
159
|
+
function lastWrite() {
|
|
160
|
+
return state.healthWrites[state.healthWrites.length - 1] || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("W1 whitelistGuard 默认开启,显式 false 才关闭", () => {
|
|
164
|
+
assert.equal(new ClashDaemon({}).whitelistGuard, true);
|
|
165
|
+
assert.equal(
|
|
166
|
+
new ClashDaemon({ whitelistGuard: false }).whitelistGuard,
|
|
167
|
+
false,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("W2 守卫关闭:越界也放行,不切换", async () => {
|
|
172
|
+
resetState();
|
|
173
|
+
const { d, c } = makeCtx({ whitelistGuard: false });
|
|
174
|
+
assert.equal(await d._enforceWhitelist(c, state.currentNode), false);
|
|
175
|
+
assert.equal(state.switches.length, 0, "守卫关闭不应切换");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("W3 node 为空或 '?'(API 失败):放行防误切", async () => {
|
|
179
|
+
resetState();
|
|
180
|
+
const { d, c } = makeCtx();
|
|
181
|
+
assert.equal(await d._enforceWhitelist(c, ""), false);
|
|
182
|
+
assert.equal(await d._enforceWhitelist(c, "?"), false);
|
|
183
|
+
assert.equal(state.switches.length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("W4 候选集为空:放行(由 delay 初测补齐)", async () => {
|
|
187
|
+
resetState();
|
|
188
|
+
state.nodes = [];
|
|
189
|
+
const { d, c } = makeCtx();
|
|
190
|
+
assert.equal(await d._enforceWhitelist(c, state.currentNode), false);
|
|
191
|
+
assert.equal(state.switches.length, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("W5 当前节点在列表内:放行", async () => {
|
|
195
|
+
resetState();
|
|
196
|
+
state.currentNode = "🇺🇸 美国01";
|
|
197
|
+
const { d, c } = makeCtx();
|
|
198
|
+
assert.equal(await d._enforceWhitelist(c, state.currentNode), false);
|
|
199
|
+
assert.equal(state.switches.length, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("W6 dry-run:越界只记录不切换", async () => {
|
|
203
|
+
resetState();
|
|
204
|
+
const { d, c } = makeCtx({ dryRun: true });
|
|
205
|
+
assert.equal(await d._enforceWhitelist(c, state.currentNode), false);
|
|
206
|
+
assert.equal(state.switches.length, 0, "dry-run 不应实际切换");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("W7 越界正常切回:返回 true + 断连 + 锚点 + health 记录", async () => {
|
|
210
|
+
resetState();
|
|
211
|
+
const { d, c } = makeCtx();
|
|
212
|
+
const r = await d._enforceWhitelist(c, state.currentNode);
|
|
213
|
+
assert.equal(r, true, "切回成功应返回 true");
|
|
214
|
+
assert.deepEqual(state.switches, ["🇺🇸 美国01"], "未切过候选按原序优先");
|
|
215
|
+
assert.equal(state.idleCloses, 1, "切回后应优雅断连");
|
|
216
|
+
assert.ok(
|
|
217
|
+
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
218
|
+
"切回后应更新切换锚点",
|
|
219
|
+
);
|
|
220
|
+
const w = lastWrite();
|
|
221
|
+
assert.ok(w, "应写入 health");
|
|
222
|
+
assert.equal(w.node, "🇺🇸 美国01");
|
|
223
|
+
assert.match(w.lastSwitch, /白名单守卫/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("W8 首候选验证失败:落到下一候选", async () => {
|
|
227
|
+
resetState();
|
|
228
|
+
state.failSwitchTargets = new Set(["🇺🇸 美国01"]);
|
|
229
|
+
const { d, c } = makeCtx();
|
|
230
|
+
const r = await d._enforceWhitelist(c, state.currentNode);
|
|
231
|
+
assert.equal(r, true);
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
state.switches,
|
|
234
|
+
["🇺🇸 美国01", "🇬🇧 英国02"],
|
|
235
|
+
"首候选失败应尝试下一候选",
|
|
236
|
+
);
|
|
237
|
+
assert.equal(lastWrite().node, "🇬🇧 英国02");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("W9 候选全部不可用:恢复原节点,返回 false(与 failover 口径一致)", async () => {
|
|
241
|
+
resetState();
|
|
242
|
+
state.failSwitchTargets = new Set(["🇺🇸 美国01", "🇬🇧 英国02"]);
|
|
243
|
+
const { d, c } = makeCtx();
|
|
244
|
+
const r = await d._enforceWhitelist(c, state.currentNode);
|
|
245
|
+
assert.equal(r, false, "全部候选失败应返回 false");
|
|
246
|
+
assert.deepEqual(
|
|
247
|
+
state.switches,
|
|
248
|
+
["🇺🇸 美国01", "🇬🇧 英国02", "列表外节点X"],
|
|
249
|
+
"试完全部候选后应恢复原节点(试切过程会真的切换 API)",
|
|
250
|
+
);
|
|
251
|
+
assert.equal(state.currentNode, "列表外节点X", "应恢复原节点");
|
|
252
|
+
const w = lastWrite();
|
|
253
|
+
assert.ok(w, "恢复后应写 health 便于监控页可见");
|
|
254
|
+
assert.equal(w.node, "列表外节点X");
|
|
255
|
+
assert.match(w.error, /候选全部不可用/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("W10 setWhitelistGuard IPC 命令动态开关", () => {
|
|
259
|
+
resetState();
|
|
260
|
+
const { d } = makeCtx();
|
|
261
|
+
d._cmdHandlers.setWhitelistGuard({ enabled: true });
|
|
262
|
+
assert.equal(d.whitelistGuard, true);
|
|
263
|
+
d._cmdHandlers.setWhitelistGuard({ enabled: false });
|
|
264
|
+
assert.equal(d.whitelistGuard, false);
|
|
265
|
+
d._cmdHandlers.setWhitelistGuard({ enabled: "false" });
|
|
266
|
+
assert.equal(d.whitelistGuard, false, "字符串 'false' 应解析为关闭");
|
|
267
|
+
d._cmdHandlers.setWhitelistGuard({});
|
|
268
|
+
assert.equal(d.whitelistGuard, true, "缺省 enabled 应解析为开启");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("W11 集成:壳页分支同样触发守卫(列表外不允许)", async () => {
|
|
272
|
+
resetState();
|
|
273
|
+
// 壳页签名:200 + 无跳转 + 无 error + 无 title
|
|
274
|
+
state.health = {
|
|
275
|
+
healthy: false,
|
|
276
|
+
httpCode: 200,
|
|
277
|
+
timeTotal: 1.0,
|
|
278
|
+
sizeDownload: 43000,
|
|
279
|
+
numRedirects: 0,
|
|
280
|
+
title: "",
|
|
281
|
+
error: null,
|
|
282
|
+
};
|
|
283
|
+
const { d, c } = makeCtx();
|
|
284
|
+
await d._healthCheckController(c);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
state.switches,
|
|
287
|
+
["🇺🇸 美国01"],
|
|
288
|
+
"壳页=链路正常,但越界节点仍应被守卫切回",
|
|
289
|
+
);
|
|
290
|
+
assert.equal(lastWrite().node, "🇺🇸 美国01", "最终 health 应为守卫切回结果");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("W12 集成:复测恢复分支同样触发守卫(列表外不允许)", async () => {
|
|
294
|
+
resetState();
|
|
295
|
+
// 首次异常 → 复测恢复(列表外节点仍不允许)
|
|
296
|
+
state.healthSeq = [
|
|
297
|
+
{ healthy: false, httpCode: 0, timeTotal: 0, sizeDownload: 0, numRedirects: 0, title: "", error: "timeout" },
|
|
298
|
+
{ ...state.health },
|
|
299
|
+
];
|
|
300
|
+
const { d, c } = makeCtx();
|
|
301
|
+
await d._healthCheckController(c); // 内部含 3s 复测等待
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
state.switches,
|
|
304
|
+
["🇺🇸 美国01"],
|
|
305
|
+
"复测恢复后越界节点应被守卫切回",
|
|
306
|
+
);
|
|
307
|
+
assert.equal(lastWrite().node, "🇺🇸 美国01");
|
|
308
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// 验证:explore 两条验证码路径的轮询等待与计数语义
|
|
2
|
+
// 1) waitForCaptchaResolved:解决即继续(提前返回)/ 超时关不掉返回 false / 超时关闭成功返回 true
|
|
3
|
+
// 2) 计数语义:解决后 captchaCount--(同一验证码不再重复计数凑阈值)
|
|
4
|
+
// 运行:node scripts/test-explore-captcha-wait.mjs
|
|
5
|
+
let pass = 0;
|
|
6
|
+
let fail = 0;
|
|
7
|
+
function check(name, ok, detail = "") {
|
|
8
|
+
if (ok) {
|
|
9
|
+
pass++;
|
|
10
|
+
console.log(`✓ ${name}${detail ? `: ${detail}` : ""}`);
|
|
11
|
+
} else {
|
|
12
|
+
fail++;
|
|
13
|
+
console.error(`✗ ${name}${detail ? `: ${detail}` : ""}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 从 explore.js 提取 waitForCaptchaResolved 函数源码做行为验证(依赖注入 page + captchaWaitMin)
|
|
18
|
+
const fs = await import("node:fs");
|
|
19
|
+
const src = fs.readFileSync("src/cli/explore.js", "utf8");
|
|
20
|
+
const start = src.indexOf("async function waitForCaptchaResolved()");
|
|
21
|
+
if (start < 0) throw new Error("waitForCaptchaResolved not found");
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let end = -1;
|
|
24
|
+
for (let i = src.indexOf("{", start); i < src.length; i++) {
|
|
25
|
+
if (src[i] === "{") depth++;
|
|
26
|
+
else if (src[i] === "}") {
|
|
27
|
+
depth--;
|
|
28
|
+
if (depth === 0) {
|
|
29
|
+
end = i + 1;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const fnSrc = src.slice(start, end);
|
|
35
|
+
|
|
36
|
+
// 缩短轮询间隔:把 15000ms 替换为 30ms(仅测试,语义不变)
|
|
37
|
+
const fnSrcFast = fnSrc
|
|
38
|
+
.replace(/setTimeout\(r, 15000\)/g, "setTimeout(r, 30)")
|
|
39
|
+
.replace(/setTimeout\(r, 2000\)/g, "setTimeout(r, 30)");
|
|
40
|
+
|
|
41
|
+
function makeHarness({ captchaStillVisible, detectThrows = false }) {
|
|
42
|
+
// captchaStillVisible: () => bool,控制验证码是否还可见
|
|
43
|
+
const page = {
|
|
44
|
+
evaluate: async (fn) => {
|
|
45
|
+
if (detectThrows) throw new Error("Execution context was destroyed");
|
|
46
|
+
if (fn.toString().includes("captcha-verify-container")) {
|
|
47
|
+
return captchaStillVisible() ? { exists: true, visible: true } : null;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
return { page };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 用例1:验证码在等待期间被解决 → 提前返回 true(不再等满 3 分钟)
|
|
56
|
+
{
|
|
57
|
+
let visible = true;
|
|
58
|
+
setTimeout(() => (visible = false), 100); // 100ms 后人工解决
|
|
59
|
+
const { page } = makeHarness({ captchaStillVisible: () => visible });
|
|
60
|
+
const t0 = Date.now();
|
|
61
|
+
const result = await new Promise((resolve) => {
|
|
62
|
+
const module = new Function(
|
|
63
|
+
"page",
|
|
64
|
+
"captchaWaitMin",
|
|
65
|
+
"detectCaptcha",
|
|
66
|
+
"closeCaptcha",
|
|
67
|
+
`return (async () => { ${fnSrcFast}
|
|
68
|
+
return await waitForCaptchaResolved();
|
|
69
|
+
})()`,
|
|
70
|
+
);
|
|
71
|
+
const dc = async () => (visible ? { exists: true, visible: true } : null);
|
|
72
|
+
resolve(module(page, 3, dc, async () => {}));
|
|
73
|
+
});
|
|
74
|
+
const elapsed = Date.now() - t0;
|
|
75
|
+
check(
|
|
76
|
+
"等待期间解决 → 提前返回 true",
|
|
77
|
+
result === true && elapsed < 3000,
|
|
78
|
+
`elapsed=${elapsed}ms`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 用例2:验证码一直不解决 → 超时后关闭失败 → false(计数切账户由调用方处理)
|
|
83
|
+
{
|
|
84
|
+
const captchaStillVisible0 = () => true; // 永远可见
|
|
85
|
+
const { page } = makeHarness({ captchaStillVisible: captchaStillVisible0 });
|
|
86
|
+
const captchaWaitMin = 0.001; // 60ms(以分钟计)
|
|
87
|
+
const result = await new Promise((resolve) => {
|
|
88
|
+
const module = new Function(
|
|
89
|
+
"page",
|
|
90
|
+
"captchaWaitMin",
|
|
91
|
+
"detectCaptcha",
|
|
92
|
+
"closeCaptcha",
|
|
93
|
+
`return (async () => { ${fnSrcFast}
|
|
94
|
+
return await waitForCaptchaResolved();
|
|
95
|
+
})()`,
|
|
96
|
+
);
|
|
97
|
+
const dc = async () => ({ exists: true, visible: true });
|
|
98
|
+
resolve(module(page, captchaWaitMin, dc, async () => {}));
|
|
99
|
+
});
|
|
100
|
+
check("一直未解决+关闭失败 → false", result === false);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 用例3:超时后 closeCaptcha 关掉了 → true
|
|
104
|
+
{
|
|
105
|
+
let visible = true;
|
|
106
|
+
const { page } = makeHarness({ captchaStillVisible: () => visible });
|
|
107
|
+
const captchaWaitMin = 0.001;
|
|
108
|
+
let closeCalled = false;
|
|
109
|
+
const result = await new Promise((resolve) => {
|
|
110
|
+
const module = new Function(
|
|
111
|
+
"page",
|
|
112
|
+
"captchaWaitMin",
|
|
113
|
+
"detectCaptcha",
|
|
114
|
+
"closeCaptcha",
|
|
115
|
+
`return (async () => { ${fnSrcFast}
|
|
116
|
+
return await waitForCaptchaResolved();
|
|
117
|
+
})()`,
|
|
118
|
+
);
|
|
119
|
+
const dc = async () => (visible ? { exists: true, visible: true } : null);
|
|
120
|
+
const cc = async () => {
|
|
121
|
+
closeCalled = true;
|
|
122
|
+
visible = false; // 关闭成功
|
|
123
|
+
};
|
|
124
|
+
resolve(module(page, captchaWaitMin, dc, cc));
|
|
125
|
+
});
|
|
126
|
+
check(
|
|
127
|
+
"超时后关闭成功 → true(尝试过 closeCaptcha)",
|
|
128
|
+
result === true && closeCalled === true,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 用例4:detectCaptcha 抛异常(页面重建中)→ 按已解决处理返回 true,不卡死
|
|
133
|
+
{
|
|
134
|
+
const { page } = makeHarness({
|
|
135
|
+
captchaStillVisible: () => true,
|
|
136
|
+
detectThrows: true,
|
|
137
|
+
});
|
|
138
|
+
const result = await new Promise((resolve) => {
|
|
139
|
+
const module = new Function(
|
|
140
|
+
"page",
|
|
141
|
+
"captchaWaitMin",
|
|
142
|
+
"detectCaptcha",
|
|
143
|
+
"closeCaptcha",
|
|
144
|
+
`return (async () => { ${fnSrcFast}
|
|
145
|
+
return await waitForCaptchaResolved();
|
|
146
|
+
})()`,
|
|
147
|
+
);
|
|
148
|
+
const dc = async () => {
|
|
149
|
+
throw new Error("Execution context was destroyed");
|
|
150
|
+
};
|
|
151
|
+
resolve(module(page, 3, dc, async () => {}));
|
|
152
|
+
});
|
|
153
|
+
check("检测异常 → 按已解决返回 true(不卡死)", result === true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 用例5:计数语义(代码结构断言)——两条路径均含 resolved 后 captchaCount--
|
|
157
|
+
{
|
|
158
|
+
const hasPath1 =
|
|
159
|
+
src.includes("captchaCount--; // 解决了就不算一次独立事件") ||
|
|
160
|
+
src.includes("captchaCount--; // 解决了就不算");
|
|
161
|
+
const hasPath2 = src.includes("captchaCount--; // 人工解决了,不算独立事件");
|
|
162
|
+
check(
|
|
163
|
+
"两条路径都有解决后回退计数",
|
|
164
|
+
hasPath1 && hasPath2,
|
|
165
|
+
`path1=${hasPath1} path2=${hasPath2}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
170
|
+
process.exit(fail > 0 ? 1 : 0);
|