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 +1 -1
- package/scripts/test-webserver-error-classify.mjs +379 -0
- package/src/clash/checker.js +14 -2
- package/src/clash/daemon.js +7 -1
- package/src/cli/comments.js +40 -125
- package/src/cli/explore.js +24 -0
- package/src/cli/tag.js +261 -210
- package/src/cli/webserver.js +6 -0
- package/src/lib/api-interceptor-comment.js +14 -2
- package/src/lib/args.js +24 -0
- package/src/lib/browser/page.js +34 -6
- package/src/lib/browser-memory.js +95 -0
- package/src/lib/client-error-report.js +82 -0
- package/src/lib/constants.js +4 -0
- package/src/lib/parse-ssr.mjs +13 -1
- package/src/lib/port.js +72 -0
- package/src/lib/tiktok-scraper.mjs +104 -4
- package/src/watch/public/app.js +67 -23
- package/src/watch/public/style.css +18 -0
- package/src/webserver/server.mjs +354 -39
package/src/lib/browser/page.js
CHANGED
|
@@ -228,14 +228,42 @@ export async function isLoggedInByDom(page) {
|
|
|
228
228
|
|
|
229
229
|
export async function closeCommentPanel(page) {
|
|
230
230
|
await page.evaluate(() => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
}
|
package/src/lib/constants.js
CHANGED
|
@@ -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",
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -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
|
package/src/lib/port.js
ADDED
|
@@ -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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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");
|