tt-help-cli-ycl 1.4.130 → 1.4.132

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.130",
3
+ "version": "1.4.132",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,14 +1,11 @@
1
1
  /**
2
- * daemon _handleFailover 三级响应单测(2026-08-22,无需 Clash 运行)
2
+ * daemon _handleFailover 单测(2026-08-25,时间锚点方案,无需 Clash 运行)
3
3
  *
4
4
  * 验证:
5
- * - 探针健康 → 窗口内 failover 仅检查不切换(前 N-1 次)
6
- * - 探针不健康 → 立即切换(无视窗口)
7
- * - 壳页 → 视为链路正常,仅检查不切换(与巡检口径一致)
8
- * - 窗口过期 → 计数重置
9
- * - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
10
- * - 实际切换后 → 窗口与计数重置
11
- * - escapeAfter 可配置
5
+ * - 探针不健康 → 立即切换(无视冷却)
6
+ * - 探针健康(含壳页)→ 距上次切换 < 冷却期仅检查不切换
7
+ * - 探针健康 → 距上次切换 >= 冷却期 → 按业务级封禁轮换一次
8
+ * - 切换后(含手动/巡检/failover)更新 _lastSwitchAt 锚点,冷却期内不再切
12
9
  * - 候选集为空 → 触发 delay 初测且不切换
13
10
  *
14
11
  * 运行: node --test scripts/test-daemon-failover.mjs
@@ -116,29 +113,32 @@ function makeCtx(opts = {}) {
116
113
  return { d, c };
117
114
  }
118
115
 
119
- test("F1 探针健康:前 2 次 failover 仅检查不切换,第 3 次轮换", async () => {
116
+ test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点", async () => {
120
117
  resetState();
121
118
  const { d, c } = makeCtx();
122
- await d._handleFailover({ reason: "F1-1" }, c);
123
- await d._handleFailover({ reason: "F1-2" }, c);
124
- assert.equal(state.switches.length, 0, "前 2 次不应切换");
125
- assert.equal(state.closes, 0, "前 2 次不应断开连接");
126
- await d._handleFailover({ reason: "F1-3" }, c);
127
- assert.deepEqual(state.switches, ["🇲🇾 马来02"], "第 3 次按轮换切换到候选");
119
+ c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
120
+ await d._handleFailover({ reason: "F1" }, c);
121
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
128
122
  assert.equal(state.closes, 1, "切换后应断开旧连接");
123
+ assert.ok(
124
+ c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
125
+ "切换后应更新锚点",
126
+ );
129
127
  });
130
128
 
131
- test("F2 探针不健康:立即切换(无视窗口)", async () => {
129
+ test("F2 探针不健康:立即切换(无视冷却)", async () => {
132
130
  resetState();
133
131
  const { d, c } = makeCtx();
132
+ c._lastSwitchAt = Date.now(); // 刚切过,但探针不健康时不看锚点
134
133
  state.health = { ...state.health, healthy: false, error: "timeout" };
135
134
  await d._handleFailover({ reason: "F2" }, c);
136
135
  assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
137
136
  });
138
137
 
139
- test("F3 壳页视为链路正常:仅检查不切换", async () => {
138
+ test("F3 壳页视为链路正常:冷却期内仅检查不切换", async () => {
140
139
  resetState();
141
140
  const { d, c } = makeCtx();
141
+ c._lastSwitchAt = Date.now(); // 冷却期内
142
142
  // 壳页签名:200 + 无跳转 + 无 error + 无 title
143
143
  state.health = {
144
144
  healthy: false,
@@ -150,41 +150,45 @@ test("F3 壳页视为链路正常:仅检查不切换", async () => {
150
150
  error: null,
151
151
  };
152
152
  await d._handleFailover({ reason: "F3" }, c);
153
- assert.equal(state.switches.length, 0, "壳页不应触发切换");
153
+ assert.equal(state.switches.length, 0, "冷却期内壳页不应触发切换");
154
154
  });
155
155
 
156
- test("F4 窗口过期:计数重置,不累计到逃生阈值", async () => {
156
+ test("F4 冷却期约束:切换后冷却期内不连续切换,过期后再切", async () => {
157
157
  resetState();
158
- const { d, c } = makeCtx({ failoverCooldownMs: 150 });
159
- await d._handleFailover({ reason: "F4-1" }, c); // count=1
160
- await new Promise((r) => setTimeout(r, 200)); // 窗口过期
161
- await d._handleFailover({ reason: "F4-2" }, c); // 重置后 count=1
162
- await d._handleFailover({ reason: "F4-3" }, c); // count=2
163
- assert.equal(state.switches.length, 0, "窗口过期后不应累计旧计数");
164
- await d._handleFailover({ reason: "F4-4" }, c); // count=3 → 轮换
165
- assert.equal(state.switches.length, 1, "第 3 次才应轮换");
158
+ const { d, c } = makeCtx();
159
+ // 首次 failover(无锚点)→ 按冷却期外处理 → 轮换
160
+ await d._handleFailover({ reason: "F4-1" }, c);
161
+ assert.equal(state.switches.length, 1, "首次应轮换切换");
162
+ // 锚点刚更新,冷却期内再次 failover(探针健康)→ 不切
163
+ await d._handleFailover({ reason: "F4-2" }, c);
164
+ assert.equal(state.switches.length, 1, "冷却期内不应再切换");
165
+ // 冷却期过后 → 再次轮换
166
+ c._lastSwitchAt = Date.now() - 600_001;
167
+ await d._handleFailover({ reason: "F4-3" }, c);
168
+ assert.equal(state.switches.length, 2, "冷却期过后应再切换");
166
169
  });
167
170
 
168
- test("F5 切换后窗口重置:重新从仅检查开始", async () => {
171
+ test("F5 手动切换同样更新锚点:切换后冷却期内 failover 不再切", async () => {
169
172
  resetState();
170
173
  const { d, c } = makeCtx();
171
- await d._handleFailover({ reason: "F5-1" }, c); // skip
172
- await d._handleFailover({ reason: "F5-2" }, c); // skip
173
- await d._handleFailover({ reason: "F5-3" }, c); // switch
174
- assert.equal(state.switches.length, 1, "第 3 次应切换");
175
- await d._handleFailover({ reason: "F5-4" }, c); // 重置后 count=1 → skip
176
- assert.equal(state.switches.length, 1, "切换后应重置窗口,不连续切换");
177
- await d._handleFailover({ reason: "F5-5" }, c); // count=2 → skip
178
- await d._handleFailover({ reason: "F5-6" }, c); // count=3 → switch
179
- assert.equal(state.switches.length, 2, "第 6 次才应再次轮换");
174
+ // 手动切换(用户主动切,立即执行)
175
+ await d._handleSwitchNode({ node: "🇲🇾 马来02", proxyPort: 7897 }, c);
176
+ assert.equal(state.switches.length, 1, "手动切换应立即执行");
177
+ assert.ok(
178
+ c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
179
+ "手动切换应更新锚点",
180
+ );
181
+ // 手动切换后冷却期内,探针健康的 failover 不再切
182
+ await d._handleFailover({ reason: "F5-2" }, c);
183
+ assert.equal(
184
+ state.switches.length,
185
+ 1,
186
+ "手动切换后冷却期内 failover 不应再切",
187
+ );
180
188
  });
181
189
 
182
- test("F6 escapeAfter 可配置:=1 时首次健康 failover 即轮换", async () => {
183
- resetState();
184
- const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
185
- await d._handleFailover({ reason: "F6" }, c);
186
- assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
187
- });
190
+ // F6 已移除:failoverEscapeAfter 计数方案已弃用(2026-08-25 改为时间锚点),
191
+ // 不再存在"连报 N 次"阈值;字段保留仅为兼容外部传参,不影响行为。
188
192
 
189
193
  test("F7 候选集为空:触发 delay 初测且不切换", async () => {
190
194
  resetState();
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 探测模块集成测试(真实浏览器 + 真实 TikTok)
3
+ *
4
+ * 用法:
5
+ * node scripts/test-probe-live.mjs [ports] [points]
6
+ * 例: node scripts/test-probe-live.mjs 4222
7
+ * node scripts/test-probe-live.mjs 4222 video,comment
8
+ *
9
+ * 说明:
10
+ * - 连接本地已启动的 CDP 端口(不 kill 业务浏览器)
11
+ * - 会对 TikTok 真实发起访问(探测本身是访问行为)
12
+ * - 断言输出结构 + 至少能完成调度;业务可用性结果只打印不判成败
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import { runProbe, POINT_NAMES } from "../src/lib/probe/index.js";
16
+
17
+ const ports = (process.argv[2] || "4222")
18
+ .split(",")
19
+ .map((s) => Number(s.trim()))
20
+ .filter((n) => Number.isInteger(n) && n > 0);
21
+ const points = process.argv[3]
22
+ ? process.argv[3].split(",").map((s) => s.trim()).filter(Boolean)
23
+ : POINT_NAMES;
24
+
25
+ console.error(`[live-test] ports=${ports.join(",")} points=${points.join(",")}`);
26
+ console.error(`[live-test] 说明:将对 TikTok 真实访问,属正常探测行为\n`);
27
+
28
+ const t0 = Date.now();
29
+ const result = await runProbe({ ports, points, log: console.error });
30
+ const totalMs = Date.now() - t0;
31
+
32
+ console.error(`\n[结果] 总耗时 ${totalMs}ms`);
33
+ console.log(JSON.stringify(result, null, 2));
34
+
35
+ // ── 结构断言(不判业务成败,只判调度/输出正确) ──
36
+ let failed = 0;
37
+ try {
38
+ assert.ok(Array.isArray(result.ports), "ports 应为数组");
39
+ assert.equal(result.ports.length, ports.length, "端口数应一致");
40
+ assert.ok(result.ports.every((p) => Number.isInteger(p.port)), "端口号应为整数");
41
+ assert.ok(
42
+ result.ports.every((p) => typeof p.loggedIn === "boolean"),
43
+ "loggedIn 应为布尔",
44
+ );
45
+ // 每端口包含请求的功能点键
46
+ for (const p of result.ports) {
47
+ for (const name of points) {
48
+ assert.ok(
49
+ name in p.points,
50
+ `端口 ${p.port} 应包含功能点 ${name}`,
51
+ );
52
+ const r = p.points[name];
53
+ assert.equal(typeof r.ok, "boolean", `${name}.ok 应为布尔`);
54
+ assert.equal(typeof r.count, "number", `${name}.count 应为数字`);
55
+ assert.equal(typeof r.elapsedMs, "number", `${name}.elapsedMs 应为数字`);
56
+ assert.ok(
57
+ typeof r.error === "string" || r.error === null,
58
+ `${name}.error 应为 string|null`,
59
+ );
60
+ }
61
+ }
62
+ console.error(`\n[断言] 结构校验全部通过 ✓`);
63
+ } catch (e) {
64
+ failed = 1;
65
+ console.error(`\n[断言] 失败: ${e.message}`);
66
+ }
67
+
68
+ // 汇总业务结果
69
+ console.error("\n[业务汇总]");
70
+ for (const p of result.ports) {
71
+ for (const name of points) {
72
+ const r = p.points[name];
73
+ if (!r) continue;
74
+ const tag = r.skipped
75
+ ? `跳过(${r.skipReason})`
76
+ : r.ok
77
+ ? `✓ ${r.count}`
78
+ : `✗ ${r.error}`;
79
+ console.error(` port ${p.port} ${name.padEnd(8)} ${tag} [${r.elapsedMs}ms]`);
80
+ }
81
+ }
82
+
83
+ process.exit(failed);
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 探测模块单元测试(离线,不连浏览器)
3
+ *
4
+ * 覆盖:
5
+ * - common: classifyError / withTimeout / cleanMessage / makePointResult
6
+ * - targets: DEFAULT_TARGETS / resolveTargets
7
+ * - index: POINT_NAMES / normalizePoints / runProbe 空端口与异常端口
8
+ * - points: comment 无视频 skipped、follow 未登录 skipped、POINT_NAME 注册
9
+ *
10
+ * 运行: node scripts/test-probe.mjs
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import {
14
+ classifyError,
15
+ withTimeout,
16
+ cleanMessage,
17
+ makePointResult,
18
+ isTransientKind,
19
+ retryOnTransient,
20
+ } from "../src/lib/probe/common.js";
21
+ import { DEFAULT_TARGETS, resolveTargets } from "../src/lib/probe/targets.js";
22
+ import {
23
+ POINTS,
24
+ POINT_NAMES,
25
+ normalizePoints,
26
+ runProbe,
27
+ } from "../src/lib/probe/index.js";
28
+ import {
29
+ POINT_NAME as COMMENT_POINT_NAME,
30
+ probeComment,
31
+ } from "../src/lib/probe/points/comment.js";
32
+ import {
33
+ POINT_NAME as FOLLOW_POINT_NAME,
34
+ probeFollow,
35
+ } from "../src/lib/probe/points/follow.js";
36
+
37
+ let passed = 0;
38
+ const failures = [];
39
+ const allTests = [];
40
+ function test(name, fn) {
41
+ allTests.push(
42
+ Promise.resolve().then(() => {
43
+ try {
44
+ fn();
45
+ passed++;
46
+ console.log(`✓ ${name}`);
47
+ } catch (e) {
48
+ failures.push({ name, error: e });
49
+ console.error(`✗ ${name}: ${e.message}`);
50
+ }
51
+ }),
52
+ );
53
+ }
54
+ function testAsync(name, fn) {
55
+ allTests.push(
56
+ Promise.resolve().then(async () => {
57
+ try {
58
+ await fn();
59
+ passed++;
60
+ console.log(`✓ ${name}`);
61
+ } catch (e) {
62
+ failures.push({ name, error: e });
63
+ console.error(`✗ ${name}: ${e.message}`);
64
+ }
65
+ }),
66
+ );
67
+ }
68
+
69
+ // ── common.classifyError ──
70
+ test("classifyError: 被封 → blocked", () => {
71
+ assert.equal(classifyError(new Error("被封: abc")).kind, "blocked");
72
+ });
73
+ test("classifyError: CDN限流 → rate_limited", () => {
74
+ assert.equal(classifyError(new Error("CDN限流: abc")).kind, "rate_limited");
75
+ });
76
+ test("classifyError: Access Denied → rate_limited", () => {
77
+ assert.equal(classifyError("Access Denied").kind, "rate_limited");
78
+ });
79
+ test("classifyError: 验证码 → captcha", () => {
80
+ assert.equal(classifyError("验证码出现").kind, "captcha");
81
+ });
82
+ test("classifyError: captcha → captcha", () => {
83
+ assert.equal(classifyError("captcha verify").kind, "captcha");
84
+ });
85
+ test("classifyError: Timeout → timeout", () => {
86
+ assert.equal(classifyError(new Error("Timeout 123")).kind, "timeout");
87
+ });
88
+ test("classifyError: 超时 → timeout", () => {
89
+ assert.equal(classifyError("请求超时").kind, "timeout");
90
+ });
91
+ test("classifyError: login_required → login_required", () => {
92
+ assert.equal(classifyError("login_required").kind, "login_required");
93
+ });
94
+ test("classifyError: 未登录 → login_required", () => {
95
+ assert.equal(classifyError("请先登录").kind, "login_required");
96
+ });
97
+ test("classifyError: region → region_blocked", () => {
98
+ assert.equal(
99
+ classifyError("not available in your region").kind,
100
+ "region_blocked",
101
+ );
102
+ });
103
+ test("classifyError: 普通错误 → error", () => {
104
+ assert.equal(classifyError("boom something").kind, "error");
105
+ });
106
+ test("classifyError: 空 → error + 未知错误", () => {
107
+ const c = classifyError(null);
108
+ assert.equal(c.kind, "error");
109
+ assert.equal(c.message, "未知错误");
110
+ });
111
+
112
+ // ── common.cleanMessage ──
113
+ test("cleanMessage: 去 ANSI + 首行 + 限长", () => {
114
+ const out = cleanMessage("\x1b[31mline1\x1b[0m\nline2\n".repeat(50));
115
+ assert.ok(!out.includes("\x1b"));
116
+ assert.ok(!out.includes("\n"));
117
+ assert.ok(out.length <= 300);
118
+ });
119
+
120
+ // ── common.withTimeout ──
121
+ testAsync("withTimeout: 正常完成返回结果", async () => {
122
+ const r = await withTimeout(Promise.resolve("ok"), 2000, "t");
123
+ assert.equal(r, "ok");
124
+ });
125
+ testAsync("withTimeout: 超时 reject", async () => {
126
+ let timedOut = false;
127
+ try {
128
+ await withTimeout(new Promise(() => {}), 30, "超时测试");
129
+ } catch (e) {
130
+ timedOut = true;
131
+ assert.ok(e.message.includes("超时测试"));
132
+ }
133
+ assert.ok(timedOut, "应超时 reject");
134
+ });
135
+
136
+ // ── common.makePointResult ──
137
+ test("makePointResult: 默认空 → kind=empty", () => {
138
+ const r = makePointResult({ ok: false });
139
+ assert.equal(r.kind, "empty");
140
+ assert.equal(r.count, 0);
141
+ assert.equal(r.skipped, false);
142
+ });
143
+ test("makePointResult: ok → kind=null + extra 合并", () => {
144
+ const r = makePointResult({ ok: true, count: 3, extra: { a: 1 } });
145
+ assert.equal(r.kind, null);
146
+ assert.equal(r.a, 1);
147
+ });
148
+ test("makePointResult: skipped + 显式 kind", () => {
149
+ const r = makePointResult({
150
+ ok: false,
151
+ skipped: true,
152
+ skipReason: "no_video",
153
+ kind: "login_required",
154
+ });
155
+ assert.equal(r.kind, "login_required");
156
+ assert.equal(r.skipReason, "no_video");
157
+ });
158
+
159
+ // ── common.isTransientKind / retryOnTransient ──
160
+ test("isTransientKind: 瞬时型为 true", () => {
161
+ for (const k of ["timeout", "empty", "error", "waf_challenge"]) {
162
+ assert.equal(isTransientKind(k), true, `${k} 应为瞬时`);
163
+ }
164
+ });
165
+ test("isTransientKind: 确定型与空为 false", () => {
166
+ for (const k of [
167
+ "blocked",
168
+ "rate_limited",
169
+ "captcha",
170
+ "login_required",
171
+ "region_blocked",
172
+ null,
173
+ undefined,
174
+ "",
175
+ ]) {
176
+ assert.equal(isTransientKind(k), false, `${k} 不应为瞬时`);
177
+ }
178
+ });
179
+ testAsync("retryOnTransient: 成功即返回(只调 1 次)", async () => {
180
+ let calls = 0;
181
+ const r = await retryOnTransient(async () => {
182
+ calls++;
183
+ return makePointResult({ ok: true, count: 3 });
184
+ }, { retries: 2, retryDelayMs: 0 });
185
+ assert.equal(r.ok, true);
186
+ assert.equal(calls, 1);
187
+ });
188
+ testAsync("retryOnTransient: 确定性失败不重试(只调 1 次)", async () => {
189
+ let calls = 0;
190
+ const r = await retryOnTransient(async () => {
191
+ calls++;
192
+ return makePointResult({ ok: false, kind: "blocked" });
193
+ }, { retries: 2, retryDelayMs: 0 });
194
+ assert.equal(r.ok, false);
195
+ assert.equal(r.kind, "blocked");
196
+ assert.equal(calls, 1);
197
+ });
198
+ testAsync("retryOnTransient: 瞬时失败后第 2 次成功", async () => {
199
+ let calls = 0;
200
+ const r = await retryOnTransient(async () => {
201
+ calls++;
202
+ if (calls === 1) return makePointResult({ ok: false, kind: "waf_challenge" });
203
+ return makePointResult({ ok: true, count: 5 });
204
+ }, { retries: 2, retryDelayMs: 0 });
205
+ assert.equal(r.ok, true);
206
+ assert.equal(calls, 2);
207
+ });
208
+ testAsync("retryOnTransient: 瞬时失败耗尽返回最后结果", async () => {
209
+ let calls = 0;
210
+ const r = await retryOnTransient(async () => {
211
+ calls++;
212
+ return makePointResult({ ok: false, kind: "timeout" });
213
+ }, { retries: 2, retryDelayMs: 0 });
214
+ assert.equal(r.ok, false);
215
+ assert.equal(r.kind, "timeout");
216
+ assert.equal(calls, 3); // 1 次初始 + 2 次重试
217
+ });
218
+ testAsync("retryOnTransient: 重试间有间隔延迟", async () => {
219
+ const t0 = Date.now();
220
+ let calls = 0;
221
+ await retryOnTransient(
222
+ async () => {
223
+ calls++;
224
+ return makePointResult({ ok: false, kind: "empty" });
225
+ },
226
+ { retries: 1, retryDelayMs: 150 },
227
+ );
228
+ assert.equal(calls, 2);
229
+ assert.ok(Date.now() - t0 >= 150, "应有重试间隔");
230
+ });
231
+
232
+ // ── targets ──
233
+ test("targets: 默认值", () => {
234
+ assert.equal(DEFAULT_TARGETS.author, "tiktok");
235
+ assert.equal(DEFAULT_TARGETS.searchQuery, "dog");
236
+ assert.equal(DEFAULT_TARGETS.video, null);
237
+ });
238
+ test("targets: resolveTargets 覆盖 + 保留默认", () => {
239
+ const t = resolveTargets({ author: "foo", video: "x" });
240
+ assert.equal(t.author, "foo");
241
+ assert.equal(t.searchQuery, "dog");
242
+ assert.equal(t.video, "x");
243
+ });
244
+ test("targets: resolveTargets 空覆盖 → 全默认", () => {
245
+ const t = resolveTargets();
246
+ assert.deepEqual(t, DEFAULT_TARGETS);
247
+ });
248
+ test("targets: resolveTargets null → 全默认", () => {
249
+ const t = resolveTargets(null);
250
+ assert.deepEqual(t, DEFAULT_TARGETS);
251
+ });
252
+
253
+ // ── index.normalizePoints ──
254
+ test("normalizePoints: 空 → 全部功能点", () => {
255
+ assert.deepEqual(normalizePoints(), [...POINT_NAMES]);
256
+ assert.deepEqual(normalizePoints([]), [...POINT_NAMES]);
257
+ assert.deepEqual(normalizePoints(null), [...POINT_NAMES]);
258
+ });
259
+ test("normalizePoints: 全非法 → 全部功能点", () => {
260
+ assert.deepEqual(normalizePoints(["bad", "xxx"]), [...POINT_NAMES]);
261
+ });
262
+ test("normalizePoints: 过滤非法 + 去重 + 保序", () => {
263
+ assert.deepEqual(normalizePoints(["video", "video", "bad", "comment"]), [
264
+ "video",
265
+ "comment",
266
+ ]);
267
+ });
268
+ test("POINTS 注册表完整", () => {
269
+ assert.equal(POINT_NAMES.length, 5);
270
+ for (const n of POINT_NAMES) assert.equal(typeof POINTS[n], "function");
271
+ });
272
+
273
+ // ── points 注册与离线可判定分支 ──
274
+ test("points: 各模块 POINT_NAME 常量正确", () => {
275
+ assert.equal(COMMENT_POINT_NAME, "comment");
276
+ assert.equal(FOLLOW_POINT_NAME, "follow");
277
+ });
278
+ testAsync("probeComment: 无视频 → skipped(no_video)", async () => {
279
+ const r = await probeComment(null, {
280
+ target: { video: null },
281
+ videoHref: null,
282
+ });
283
+ assert.equal(r.skipped, true);
284
+ assert.equal(r.skipReason, "no_video");
285
+ assert.equal(r.ok, false);
286
+ });
287
+ testAsync("probeFollow: 未登录 → skipped(not_logged_in)", async () => {
288
+ const r = await probeFollow(null, { target: {}, loggedIn: false });
289
+ assert.equal(r.skipped, true);
290
+ assert.equal(r.skipReason, "not_logged_in");
291
+ assert.equal(r.kind, "login_required");
292
+ });
293
+
294
+ // ── runProbe 空端口 / 非法端口 ──
295
+ testAsync("runProbe: 空端口 → 空结果结构", async () => {
296
+ const r = await runProbe({ ports: [], log: () => {} });
297
+ assert.deepEqual(r.ports, []);
298
+ assert.ok(Array.isArray(r.points));
299
+ assert.equal(typeof r.checkedAt, "string");
300
+ assert.equal(typeof r.durationMs, "number");
301
+ });
302
+ testAsync("runProbe: 非法端口被过滤", async () => {
303
+ const r = await runProbe({ ports: [0, -1, NaN, "abc"], log: () => {} });
304
+ assert.deepEqual(r.ports, []);
305
+ });
306
+ // 注:真实端口连接(存在/不存在端口)属集成测试范畴(会启动/连接 Edge),见 scripts/test-probe-live.mjs
307
+
308
+ // ── 汇总(等全部测试完成) ──
309
+ await Promise.all(allTests);
310
+ console.log(`\n${passed} 通过, ${failures.length} 失败`);
311
+ if (failures.length > 0) {
312
+ for (const f of failures)
313
+ console.error(` FAIL: ${f.name}\n ${f.error.stack}`);
314
+ process.exit(1);
315
+ }
@@ -26,7 +26,7 @@ const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
26
26
  let _healthyFile = HEALTHY_FILE;
27
27
 
28
28
  export const CONFIG = {
29
- testUrl: "https://www.tiktok.com/explore",
29
+ testUrl: "https://www.tiktok.com/@tiktok",
30
30
  proxyUrl: "http://127.0.0.1:7897",
31
31
  apiPort: 9097,
32
32
  groupName: "GLOBAL",
@@ -20,9 +20,10 @@ function fmtCountries(v) {
20
20
  return list.length ? list.join(",") : "无";
21
21
  }
22
22
 
23
- // failover 检查窗口(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
23
+ // failover 冷却期(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
24
+ // 锚点 = 最近一次实际切换节点的时间(_lastSwitchAt):冷却期内探针健康不切换
24
25
  const FAILOVER_COOLDOWN_MS = 600_000;
25
- // 窗口内探针健康时的切换阈值(默认 3:前 2 次仅检查,第 3 次按业务级封禁轮换)
26
+ // 已弃用(2026-08-25 改为时间锚点方案):旧版"窗口内连报 N 次"计数,字段保留仅为兼容外部传参
26
27
  const FAILOVER_ESCAPE_AFTER = 3;
27
28
 
28
29
  // ══════════════════════════════════════════════
@@ -97,6 +98,11 @@ export class ClashDaemon {
97
98
  c._switchHistory.set(nodeName, Date.now());
98
99
  }
99
100
 
101
+ /** 记录最近一次实际切换节点的时间(failover/巡检/手动切换共用锚点,冷却期判断依据) */
102
+ _markSwitchTime(c) {
103
+ if (c) c._lastSwitchAt = Date.now();
104
+ }
105
+
100
106
  /**
101
107
  * 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
102
108
  * 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
@@ -428,6 +434,7 @@ export class ClashDaemon {
428
434
  // 全部候选不可用,恢复原节点
429
435
  await checker.switchToNode(node).catch(() => {});
430
436
  this._markSwitch(c, node);
437
+ this._markSwitchTime(c);
431
438
  await api.closeAllConnections().catch(() => {});
432
439
  this._log(
433
440
  `✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
@@ -447,6 +454,7 @@ export class ClashDaemon {
447
454
  // 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
448
455
  // 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
449
456
  await api.closeAllConnections().catch(() => {});
457
+ this._markSwitchTime(c);
450
458
  this._writeHealth(c, {
451
459
  node: target,
452
460
  healthy: h2.healthy,
@@ -585,6 +593,7 @@ export class ClashDaemon {
585
593
  });
586
594
  }
587
595
  this._markSwitch(c, cmd.node);
596
+ this._markSwitchTime(c);
588
597
  const h = checker.checkNodeHealth();
589
598
  // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
590
599
  await api.closeAllConnections().catch(() => {});
@@ -609,40 +618,36 @@ export class ClashDaemon {
609
618
  * 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
610
619
  * 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
611
620
  *
612
- * failover 三级响应(2026-08-22,替代原 45s 冷却):
621
+ * failover 处理(2026-08-25,改为时间锚点方案):
613
622
  * 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
614
- * 2. 探针不健康 → 立即切换(有依据才切,无视窗口);
615
- * 3. 探针健康 → 窗口内默认只检查不切换;同一窗口内 failover 到达
616
- * failoverEscapeAfter 次(默认 3)→ 视为业务级封禁(curl 探针感知不到
617
- * TikTok 业务风控)→ 按轮换逻辑切一次。
618
- * 实际切换(成功或恢复原节点)或触发候选初测后重置窗口与计数,给新节点干净
619
- * 观察窗口;窗口 = failoverCooldownMs(默认 10 分钟,与候选集初测周期对齐)。
623
+ * 2. 探针不健康 → 立即切换(有依据才切,无视冷却);
624
+ * 3. 探针健康(含壳页)→ 距上次实际切换(含巡检/手动/failover,见
625
+ * _lastSwitchAt)< failoverCooldownMs(默认 10 分钟)→ 仅检查不切换;
626
+ * 距上次切换 >= 冷却期 → 视为业务级封禁(curl 探针感知不到 TikTok 业务
627
+ * 风控)→ 按轮换逻辑切一次。
628
+ * 每次实际切换(成功采用候选或恢复原节点)后更新 _lastSwitchAt 锚点,保证
629
+ * 10 分钟内最多切 1 次,给新节点干净观察期。
620
630
  * 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
621
631
  */
622
632
  async _handleFailover(cmd, c) {
623
633
  try {
624
634
  const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
625
635
  const now = Date.now();
626
- const w =
627
- c._failoverWindow || (c._failoverWindow = { startAt: 0, count: 0 });
628
- if (now - w.startAt >= this.failoverCooldownMs) {
629
- w.startAt = now;
630
- w.count = 0;
631
- }
632
- w.count += 1;
633
636
 
634
637
  // 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
635
638
  const h = checker.checkNodeHealth();
636
639
  const probeOk = !!h && (h.healthy || this._isShellPage(h));
637
- if (probeOk && w.count < this.failoverEscapeAfter) {
638
- this._log(
639
- `🔄 [${c.proxyPort}] 探针健康,窗口内第 ${w.count}/${this.failoverEscapeAfter} 次 failover 仅检查不切换 [${reason}]`,
640
- );
641
- return;
642
- }
643
640
  if (probeOk) {
641
+ // 探针健康:受冷却期约束(锚点 = 最近一次实际切换节点的时间 _lastSwitchAt)
642
+ const gap = now - (c._lastSwitchAt || 0);
643
+ if (gap < this.failoverCooldownMs) {
644
+ this._log(
645
+ `🔄 [${c.proxyPort}] 探针健康,距上次切换 ${Math.round(gap / 1000)}s(<${this.failoverCooldownMs / 60000}min 冷却),仅检查不切换 [${reason}]`,
646
+ );
647
+ return;
648
+ }
644
649
  this._log(
645
- `🔄 [${c.proxyPort}] 窗口内第 ${w.count} 次 failover:探针健康但业务持续报错,按业务级封禁轮换 [${reason}]`,
650
+ `🔄 [${c.proxyPort}] 探针健康但业务持续报错,距上次切换已过冷却期,按业务级封禁轮换 [${reason}]`,
646
651
  );
647
652
  } else {
648
653
  this._log(
@@ -658,11 +663,15 @@ export class ClashDaemon {
658
663
  );
659
664
  const { nodes } = checker.loadHealthyList();
660
665
  if (!nodes.length) {
661
- this._log("🔄 候选集为空,触发 delay 初测");
662
- // 初测即处置:重置窗口,避免健康探测每轮重复触发初测
663
- w.startAt = now;
664
- w.count = 0;
665
- await this._runDelayProbeController(c);
666
+ const gap = now - (c._lastProbeAt || 0);
667
+ if (gap >= this.benchmarkMs) {
668
+ this._log("🔄 候选集为空,触发 delay 初测");
669
+ await this._runDelayProbeController(c);
670
+ } else {
671
+ this._log(
672
+ `🔄 候选集为空(初测冷却中 ${Math.round((this.benchmarkMs - gap) / 1000)}s)`,
673
+ );
674
+ }
666
675
  return;
667
676
  }
668
677
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
@@ -685,9 +694,8 @@ export class ClashDaemon {
685
694
  }
686
695
  this._log(` ↺ ${cand} 内容验证未通过,下一个`);
687
696
  }
688
- // 本次 failover 已尽力处置(成功切换或恢复原节点):重置窗口,给新节点干净观察窗口
689
- w.startAt = now;
690
- w.count = 0;
697
+ // 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
698
+ this._markSwitchTime(c);
691
699
 
692
700
  if (!target) {
693
701
  await checker.switchToNode(node).catch(() => {});
package/src/cli/attach.js CHANGED
@@ -389,7 +389,12 @@ export async function handleAttach(options) {
389
389
  MAX_CDN_COOLDOWN_SEC,
390
390
  );
391
391
  const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
392
- if (shouldSwitch) {
392
+ if (!scraper) {
393
+ // webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
394
+ attachLog(
395
+ ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
396
+ );
397
+ } else if (shouldSwitch) {
393
398
  // 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
394
399
  try {
395
400
  writeCommand({
@@ -429,18 +429,25 @@ export async function handlePrecheck(options) {
429
429
  const cdnRatio =
430
430
  candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
431
431
  if (cdnRatio > 0.3) {
432
- // 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
433
- try {
434
- writeCommand({
435
- action: "failover",
436
- reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
437
- });
438
- } catch {}
439
432
  // 切换节点后新 IP 生效需短暂等待,冷却可缩短(替代纯硬等 120s)
440
433
  const coolDownSeconds = cdnRatio > 0.8 ? 90 : 60;
441
- precheckLog(
442
- ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
443
- );
434
+ if (scraper) {
435
+ // 本地模式:用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
436
+ try {
437
+ writeCommand({
438
+ action: "failover",
439
+ reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
440
+ });
441
+ } catch {}
442
+ precheckLog(
443
+ ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
444
+ );
445
+ } else {
446
+ // webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
447
+ precheckLog(
448
+ ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
449
+ );
450
+ }
444
451
  await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
445
452
  if (scraper) {
446
453
  await recycleScraper(
@@ -103,6 +103,15 @@ function isCollectorCommand(cmdDef) {
103
103
  return COLLECTOR_KEYWORDS.some((k) => s.includes(k));
104
104
  }
105
105
 
106
+ /**
107
+ * 检测命令是否运行在 webserver 模式(不启动本地浏览器,抓取由远端集中完成)。
108
+ * 该模式下限流/网络错误发生在远端 webserver 出口,本地切换节点无法解决,
109
+ * 因此错误触发切换统计应排除此类进程。
110
+ */
111
+ function isWebserverMode(cmdDef) {
112
+ return /--webserver(?=\s|=|$)/.test(cmdDef?.command || "");
113
+ }
114
+
106
115
  /** 自动发现 daemon.js 路径 */
107
116
  function findDaemonPath() {
108
117
  // 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
@@ -242,6 +251,11 @@ export class WatchdogAgent {
242
251
  this._graceEnded = false; // 宽限期是否已结束
243
252
  // 业务日志错误统计 → 触发节点切换
244
253
  this._errorStates = new Map(); // cmdId -> {consecutive, firstErrorAt, lastErrorAt}
254
+ // 进程(重新)启动 → 重置该命令的错误计数(新进程实例从干净观察窗口开始,
255
+ // 避免把上一个进程实例的错误延续到新实例而误触发节点切换)
256
+ this.processManager.on("process-started", ({ cmdId }) => {
257
+ this._errorStates.delete(cmdId);
258
+ });
245
259
  this._errorTriggerTimer = null;
246
260
  this._errorTriggerEnabled = false; // 默认不启用(后端下发开启才生效)
247
261
  this._errorTriggerCount = ERROR_TRIGGER_COUNT;
@@ -388,6 +402,8 @@ export class WatchdogAgent {
388
402
  let failoverTriggered = false; // 本轮是否已触发全局切换
389
403
  for (const { cmdId, cmdDef, newErrors } of scanned) {
390
404
  if (!isCollectorCommand(cmdDef)) continue;
405
+ // webserver 模式:错误发生在远端出口,本地切节点无法解决,不计入错误触发统计
406
+ if (isWebserverMode(cmdDef)) continue;
391
407
  let st = this._errorStates.get(cmdId) || {
392
408
  consecutive: 0,
393
409
  firstErrorAt: 0,
@@ -377,6 +377,8 @@ export class ProcessManager extends EventEmitter {
377
377
  logs: [],
378
378
  lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
379
379
  });
380
+ // 通知上层:进程(重新)启动,可重置该命令的跨实例状态(如错误计数)
381
+ this.emit("process-started", { cmdId: id, cmdDef });
380
382
  }
381
383
 
382
384
  /**
@@ -420,7 +422,6 @@ export class ProcessManager extends EventEmitter {
420
422
  } catch {}
421
423
  }, 2000);
422
424
  }
423
-
424
425
  } catch (e) {
425
426
  console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
426
427
  }
@@ -487,7 +488,9 @@ export class ProcessManager extends EventEmitter {
487
488
  const result = [];
488
489
  for (const [cmdId, info] of this.processes) {
489
490
  if (info.stopped || !info.logs || info.logs.length === 0) continue;
490
- // 日志可能被 LLM 分析清空过,偏移需收敛到当前长度
491
+ // 日志可能被 LLM 分析清空过(_errSeen 未同步):长度小于偏移说明
492
+ // 是清空后重建的日志,重置偏移,确保新日志从头扫描不遗漏
493
+ if ((info._errSeen || 0) > info.logs.length) info._errSeen = 0;
491
494
  const seen = Math.min(info._errSeen || 0, info.logs.length);
492
495
  const newLines = info.logs.slice(seen);
493
496
  info._errSeen = info.logs.length;
@@ -515,6 +518,9 @@ export class ProcessManager extends EventEmitter {
515
518
  });
516
519
  // 清空已读取的日志,避免重复分析
517
520
  info.logs = [];
521
+ // 同步重置增量扫描偏移:否则 scanErrorLogs 会把清空后新日志
522
+ // 误判为"已扫描"(logs.length < _errSeen 时 slice 到空),造成错误漏统计
523
+ info._errSeen = 0;
518
524
  }
519
525
  return result;
520
526
  }