tt-help-cli-ycl 1.4.136 → 1.4.140

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.136",
3
+ "version": "1.4.140",
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,379 @@
1
+ // 回归测试:webserver 错误分类(IP 风控 → CDN 自愈,重启竞态 → 不计连续错误)
2
+ // 运行:node scripts/test-webserver-error-classify.mjs
3
+ import assert from "node:assert/strict";
4
+ import { createServer } from "node:http";
5
+ import { spawn } from "node:child_process";
6
+ import {
7
+ TikTokScraper,
8
+ classifyGotoError,
9
+ } from "../src/lib/tiktok-scraper.mjs";
10
+ import {
11
+ classifyError,
12
+ switchClashNode,
13
+ settleCooldownWindow,
14
+ } from "../src/webserver/server.mjs";
15
+ import { CDNBlockedError, TikTokStatusError } from "../src/lib/parse-ssr.mjs";
16
+ import { configure, resetConfig } from "../src/clash/api.js";
17
+ import { parseNetstatListeningPids } from "../src/lib/browser/cdp.js";
18
+ import { findListeningPids, killListeningOnPort } from "../src/lib/port.js";
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ const checks = [];
23
+ function check(name, fn) {
24
+ checks.push({ name, fn });
25
+ }
26
+
27
+ async function runAll() {
28
+ for (const { name, fn } of checks) {
29
+ try {
30
+ await fn();
31
+ passed++;
32
+ console.error(`PASS ${name}`);
33
+ } catch (e) {
34
+ failed++;
35
+ console.error(`FAIL ${name}: ${e.message}`);
36
+ }
37
+ }
38
+ console.error(`\n结果: ${passed} 通过, ${failed} 失败`);
39
+ process.exitCode = failed > 0 ? 1 : 0;
40
+ }
41
+
42
+ // ---- classifyGotoError ----
43
+ check("goto 非2xx + status=403 → CDNBlockedError 带状态码", () => {
44
+ const err = classifyGotoError(
45
+ "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/1",
46
+ 403,
47
+ );
48
+ assert.ok(err instanceof CDNBlockedError);
49
+ assert.equal(err.name, "CDNBlockedError");
50
+ assert.match(err.message, /status=403/);
51
+ });
52
+
53
+ check("goto 非2xx + status=429 → CDNBlockedError", () => {
54
+ const err = classifyGotoError(
55
+ "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
56
+ 429,
57
+ );
58
+ assert.ok(err instanceof CDNBlockedError);
59
+ assert.match(err.message, /status=429/);
60
+ });
61
+
62
+ check("goto 非2xx + status=null → CDNBlockedError 标 unknown", () => {
63
+ const err = classifyGotoError(
64
+ "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
65
+ null,
66
+ );
67
+ assert.ok(err instanceof CDNBlockedError);
68
+ assert.match(err.message, /status=unknown/);
69
+ });
70
+
71
+ check("goto 非2xx + status=404 → TikTokStatusError(业务结果)", () => {
72
+ const err = classifyGotoError(
73
+ "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/9",
74
+ 404,
75
+ );
76
+ assert.ok(err instanceof TikTokStatusError);
77
+ assert.equal(err.statusCode, 404);
78
+ });
79
+
80
+ check("goto ERR_ABORTED → null(原样冒泡)", () => {
81
+ assert.equal(
82
+ classifyGotoError(
83
+ "page.goto: net::ERR_ABORTED at view-source:https://x",
84
+ null,
85
+ ),
86
+ null,
87
+ );
88
+ });
89
+
90
+ check("goto frame detached → null(原样冒泡)", () => {
91
+ assert.equal(
92
+ classifyGotoError("page.goto: net::ERR_ABORTED; maybe frame was detached?"),
93
+ null,
94
+ );
95
+ });
96
+
97
+ check("goto 非字符串 → null", () => {
98
+ assert.equal(classifyGotoError(undefined), null);
99
+ assert.equal(classifyGotoError(null), null);
100
+ });
101
+
102
+ // ---- classifyError ----
103
+ check("CDNBlockedError → cdn/503/retryable/不计连续错误", () => {
104
+ const c = classifyError(new CDNBlockedError("CDN限流"));
105
+ assert.deepEqual(c, {
106
+ kind: "cdn",
107
+ status: 503,
108
+ retryable: true,
109
+ countConsecutive: false,
110
+ });
111
+ });
112
+
113
+ check("goto 包装的 CDNBlockedError → cdn", () => {
114
+ const err = classifyGotoError(
115
+ "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/2",
116
+ 403,
117
+ );
118
+ const c = classifyError(err);
119
+ assert.equal(c.kind, "cdn");
120
+ assert.equal(c.status, 503);
121
+ });
122
+
123
+ check("TikTokStatusError(404) → business/200/不计连续错误", () => {
124
+ const c = classifyError(new TikTokStatusError(404, "TikTok HTTP 404"));
125
+ assert.deepEqual(c, {
126
+ kind: "business",
127
+ status: 200,
128
+ retryable: false,
129
+ countConsecutive: false,
130
+ statusCode: 404,
131
+ });
132
+ });
133
+
134
+ check("Server is shutting down → shutdown/503/不计", () => {
135
+ const c = classifyError(new Error("Server is shutting down"));
136
+ assert.deepEqual(c, {
137
+ kind: "shutdown",
138
+ status: 503,
139
+ retryable: true,
140
+ countConsecutive: false,
141
+ });
142
+ });
143
+
144
+ check("Timeout → timeout/504/计", () => {
145
+ const c = classifyError(new Error("page.goto: Timeout 15000ms exceeded"));
146
+ assert.deepEqual(c, {
147
+ kind: "timeout",
148
+ status: 504,
149
+ retryable: true,
150
+ countConsecutive: true,
151
+ });
152
+ });
153
+
154
+ check("重启竞态产物 → browser/503/不计", () => {
155
+ const cases = [
156
+ "browserContext.newPage: Target page, context or browser has been closed",
157
+ "page.goto: net::ERR_ABORTED at view-source:https://www.tiktok.com/@x/video/3",
158
+ "page.goto: net::ERR_ABORTED; maybe frame was detached?",
159
+ "Cannot read properties of null (reading 'newPage')",
160
+ "Execution context was destroyed, most likely because of a navigation.",
161
+ "Protocol error (Page.navigate): Target closed.",
162
+ ];
163
+ for (const msg of cases) {
164
+ const c = classifyError(new Error(msg));
165
+ assert.equal(c.kind, "browser", msg);
166
+ assert.equal(c.status, 503, msg);
167
+ assert.equal(c.retryable, true, msg);
168
+ assert.equal(c.countConsecutive, false, msg);
169
+ }
170
+ });
171
+
172
+ check("未知错误 → unknown/500/计", () => {
173
+ const c = classifyError(new Error("some random parse failure"));
174
+ assert.deepEqual(c, {
175
+ kind: "unknown",
176
+ status: 500,
177
+ retryable: false,
178
+ countConsecutive: true,
179
+ });
180
+ });
181
+
182
+ // ---- TikTokScraper 关闭标记(重启竞态防护)----
183
+ check("closing=true 时 _ensurePage 抛 Server is shutting down", async () => {
184
+ const s = new TikTokScraper({ poolSize: 1 });
185
+ s.closing = true;
186
+ await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
187
+ });
188
+
189
+ check("context=null 时 _ensurePage 抛 Server is shutting down", async () => {
190
+ const s = new TikTokScraper({ poolSize: 1 });
191
+ s.closing = false;
192
+ s.context = null;
193
+ await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
194
+ });
195
+
196
+ // ---- switchClashNode(mock Clash API,不碰真实 9097)----
197
+ /** 起一个 mock Clash 控制端,返回处理函数闭包里的状态 */
198
+ function startMockClash(handler) {
199
+ return new Promise((resolve) => {
200
+ const server = createServer((req, res) => {
201
+ const url = new URL(req.url, "http://127.0.0.1");
202
+ let body = "";
203
+ req.on("data", (c) => (body += c));
204
+ req.on("end", () => {
205
+ const reply = (status, data) => {
206
+ res.statusCode = status;
207
+ if (data === undefined) {
208
+ res.end();
209
+ return;
210
+ }
211
+ res.setHeader("Content-Type", "application/json");
212
+ res.end(JSON.stringify(data));
213
+ };
214
+ handler(req, url, body ? JSON.parse(body) : null, reply);
215
+ });
216
+ });
217
+ server.listen(0, "127.0.0.1", () => resolve(server));
218
+ });
219
+ }
220
+
221
+ check("switchClashNode:选第一个 delay 通过的候选并切换", async () => {
222
+ let switchedTo = null;
223
+ const server = await startMockClash((req, url, body, reply) => {
224
+ if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
225
+ reply(200, { now: "A", all: ["A", "B", "C"] });
226
+ } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
227
+ reply(200, { delay: 120, timeout: false });
228
+ } else if (req.method === "PUT" && url.pathname === "/proxies/GLOBAL") {
229
+ switchedTo = body.name;
230
+ reply(204);
231
+ } else {
232
+ reply(404, {});
233
+ }
234
+ });
235
+ const port = server.address().port;
236
+ configure({ port });
237
+ try {
238
+ const picked = await switchClashNode("测试切节点");
239
+ assert.equal(picked, "B"); // 跳过当前节点 A,选第一个探测通过的 B
240
+ assert.equal(switchedTo, "B");
241
+ } finally {
242
+ resetConfig();
243
+ await new Promise((r) => server.close(r));
244
+ }
245
+ });
246
+
247
+ check("switchClashNode:候选均探测不通 → 抛错", async () => {
248
+ const server = await startMockClash((req, url, body, reply) => {
249
+ if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
250
+ reply(200, { now: "A", all: ["A", "B"] });
251
+ } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
252
+ reply(200, { delay: -1, timeout: true });
253
+ } else {
254
+ reply(404, {});
255
+ }
256
+ });
257
+ configure({ port: server.address().port });
258
+ try {
259
+ await assert.rejects(() => switchClashNode("测试"), /候选均探测不通/);
260
+ } finally {
261
+ resetConfig();
262
+ await new Promise((r) => server.close(r));
263
+ }
264
+ });
265
+
266
+ check("switchClashNode:无候选节点(组内只有当前节点)→ 抛错", async () => {
267
+ const server = await startMockClash((req, url, body, reply) => {
268
+ if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
269
+ reply(200, { now: "A", all: ["A"] });
270
+ } else {
271
+ reply(404, {});
272
+ }
273
+ });
274
+ configure({ port: server.address().port });
275
+ try {
276
+ await assert.rejects(() => switchClashNode("测试"), /无候选节点/);
277
+ } finally {
278
+ resetConfig();
279
+ await new Promise((r) => server.close(r));
280
+ }
281
+ });
282
+
283
+ // ---- 端口占用清理 ----
284
+ check("parseNetstatListeningPids:精确端口匹配(3000 不误匹配 30001)", () => {
285
+ const out = [
286
+ " TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234",
287
+ " TCP 0.0.0.0:30001 0.0.0.0:0 LISTENING 5678",
288
+ " TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 1234",
289
+ " TCP [::]:3000 [::]:0 LISTENING 4321",
290
+ ].join("\r\n");
291
+ assert.deepEqual(parseNetstatListeningPids(out, 3000), ["1234", "4321"]);
292
+ });
293
+
294
+ check("killListeningOnPort:杀掉占用端口的子进程并可重新监听", async () => {
295
+ // 先占一个随机端口拿到端口号,再释放
296
+ const holder = createServer();
297
+ await new Promise((r) => holder.listen(0, "127.0.0.1", r));
298
+ const port = holder.address().port;
299
+ await new Promise((r) => holder.close(r));
300
+
301
+ // spawn 独立子进程占住该端口
302
+ const child = spawn(
303
+ process.execPath,
304
+ [
305
+ "-e",
306
+ `require("http").createServer((q,s)=>s.end("ok")).listen(${port},"127.0.0.1");setInterval(()=>{},1000);`,
307
+ ],
308
+ { stdio: "ignore" },
309
+ );
310
+ await new Promise((r) => setTimeout(r, 800)); // 等子进程监听
311
+
312
+ // 确认端口确实被占用
313
+ const before = findListeningPids(port);
314
+ assert.ok(
315
+ before.includes(String(child.pid)),
316
+ `预期子进程 ${child.pid} 在监听 ${port},实际: ${before.join(",")}`,
317
+ );
318
+
319
+ // 先注册 exit 监听(kill 后进程瞬间退出,后注册会丢失事件)
320
+ const exitedPromise = new Promise((resolve) => {
321
+ const t = setTimeout(() => resolve(false), 3000);
322
+ child.once("exit", () => {
323
+ clearTimeout(t);
324
+ resolve(true);
325
+ });
326
+ });
327
+
328
+ await killListeningOnPort(port);
329
+
330
+ const exited = await exitedPromise;
331
+ assert.equal(exited, true, "子进程应被杀死");
332
+
333
+ // 端口应可重新监听
334
+ const s2 = createServer();
335
+ await new Promise((r) => s2.listen(port, "127.0.0.1", r));
336
+ await new Promise((r) => s2.close(r));
337
+ });
338
+
339
+ // ---- 5 分钟窗口结算(只数限流窗口,成功请求不参与)----
340
+ const WINDOW = 5 * 60 * 1000;
341
+ check("settleCooldownWindow:无窗口 → 不结算原样返回", () => {
342
+ assert.deepEqual(settleCooldownWindow(0, 0, 1000, WINDOW, 2), {
343
+ shouldSwitch: false,
344
+ count: 0,
345
+ windowStartAt: 0,
346
+ });
347
+ });
348
+ check("settleCooldownWindow:窗口内 → 不结算原样返回", () => {
349
+ assert.deepEqual(
350
+ settleCooldownWindow(1, 1000, 1000 + WINDOW - 1, WINDOW, 2),
351
+ {
352
+ shouldSwitch: false,
353
+ count: 1,
354
+ windowStartAt: 1000,
355
+ },
356
+ );
357
+ });
358
+ check("settleCooldownWindow:超 5 分钟且限流窗口 ≥2 → 切换并重新计数", () => {
359
+ assert.deepEqual(
360
+ settleCooldownWindow(2, 1000, 1000 + WINDOW + 1, WINDOW, 2),
361
+ {
362
+ shouldSwitch: true,
363
+ count: 0,
364
+ windowStartAt: 0,
365
+ },
366
+ );
367
+ });
368
+ check("settleCooldownWindow:超 5 分钟但限流窗口 <2 → 重新计算不切换", () => {
369
+ assert.deepEqual(
370
+ settleCooldownWindow(1, 1000, 1000 + WINDOW + 1, WINDOW, 2),
371
+ {
372
+ shouldSwitch: false,
373
+ count: 0,
374
+ windowStartAt: 0,
375
+ },
376
+ );
377
+ });
378
+
379
+ await runAll();
@@ -403,11 +403,18 @@ export function loadHealthyList(country = null) {
403
403
  if (!existsSync(_healthyFile)) return _emptyList();
404
404
  const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
405
405
  const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
406
+ const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
406
407
  const filter = country || CONFIG.nodeCountryFilter;
407
408
  const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
409
+ const delays = {};
410
+ for (const n of filtered) {
411
+ const v = allDelays[n];
412
+ if (typeof v === "number" && v > 0) delays[n] = v;
413
+ }
408
414
  return {
409
415
  updated: d.updated || "",
410
416
  nodes: filtered,
417
+ delays,
411
418
  totalNodes: allNodes.length,
412
419
  excluded,
413
420
  countryFilter: filter || null,
@@ -418,11 +425,15 @@ export function loadHealthyList(country = null) {
418
425
  return _emptyList();
419
426
  }
420
427
 
421
- export function saveHealthyList(nodes) {
428
+ export function saveHealthyList(nodes, delays = {}) {
422
429
  try {
423
430
  writeFileSync(
424
431
  _healthyFile,
425
- JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
432
+ JSON.stringify(
433
+ { updated: new Date().toISOString(), nodes, delays: delays || {} },
434
+ null,
435
+ 2,
436
+ ),
426
437
  "utf-8",
427
438
  );
428
439
  } catch (e) {
@@ -434,6 +445,7 @@ function _emptyList() {
434
445
  return {
435
446
  updated: "",
436
447
  nodes: [],
448
+ delays: {},
437
449
  totalNodes: 0,
438
450
  excluded: 0,
439
451
  countryFilter: null,
@@ -552,7 +552,12 @@ export class ClashDaemon {
552
552
  country: checker.CONFIG.nodeCountryFilter,
553
553
  });
554
554
  const elapsed = ((Date.now() - start) / 1000).toFixed(0);
555
- checker.saveHealthyList(r.nodes);
555
+ const delays = {};
556
+ for (const it of r.results || []) {
557
+ if (typeof it.delay === "number" && it.delay > 0)
558
+ delays[it.node] = it.delay;
559
+ }
560
+ checker.saveHealthyList(r.nodes, delays);
556
561
  // 清理切换历史中已不在候选集的节点,防止 Map 无限增长
557
562
  if (c._switchHistory && c._switchHistory.size) {
558
563
  const keep = new Set(r.nodes);
@@ -771,6 +776,7 @@ export class ClashDaemon {
771
776
  healthyNodesCount: list.nodes.length,
772
777
  healthyNodesTotal: list.totalNodes,
773
778
  healthyNodes: list.nodes.slice(0, 50),
779
+ healthyNodeDelays: list.delays || {},
774
780
  });
775
781
  return base;
776
782
  }