tt-help-cli-ycl 1.4.154 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.154",
3
+ "version": "1.4.155",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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);