tt-help-cli-ycl 1.4.139 → 1.4.142

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.
@@ -291,7 +291,7 @@ const HELP_TEXT = [
291
291
  " --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
292
292
  " --rate <请求/秒> 对 TikTok 的全局请求速率限制,默认 3(0=不限制)",
293
293
  " (限速突发容量自动跟随 --pool-size,无需单独设置)",
294
- " --cooldown <秒> CDN 限流自动冷却时长:首次限流后冷却,到期仍限流则单次重启,默认 180",
294
+ " --cooldown <秒> CDN 限流自动冷却时长,默认 100(冷却→探活→最多2次重启→自动切 Clash 节点)",
295
295
  " --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
296
296
  " --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
297
297
  " API:",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CDN 限流错误(Akamai Access Denied)
2
+ * CDN 限流错误(Akamai Access Denied / HTTP 非 2xx 风控)
3
3
  */
4
4
  export class CDNBlockedError extends Error {
5
5
  constructor(message = "CDN限流 (Access Denied)", reference) {
@@ -9,6 +9,18 @@ export class CDNBlockedError extends Error {
9
9
  }
10
10
  }
11
11
 
12
+ /**
13
+ * TikTok 明确 HTTP 状态码错误(goto 阶段被非 2xx 拒绝,如 404 视频/用户不存在)。
14
+ * 与 CDNBlockedError 区分:业务结果而非风控,webserver 返回 200 + statusCode。
15
+ */
16
+ export class TikTokStatusError extends Error {
17
+ constructor(statusCode, message) {
18
+ super(message || `TikTok HTTP ${statusCode}`);
19
+ this.name = "TikTokStatusError";
20
+ this.statusCode = statusCode;
21
+ }
22
+ }
23
+
12
24
  /**
13
25
  * 检测 HTML 是否为 CDN Access Denied
14
26
  * 返回 { isBlocked: true, reference: "xxx" } 或 null
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 端口占用清理:webserver 启动前检查目标端口,若被占用直接杀掉占用进程。
3
+ * Windows 走 netstat -ano + taskkill;macOS/Linux 走 lsof + SIGKILL。
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { parseNetstatListeningPids } from "./browser/cdp.js";
7
+
8
+ const KILL_WAIT_MS = 500; // 杀完等端口释放(Windows 进程退出有延迟,立即 listen 仍可能 EADDRINUSE)
9
+
10
+ function delay(ms) {
11
+ return new Promise((r) => setTimeout(r, ms));
12
+ }
13
+
14
+ /** 返回监听指定端口的 PID 列表(字符串数组) */
15
+ export function findListeningPids(port) {
16
+ if (process.platform === "win32") {
17
+ try {
18
+ const out = execSync("netstat -ano -p TCP", {
19
+ encoding: "utf-8",
20
+ timeout: 5000,
21
+ windowsHide: true,
22
+ });
23
+ return parseNetstatListeningPids(out, port);
24
+ } catch (e) {
25
+ console.error(`[port] netstat 查询失败: ${e.message}`);
26
+ return [];
27
+ }
28
+ }
29
+ // POSIX:lsof 找不到监听时退出码非 0 且无输出,属正常情况
30
+ try {
31
+ const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, {
32
+ encoding: "utf-8",
33
+ timeout: 5000,
34
+ });
35
+ return out
36
+ .split(/\r?\n/)
37
+ .map((s) => s.trim())
38
+ .filter((s) => /^\d+$/.test(s));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 杀掉监听指定端口的全部进程(排除自身 PID),供启动前清理端口占用。
46
+ * 不区分进程类型:谁占谁死(用户拍板)。
47
+ */
48
+ export async function killListeningOnPort(port) {
49
+ const pids = findListeningPids(port).filter((p) => p !== String(process.pid));
50
+ if (!pids.length) return;
51
+ console.error(
52
+ `[webserver] 端口 ${port} 被占用,直接清理进程 PID: ${pids.join(", ")}`,
53
+ );
54
+ for (const pid of pids) {
55
+ try {
56
+ if (process.platform === "win32") {
57
+ execSync(`taskkill /F /T /PID ${pid}`, {
58
+ encoding: "buffer",
59
+ timeout: 15000,
60
+ windowsHide: true,
61
+ });
62
+ } else {
63
+ process.kill(parseInt(pid, 10), "SIGKILL");
64
+ }
65
+ console.error(`[webserver] 已杀 PID ${pid}`);
66
+ } catch (e) {
67
+ console.error(`[webserver] 杀 PID ${pid} 失败: ${e.message}`);
68
+ }
69
+ }
70
+ // 等待端口释放,避免立即 listen 仍 EADDRINUSE
71
+ await delay(KILL_WAIT_MS);
72
+ }
@@ -5,6 +5,7 @@ import {
5
5
  parseVideoInfo,
6
6
  isRetryableFailure,
7
7
  CDNBlockedError,
8
+ TikTokStatusError,
8
9
  detectAccessDenied,
9
10
  } from "./parse-ssr.mjs";
10
11
 
@@ -58,6 +59,30 @@ class PageSlot {
58
59
  }
59
60
  }
60
61
 
62
+ /**
63
+ * 对 page.goto 抛出的错误做分类(goto 阶段失败时拿不到 HTML,
64
+ * detectAccessDenied 无法执行,必须在这里拦截风控类错误)。
65
+ * status 为导航期间捕获的最后一个 tiktok 响应状态码(可能为 null):
66
+ * - 404 → TikTokStatusError(视频/用户不存在,业务结果)
67
+ * - 403/429/未知 → CDNBlockedError,由 webserver 走 CDN 冷却自愈链路
68
+ * (503 + 冷却 + 躺平 + nginx failover),而不是 consecutiveErrors
69
+ * 浏览器重启链——重启对 IP/节点风控无效,只会形成死亡螺旋。
70
+ * - 其余(ERR_ABORTED / Target closed 等重启竞态产物)→ 返回 null,
71
+ * 原样冒泡交由 webserver 分类(503 retryable 且不计连续错误)。
72
+ */
73
+ export function classifyGotoError(message, status) {
74
+ if (typeof message !== "string") return null;
75
+ if (message.includes("ERR_HTTP_RESPONSE_CODE_FAILURE")) {
76
+ if (status === 404) {
77
+ return new TikTokStatusError(404, "TikTok HTTP 404 (视频/用户不存在)");
78
+ }
79
+ return new CDNBlockedError(
80
+ `goto 非2xx (疑似 IP/节点风控, status=${status ?? "unknown"}): ${message.split("\n")[0]}`,
81
+ );
82
+ }
83
+ return null;
84
+ }
85
+
61
86
  class PromiseQueue {
62
87
  constructor() {
63
88
  this._queue = [];
@@ -110,9 +135,14 @@ export class TikTokScraper {
110
135
  this.slotIdx = 0;
111
136
  this.lastWarmTime = 0;
112
137
  this.warmPromise = null;
138
+ // 关闭中标记:restart 时旧实例先 close,在途请求凭此立即失败(503),
139
+ // 而不是打到已销毁的 context 上报 "Cannot read properties of null"。
140
+ this.closing = false;
113
141
  }
114
142
 
115
143
  async init() {
144
+ // restart() = close() + init():重建后恢复可用状态
145
+ this.closing = false;
116
146
  const executablePath = detectBrowser();
117
147
  if (!executablePath) {
118
148
  throw new Error(
@@ -143,6 +173,8 @@ export class TikTokScraper {
143
173
  }
144
174
 
145
175
  async close() {
176
+ // 先置关闭标记:在途请求的入口检查会立即失败,避免与关闭流程竞态
177
+ this.closing = true;
146
178
  if (this.browser) {
147
179
  const browser = this.browser;
148
180
  let closeTimedOut = false;
@@ -213,6 +245,10 @@ export class TikTokScraper {
213
245
  }
214
246
 
215
247
  async _ensurePage(slot) {
248
+ // 实例正在关闭/已关闭(restart 竞态)→ 抛可识别错误,webserver 返回 503 且不计连续错误
249
+ if (this.closing || !this.context) {
250
+ throw new Error("Server is shutting down");
251
+ }
216
252
  try {
217
253
  if (
218
254
  !slot.page.isClosed() &&
@@ -234,10 +270,32 @@ export class TikTokScraper {
234
270
 
235
271
  const page = await this._ensurePage(slot);
236
272
 
237
- await page.goto("view-source:" + url, {
238
- waitUntil: "domcontentloaded",
239
- timeout: 15000,
240
- });
273
+ // 捕获导航期间最后一个 tiktok 响应的真实状态码:
274
+ // Chromium 的 ERR_HTTP_RESPONSE_CODE_FAILURE 不携带状态码,
275
+ // 通过 response 事件补齐,用于区分 403/429 风控与 404 不存在。
276
+ let lastStatus = null;
277
+ const onResponse = (resp) => {
278
+ try {
279
+ if (resp.url().includes("tiktok.com")) lastStatus = resp.status();
280
+ } catch {}
281
+ };
282
+ page.on("response", onResponse);
283
+
284
+ try {
285
+ await page.goto("view-source:" + url, {
286
+ waitUntil: "domcontentloaded",
287
+ timeout: 15000,
288
+ });
289
+ } catch (e) {
290
+ // goto 阶段失败(HTTP 非 2xx 等)拿不到 HTML,detectAccessDenied 没机会执行。
291
+ // 分类后抛出:风控类 → CDNBlockedError(走 webserver 冷却自愈而非浏览器重启);
292
+ // 404 → TikTokStatusError(业务结果);其余(ERR_ABORTED 等竞态)→ 原样冒泡。
293
+ const classified = classifyGotoError(e?.message, lastStatus);
294
+ if (classified) throw classified;
295
+ throw e;
296
+ } finally {
297
+ page.off("response", onResponse);
298
+ }
241
299
 
242
300
  const content = await page.evaluate(() => {
243
301
  const rows = document.querySelectorAll("tr");
@@ -2477,10 +2477,12 @@ export function createStore(filePath, options = {}) {
2477
2477
  username,
2478
2478
  stage,
2479
2479
  errorStack,
2480
+ hostname,
2480
2481
  ) {
2481
2482
  const existing = clientErrors.get(userId);
2482
2483
  if (existing) {
2483
2484
  existing.timestamp = Date.now();
2485
+ if (hostname) existing.hostname = hostname;
2484
2486
  if (errorType === "captcha") {
2485
2487
  existing.captchaCount = (existing.captchaCount || 0) + 1;
2486
2488
  if (!existing.captchaStage) existing.captchaStage = stage || "";
@@ -2498,6 +2500,7 @@ export function createStore(filePath, options = {}) {
2498
2500
  } else {
2499
2501
  clientErrors.set(userId, {
2500
2502
  userId,
2503
+ hostname: hostname || "",
2501
2504
  errorType,
2502
2505
  errorMessage: errorMessage || "",
2503
2506
  errorStack: errorStack || "",