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.
@@ -228,14 +228,42 @@ export async function isLoggedInByDom(page) {
228
228
 
229
229
  export async function closeCommentPanel(page) {
230
230
  await page.evaluate(() => {
231
- const rightPanel = document.querySelector('[class*="RightPanelContainer"]');
232
- if (rightPanel) {
233
- const tabContainer = rightPanel.querySelector('[class*="TabContainer"]');
234
- if (tabContainer) {
235
- const closeOverlay = tabContainer.querySelector("div:last-child");
236
- if (closeOverlay) closeOverlay.click();
231
+ // 覆盖新版短视频面板容器(DivVideoDetailContainer)与旧版容器
232
+ const panels = document.querySelectorAll(
233
+ '[class*="RightPanelContainer"], [class*="VideoDetailContainer"]',
234
+ );
235
+ if (panels.length === 0) return;
236
+
237
+ // 明确的关闭按钮选择器(按优先级)——绝不点击任何 tab 子元素,避免误开"商品"等 tab
238
+ const closeSelectors = [
239
+ '[data-e2e="comment-close"]',
240
+ '[data-e2e="close"]',
241
+ '[data-e2e="modal-close"]',
242
+ '[class*="CloseButton"]',
243
+ '[class*="close-button"]',
244
+ ];
245
+
246
+ for (const panel of panels) {
247
+ for (const sel of closeSelectors) {
248
+ const btn = panel.querySelector(sel);
249
+ if (btn && btn.offsetParent !== null) {
250
+ btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
251
+ btn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
252
+ btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
253
+ return;
254
+ }
237
255
  }
238
256
  }
257
+
258
+ // 无明确关闭按钮时:只点面板外的遮罩层,避免误点 tab
259
+ const mask = document.querySelector(
260
+ '[class*="DivModalMask"], [class*="modal-mask"], [class*="ModalMask"]',
261
+ );
262
+ if (mask && mask.offsetParent !== null) {
263
+ mask.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
264
+ mask.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
265
+ mask.dispatchEvent(new MouseEvent("click", { bubbles: true }));
266
+ }
239
267
  });
240
268
  }
241
269
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 浏览器 / Node 内存统计 helper(tag / comments 等业务命令共享)。
3
+ * 跨平台:macOS / Linux 用 ps RSS,Windows 用 PowerShell 查 msedge 工作集。
4
+ */
5
+ import { writeFileSync, unlinkSync } from "fs";
6
+ import { execSync } from "child_process";
7
+ import os from "os";
8
+ import path from "path";
9
+
10
+ export function formatMemoryUsage(mem = process.memoryUsage()) {
11
+ const mb = (n) => (n / 1024 / 1024).toFixed(0);
12
+ return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
13
+ }
14
+
15
+ /**
16
+ * 获取 Edge 浏览器进程的内存占用(跨平台:macOS/Linux/Windows)。
17
+ * @param {number|null} [port] - 按 CDP 端口筛选;不传则统计全部 msedge 进程
18
+ * @returns {number|null} 内存 MB,获取失败返回 null
19
+ */
20
+ export function getBrowserMemory(port = null) {
21
+ try {
22
+ const platform = os.platform();
23
+ let cmd;
24
+ if (platform === "darwin") {
25
+ if (port) {
26
+ cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
27
+ } else {
28
+ cmd =
29
+ 'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
30
+ }
31
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
32
+ const mb = parseInt(result, 10);
33
+ return mb > 0 ? mb : null;
34
+ } else if (platform === "linux") {
35
+ if (port) {
36
+ cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
37
+ } else {
38
+ cmd =
39
+ 'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
40
+ }
41
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
42
+ const mb = parseInt(result, 10);
43
+ return mb > 0 ? mb : null;
44
+ } else if (platform === "win32") {
45
+ // 用临时 .ps1 文件避免命令行转义问题
46
+ const ps1Path = path.join(os.tmpdir(), `edge-mem-${Date.now()}.ps1`);
47
+ try {
48
+ let ps1Content;
49
+ if (port) {
50
+ ps1Content =
51
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
52
+ `$sum = 0; ` +
53
+ `foreach ($p in $procs) { ` +
54
+ ` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
55
+ ` $sum += $p.WorkingSetSize ` +
56
+ ` } ` +
57
+ `}; ` +
58
+ `Write-Output $sum`;
59
+ } else {
60
+ // 无端口场景:Get-Process(.NET API,不依赖 WMI)统计全部 msedge 工作集
61
+ ps1Content =
62
+ `$procs = Get-Process msedge -ErrorAction SilentlyContinue; ` +
63
+ `$sum = 0; ` +
64
+ `foreach ($p in $procs) { $sum += $p.WorkingSet64 }; ` +
65
+ `Write-Output $sum`;
66
+ }
67
+ writeFileSync(ps1Path, ps1Content);
68
+ cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`;
69
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
70
+ try {
71
+ unlinkSync(ps1Path);
72
+ } catch {}
73
+ const bytes = parseInt(result, 10);
74
+ const mb = bytes > 0 ? Math.round(bytes / 1048576) : 0;
75
+ return mb > 0 ? mb : null;
76
+ } catch {
77
+ try {
78
+ unlinkSync(ps1Path);
79
+ } catch {}
80
+ return null;
81
+ }
82
+ }
83
+ return null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function formatMemoryWithBrowser(port = null) {
90
+ const nodeMem = formatMemoryUsage();
91
+ const edgeMB = getBrowserMemory(port);
92
+ if (!edgeMB) return nodeMem;
93
+ const portLabel = port ? `:${port}` : "";
94
+ return `${nodeMem} edge${portLabel}:${edgeMB}MB`;
95
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 客户端错误上报 + Clash daemon 故障切换 的共享 helper。
3
+ * 供 tag / comments 等业务命令复用,避免重复实现。
4
+ */
5
+
6
+ /**
7
+ * 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
8
+ * 复用 ../clash/ipc.js 现成的 writeCommand 接口,不改动 daemon/ipc 模块。
9
+ * @param {string} reason - failover 原因(会写入 daemon 日志)
10
+ * @param {(s: string) => void} [log] - 日志函数,默认 console.error
11
+ */
12
+ export async function triggerClashFailover(reason, log = console.error) {
13
+ try {
14
+ const ipc = await import("../clash/ipc.js");
15
+ ipc.writeCommand({ action: "failover", reason });
16
+ log(` 🔀 已触发 daemon failover (${reason})`);
17
+ } catch (e) {
18
+ log(` ⚠️ 触发 daemon failover 失败: ${e.message}`);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 上报单条客户端错误到服务端,并触发 daemon fallback 换节点。
24
+ * @param {object} opts
25
+ * @param {(url: string, body: object) => Promise} opts.post - 发 POST 的函数(返回 promise)
26
+ * @param {string} opts.baseUrl - 服务端地址
27
+ * @param {string} opts.userId
28
+ * @param {string} opts.errorType - 错误类型(如 consecutive_task_errors / consecutive_blocked)
29
+ * @param {string} opts.message - 用作 errorMessage 与 failover reason
30
+ * @param {string} opts.stage - stage 标识(如 tag-scoring / comments)
31
+ * @param {(s: string) => void} [opts.log] - 日志函数,默认 console.error
32
+ */
33
+ export async function reportClientErrorAndFailover({
34
+ post,
35
+ baseUrl,
36
+ userId,
37
+ errorType,
38
+ message,
39
+ stage,
40
+ log = console.error,
41
+ }) {
42
+ try {
43
+ await post(`${baseUrl}/api/error-report`, {
44
+ userId,
45
+ username: "",
46
+ errorType,
47
+ errorMessage: message,
48
+ stage,
49
+ errorStack: "",
50
+ });
51
+ } catch {}
52
+ await triggerClashFailover(message, log);
53
+ }
54
+
55
+ /**
56
+ * 连续任务错误达到阈值后:上报服务端 + 触发 daemon fallback 换节点。
57
+ * @param {object} opts
58
+ * @param {(url: string, body: object) => Promise} opts.post - 发 POST 的函数(返回 promise)
59
+ * @param {string} opts.baseUrl - 服务端地址
60
+ * @param {string} opts.userId
61
+ * @param {string} opts.message - 用作 errorMessage 与 failover reason
62
+ * @param {string} opts.stage - stage 标识(如 tag-scoring / comments)
63
+ * @param {(s: string) => void} [opts.log] - 日志函数,默认 console.error
64
+ */
65
+ export async function reportConsecutiveTaskErrors({
66
+ post,
67
+ baseUrl,
68
+ userId,
69
+ message,
70
+ stage,
71
+ log = console.error,
72
+ }) {
73
+ return reportClientErrorAndFailover({
74
+ post,
75
+ baseUrl,
76
+ userId,
77
+ errorType: "consecutive_task_errors",
78
+ message,
79
+ stage,
80
+ log,
81
+ });
82
+ }
@@ -289,6 +289,9 @@ const HELP_TEXT = [
289
289
  " -p, --port <端口号> 监听端口,默认 3000",
290
290
  " --pool-size <数量> 浏览器页面池大小,默认 8",
291
291
  " --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
292
+ " --rate <请求/秒> 对 TikTok 的全局请求速率限制,默认 3(0=不限制)",
293
+ " (限速突发容量自动跟随 --pool-size,无需单独设置)",
294
+ " --cooldown <秒> CDN 限流自动冷却时长,默认 100(冷却→探活→最多2次重启→自动切 Clash 节点)",
292
295
  " --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
293
296
  " --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
294
297
  " API:",
@@ -470,6 +473,7 @@ const HELP_TEXT = [
470
473
  " tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
471
474
  " tt-help refresh --server http://127.0.0.1:3001 --port 9222",
472
475
  " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
476
+ " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897 --rate 3",
473
477
  " tt-help webserver -p 3000 --log",
474
478
  " tt-help config set server http://127.0.0.1:3001",
475
479
  " tt-help attach -p 5 -i 10",
@@ -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
 
@@ -18,6 +19,38 @@ function delay(ms) {
18
19
  return new Promise((r) => setTimeout(r, ms));
19
20
  }
20
21
 
22
+ // 令牌桶:平均 rate 个请求/秒,允许最多 capacity 个突发。
23
+ // rate <= 0 表示不限流(兼容旧行为)。用于限制对 TikTok 的全局 QPS,
24
+ // 避免 8 页并行时短时间高频访问触发 Akamai CDN 限流(Access Denied)。
25
+ class TokenBucket {
26
+ constructor({ ratePerSecond, capacity }) {
27
+ this.rate = ratePerSecond;
28
+ this.capacity = capacity;
29
+ this.tokens = capacity;
30
+ this.lastRefill = Date.now();
31
+ }
32
+ _refill() {
33
+ const now = Date.now();
34
+ const elapsed = (now - this.lastRefill) / 1000;
35
+ this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate);
36
+ this.lastRefill = now;
37
+ if (this.tokens < 0) this.tokens = 0;
38
+ }
39
+ async acquire() {
40
+ if (this.rate <= 0) return; // 不限流
41
+ while (true) {
42
+ this._refill();
43
+ if (this.tokens >= 1) {
44
+ this.tokens -= 1;
45
+ return;
46
+ }
47
+ // 距下一个令牌可用还需多少毫秒
48
+ const waitMs = Math.ceil(((1 - this.tokens) / this.rate) * 1000);
49
+ await delay(Math.max(waitMs, 40));
50
+ }
51
+ }
52
+ }
53
+
21
54
  class PageSlot {
22
55
  constructor(page) {
23
56
  this.page = page;
@@ -26,6 +59,30 @@ class PageSlot {
26
59
  }
27
60
  }
28
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
+
29
86
  class PromiseQueue {
30
87
  constructor() {
31
88
  this._queue = [];
@@ -60,21 +117,32 @@ export class TikTokScraper {
60
117
  warmUrl = DEFAULT_WARM_URL,
61
118
  maxRequestsPerPage = DEFAULT_MAX_REQUESTS_PER_PAGE,
62
119
  proxyServer = null,
120
+ requestRatePerSecond = 0, // 0 = 不限制对 TikTok 的请求速率
121
+ requestBurst = 8, // 限速器突发容量(允许一次小突发热身/单次重试)
63
122
  } = {}) {
64
123
  this.poolSize = poolSize;
65
124
  this.wafTtl = wafTtl;
66
125
  this.warmUrl = warmUrl;
67
126
  this.maxRequestsPerPage = maxRequestsPerPage;
68
127
  this.proxyServer = proxyServer;
128
+ this.rateLimiter = new TokenBucket({
129
+ ratePerSecond: requestRatePerSecond,
130
+ capacity: requestBurst,
131
+ });
69
132
  this.browser = null;
70
133
  this.context = null;
71
134
  this.slots = [];
72
135
  this.slotIdx = 0;
73
136
  this.lastWarmTime = 0;
74
137
  this.warmPromise = null;
138
+ // 关闭中标记:restart 时旧实例先 close,在途请求凭此立即失败(503),
139
+ // 而不是打到已销毁的 context 上报 "Cannot read properties of null"。
140
+ this.closing = false;
75
141
  }
76
142
 
77
143
  async init() {
144
+ // restart() = close() + init():重建后恢复可用状态
145
+ this.closing = false;
78
146
  const executablePath = detectBrowser();
79
147
  if (!executablePath) {
80
148
  throw new Error(
@@ -105,6 +173,8 @@ export class TikTokScraper {
105
173
  }
106
174
 
107
175
  async close() {
176
+ // 先置关闭标记:在途请求的入口检查会立即失败,避免与关闭流程竞态
177
+ this.closing = true;
108
178
  if (this.browser) {
109
179
  const browser = this.browser;
110
180
  let closeTimedOut = false;
@@ -175,6 +245,10 @@ export class TikTokScraper {
175
245
  }
176
246
 
177
247
  async _ensurePage(slot) {
248
+ // 实例正在关闭/已关闭(restart 竞态)→ 抛可识别错误,webserver 返回 503 且不计连续错误
249
+ if (this.closing || !this.context) {
250
+ throw new Error("Server is shutting down");
251
+ }
178
252
  try {
179
253
  if (
180
254
  !slot.page.isClosed() &&
@@ -190,12 +264,38 @@ export class TikTokScraper {
190
264
  }
191
265
 
192
266
  async _fetchViewSource(url, slot) {
267
+ // 全局限速:所有对外请求(含重试路径)统一在这里节流,
268
+ // 避免 8 页并行时短时间高频访问触发 TikTok CDN 限流。
269
+ await this.rateLimiter.acquire();
270
+
193
271
  const page = await this._ensurePage(slot);
194
272
 
195
- await page.goto("view-source:" + url, {
196
- waitUntil: "domcontentloaded",
197
- timeout: 15000,
198
- });
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
+ }
199
299
 
200
300
  const content = await page.evaluate(() => {
201
301
  const rows = document.querySelectorAll("tr");