tt-help-cli-ycl 1.4.113 → 1.4.115
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/monitor-refresh-mini.mjs +161 -0
- package/scripts/precheck-monitor.sh +37 -0
- package/src/clash/checker.js +18 -4
- package/src/clash/daemon.js +9 -0
- package/src/cli/attach.js +57 -10
- package/src/cli/precheck.js +42 -14
- package/src/cli/refresh-mini.js +714 -0
- package/src/cli/refresh.js +28 -12
- package/src/cli/watchdog.js +5 -2
- package/src/lib/api-client.js +57 -42
- package/src/lib/api-interceptor.js +165 -49
- package/src/lib/args.js +86 -0
- package/src/lib/constants.js +22 -0
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/scraper/explore-core.js +24 -9
- package/src/watch/llm-config.js +14 -0
- package/src/watch/server.js +17 -0
- package/src/watchdog/process-manager.js +58 -1
- package/src/watchdog/reporter.js +1 -0
- package/src/watchdog/standalone-server.js +228 -0
- package/src/webserver/server.mjs +40 -7
package/src/main.js
CHANGED
|
@@ -13,6 +13,7 @@ import { handleVideoStats } from "./cli/videostats.js";
|
|
|
13
13
|
import { handleDbImport } from "./cli/db-import.js";
|
|
14
14
|
import { handleWebserver } from "./cli/webserver.js";
|
|
15
15
|
import { handleRefresh } from "./cli/refresh.js";
|
|
16
|
+
import { handleRefreshMini } from "./cli/refresh-mini.js";
|
|
16
17
|
import { handlePrecheck } from "./cli/precheck.js";
|
|
17
18
|
import {
|
|
18
19
|
handleTag,
|
|
@@ -54,6 +55,8 @@ async function main() {
|
|
|
54
55
|
return handleDbImport(parsed);
|
|
55
56
|
case "refresh":
|
|
56
57
|
return handleRefresh(parsed);
|
|
58
|
+
case "refresh-mini":
|
|
59
|
+
return handleRefreshMini(parsed);
|
|
57
60
|
case "precheck":
|
|
58
61
|
return handlePrecheck(parsed);
|
|
59
62
|
case "tag":
|
package/src/npm-main.js
CHANGED
|
@@ -9,6 +9,7 @@ import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
|
|
|
9
9
|
import { handleOpen } from "./cli/open.js";
|
|
10
10
|
import { handleComments } from "./cli/comments.js";
|
|
11
11
|
import { handleRefresh } from "./cli/refresh.js";
|
|
12
|
+
import { handleRefreshMini } from "./cli/refresh-mini.js";
|
|
12
13
|
import { handlePrecheck } from "./cli/precheck.js";
|
|
13
14
|
import { handleWebserver } from "./cli/webserver.js";
|
|
14
15
|
import {
|
|
@@ -55,6 +56,8 @@ async function main() {
|
|
|
55
56
|
return handleComments(parsed);
|
|
56
57
|
case "refresh":
|
|
57
58
|
return handleRefresh(parsed);
|
|
59
|
+
case "refresh-mini":
|
|
60
|
+
return handleRefreshMini(parsed);
|
|
58
61
|
case "precheck":
|
|
59
62
|
return handlePrecheck(parsed);
|
|
60
63
|
case "tag":
|
|
@@ -28,6 +28,7 @@ async function processExplore(page, username, options, log) {
|
|
|
28
28
|
proxyServer = null,
|
|
29
29
|
webserverUrl = null,
|
|
30
30
|
noProduct = false,
|
|
31
|
+
mini = false, // 精简模式:仅采集视频数据,跳过用户信息/国家采样/关注粉丝/商品详情
|
|
31
32
|
} = options;
|
|
32
33
|
|
|
33
34
|
const result = {
|
|
@@ -63,13 +64,17 @@ async function processExplore(page, username, options, log) {
|
|
|
63
64
|
log(` 访问 @${username} 主页...`);
|
|
64
65
|
const videoList = await collectVideos(page, username, maxVideos, log);
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
if (!mini) {
|
|
68
|
+
log(" 获取用户信息...");
|
|
69
|
+
const info = await getUserInfo(page);
|
|
70
|
+
if (info) {
|
|
71
|
+
result.userInfo = info;
|
|
72
|
+
log(
|
|
73
|
+
` 用户: ${info.nickname || username} | 粉丝: ${info.followerCount || "-"} | 视频: ${info.videoCount || "-"}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
log(" [mini] 跳过用户信息");
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
const captcha = await detectCaptcha(page);
|
|
@@ -132,8 +137,8 @@ async function processExplore(page, username, options, log) {
|
|
|
132
137
|
};
|
|
133
138
|
|
|
134
139
|
// 如果有商品链接且未禁用,通过 WebService API 抓取商品详情页(评分、销量等)
|
|
135
|
-
// --no-product
|
|
136
|
-
if (topVideo.seoUrl && options.webserverUrl && !noProduct) {
|
|
140
|
+
// --no-product / mini 模式时跳过(由独立商品程序异步补抓,避免阻塞主流程)
|
|
141
|
+
if (topVideo.seoUrl && options.webserverUrl && !noProduct && !mini) {
|
|
137
142
|
try {
|
|
138
143
|
const resp = await fetch(
|
|
139
144
|
`${options.webserverUrl}/api/tiktok/product`,
|
|
@@ -166,6 +171,16 @@ async function processExplore(page, username, options, log) {
|
|
|
166
171
|
return result;
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
// mini 模式:跳过国家采样与关注/粉丝,仅登记视频列表,大幅提速
|
|
175
|
+
if (mini) {
|
|
176
|
+
result.keepFollow = false;
|
|
177
|
+
result.hasFollowData = false;
|
|
178
|
+
result.videoList = videoArray;
|
|
179
|
+
result.processed = true;
|
|
180
|
+
log(` [mini] 仅采集视频数据,跳过国家采样与关注/粉丝`);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
// 国家采样判断
|
|
170
185
|
// explore 模式:采样 5 个,优先命中目标国家,不匹配则回退众数 → 写 locationCreated
|
|
171
186
|
// refresh 模式:采样 7 个,纯众数逻辑 → 写 confirmedLocation(二次确认)
|
package/src/watch/llm-config.js
CHANGED
|
@@ -177,6 +177,20 @@ export function getLlmConfig() {
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* 获取完整 LLM 配置(含完整 API Key)— 仅供本机内部服务使用
|
|
182
|
+
* 由 /api/llm-config/raw 端点暴露,该端点仅允许 127.0.0.1 访问
|
|
183
|
+
*/
|
|
184
|
+
export function getLlmConfigRaw() {
|
|
185
|
+
if (!_loaded) loadLlmConfig();
|
|
186
|
+
return {
|
|
187
|
+
provider: _cachedProvider,
|
|
188
|
+
model: _cachedModel,
|
|
189
|
+
url: _cachedUrl,
|
|
190
|
+
apiKey: _cachedApiKey,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
/** 获取当前 LLM URL(内部调用) */
|
|
181
195
|
export function getLlmUrl() {
|
|
182
196
|
if (!_loaded) loadLlmConfig();
|
package/src/watch/server.js
CHANGED
|
@@ -447,6 +447,23 @@ export function startWatchServer(
|
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
// ── LLM 完整配置端点(仅本机可访问,供看门狗独立服务动态拉取) ──
|
|
451
|
+
if (req.method === "GET" && routePath === "/api/llm-config/raw") {
|
|
452
|
+
const remote = req.socket.remoteAddress || "";
|
|
453
|
+
const isLocal =
|
|
454
|
+
remote === "127.0.0.1" ||
|
|
455
|
+
remote === "::1" ||
|
|
456
|
+
remote === "::ffff:127.0.0.1";
|
|
457
|
+
if (!isLocal) {
|
|
458
|
+
console.error(`[llm-raw] 拒绝非本机访问: ${remote}`);
|
|
459
|
+
sendJSON(res, 403, { error: "forbidden" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { getLlmConfigRaw } = await import("./llm-config.js");
|
|
463
|
+
sendJSON(res, 200, getLlmConfigRaw());
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
450
467
|
if (req.method === "POST" && routePath === "/api/users") {
|
|
451
468
|
try {
|
|
452
469
|
const { usernames, sources, guessedLocation } = await readBody(req);
|
|
@@ -24,6 +24,60 @@ const IS_WIN = process.platform === "win32";
|
|
|
24
24
|
const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
|
|
25
25
|
const STALE_LOG_TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟无日志输出视为假死
|
|
26
26
|
|
|
27
|
+
// === 控制台日志格式化(仅显示层增强,不影响任何功能链路) ===
|
|
28
|
+
// 目标:多进程日志交错打印时,通过时间戳 + 固定宽度彩色 [cmdId] 前缀方便人眼分流。
|
|
29
|
+
// 兼容:Windows(Windows Terminal 彩色 / 旧 conhost 自动降级纯文本)与 macOS(原生 ANSI 彩色)。
|
|
30
|
+
|
|
31
|
+
// 每进程固定一种颜色(按 cmdId 哈希取色),新增进程自动分配且不重复
|
|
32
|
+
const PROC_COLORS = [32, 36, 33, 35, 34, 92, 96, 93, 95, 94];
|
|
33
|
+
const ID_PAD_WIDTH = 10; // cmdId 前缀固定宽度,短 ID 补齐保持消息列对齐
|
|
34
|
+
|
|
35
|
+
function getProcColor(id) {
|
|
36
|
+
let sum = 0;
|
|
37
|
+
const s = String(id);
|
|
38
|
+
for (let i = 0; i < s.length; i++) sum = (sum + s.charCodeAt(i)) % 997;
|
|
39
|
+
return PROC_COLORS[sum % PROC_COLORS.length];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canUseColor() {
|
|
43
|
+
if (!process.stderr.isTTY) return false;
|
|
44
|
+
if (process.env.NO_COLOR) return false;
|
|
45
|
+
if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true")
|
|
46
|
+
return true;
|
|
47
|
+
// macOS 终端恒 ≥4;Windows Terminal ≥4;旧 conhost 未开启 VT 时为 1 → 自动降级纯文本
|
|
48
|
+
const depth =
|
|
49
|
+
typeof process.stderr.getColorDepth === "function"
|
|
50
|
+
? process.stderr.getColorDepth()
|
|
51
|
+
: 1;
|
|
52
|
+
return depth >= 4;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fmtClock(d = new Date()) {
|
|
56
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
57
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 打印子进程日志行(替代裸 console.error)。
|
|
62
|
+
* 只改显示形态,msg 内容一行不少;多行消息逐行加前缀。
|
|
63
|
+
*/
|
|
64
|
+
function printProcessLine(id, msg) {
|
|
65
|
+
const lines = String(msg).split(/\r?\n/);
|
|
66
|
+
const prefixId = `[${String(id).padEnd(ID_PAD_WIDTH)}]`;
|
|
67
|
+
const color = getProcColor(id);
|
|
68
|
+
const useColor = canUseColor();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const clock = fmtClock();
|
|
71
|
+
if (useColor) {
|
|
72
|
+
process.stderr.write(
|
|
73
|
+
`\x1b[90m${clock}\x1b[0m \x1b[${color}m${prefixId}\x1b[0m ${line}\n`,
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
process.stderr.write(`${clock} ${prefixId} ${line}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
27
81
|
// 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
|
|
28
82
|
function extractCdpPorts(cmdLine) {
|
|
29
83
|
const match = cmdLine.match(/--base-port\s+(\d+)/);
|
|
@@ -219,7 +273,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
219
273
|
const msg = data.toString().trim();
|
|
220
274
|
if (msg) {
|
|
221
275
|
const short = msg.substring(0, 200);
|
|
222
|
-
|
|
276
|
+
// 仅显示层格式化:时间戳 + 彩色固定宽度前缀,内容不变。
|
|
277
|
+
// 前缀优先用命令名(如 explore-F1),未命名时退回 cmdId。
|
|
278
|
+
const displayId = (cmdDef && cmdDef.name) || id;
|
|
279
|
+
printProcessLine(displayId, short);
|
|
223
280
|
// 环形缓冲:保留最近 N 行供 LLM 分析
|
|
224
281
|
const info = this.processes.get(id);
|
|
225
282
|
if (info) {
|
package/src/watchdog/reporter.js
CHANGED
|
@@ -39,6 +39,7 @@ export function getClientConfig() {
|
|
|
39
39
|
const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
40
40
|
return {
|
|
41
41
|
server: cfg.server || "",
|
|
42
|
+
watchdogServer: cfg.watchdogServer || "",
|
|
42
43
|
proxy: cfg.proxy || "",
|
|
43
44
|
browser: cfg.browser || "",
|
|
44
45
|
userId: cfg.userId || "",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 看门狗独立服务 — 与 watch 完全隔离的独立进程
|
|
3
|
+
*
|
|
4
|
+
* 复用 src/watch/watchdog-server.js 的全部看门狗逻辑
|
|
5
|
+
* (内存状态 + ~/.tt-help-watchdog.json 持久化),
|
|
6
|
+
* 不接触 watch 的 SQLite 数据库、不依赖 watch 的 http 服务。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node src/watchdog/standalone-server.js [--port 17302]
|
|
10
|
+
* WATCHDOG_PORT=17302 node src/watchdog/standalone-server.js
|
|
11
|
+
*
|
|
12
|
+
* LLM 配置(可选,仅影响 POST /api/watchdog/analyze-logs):
|
|
13
|
+
* 环境变量 LLM_URL / LLM_MODEL / APIKEY,缺省时用 llm-config 内置默认值。
|
|
14
|
+
* 本进程永不打开 SQLite(getDb() 恒为 null),LLM 配置自动走环境变量兜底。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from "http";
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
createReadStream,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "fs";
|
|
24
|
+
import { extname, join, normalize, dirname, sep } from "path";
|
|
25
|
+
import { fileURLToPath } from "url";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import {
|
|
28
|
+
registerWatchdogRoutes,
|
|
29
|
+
tryHandleWatchdogRoute,
|
|
30
|
+
} from "../watch/watchdog-server.js";
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const PUBLIC_DIR = join(__dirname, "..", "watch", "public");
|
|
34
|
+
|
|
35
|
+
// ── 端口解析:--port 参数 > WATCHDOG_PORT 环境变量 > 默认 17302 ──
|
|
36
|
+
function resolvePort() {
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
const idx = argv.indexOf("--port");
|
|
39
|
+
if (idx !== -1 && argv[idx + 1]) {
|
|
40
|
+
const p = parseInt(argv[idx + 1], 10);
|
|
41
|
+
if (p > 0 && p < 65536) return p;
|
|
42
|
+
}
|
|
43
|
+
const env = parseInt(process.env.WATCHDOG_PORT, 10);
|
|
44
|
+
if (env > 0 && env < 65536) return env;
|
|
45
|
+
return 17302;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── watch 端口(本机 127.0.0.1):--watch-port 参数 > WATCHDOG_WATCH_PORT 环境变量 > 默认 3011 ──
|
|
49
|
+
function resolveWatchPort() {
|
|
50
|
+
const argv = process.argv.slice(2);
|
|
51
|
+
const idx = argv.indexOf("--watch-port");
|
|
52
|
+
if (idx !== -1 && argv[idx + 1]) {
|
|
53
|
+
const p = parseInt(argv[idx + 1], 10);
|
|
54
|
+
if (p > 0 && p < 65536) return p;
|
|
55
|
+
}
|
|
56
|
+
const env = parseInt(process.env.WATCHDOG_WATCH_PORT, 10);
|
|
57
|
+
if (env > 0 && env < 65536) return env;
|
|
58
|
+
return 3011;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// LLM 配置本地缓存(“以前的”配置,watch 不可达时兜底)
|
|
62
|
+
const LLM_CACHE_PATH = join(homedir(), ".tt-help-watchdog-llm.json");
|
|
63
|
+
const WATCH_PORT = resolveWatchPort();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 动态刷新 LLM 配置:
|
|
67
|
+
* 1. 从 watch 的受限端点 /api/llm-config/raw(仅 127.0.0.1)拉取完整配置
|
|
68
|
+
* 2. 拉取成功 → 注入环境变量 + 更新本地缓存
|
|
69
|
+
* 3. 拉取失败 → 读本地缓存(“以前的”)注入
|
|
70
|
+
* 4. reloadLlmConfig() 强制 llm-config 重读 env(绕开其 _loaded 缓存)
|
|
71
|
+
*/
|
|
72
|
+
async function refreshLlmConfig() {
|
|
73
|
+
let cfg = null;
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetch(
|
|
76
|
+
`http://127.0.0.1:${WATCH_PORT}/api/llm-config/raw`,
|
|
77
|
+
{ signal: AbortSignal.timeout(3000) },
|
|
78
|
+
);
|
|
79
|
+
if (resp.ok) {
|
|
80
|
+
cfg = await resp.json();
|
|
81
|
+
if (cfg && cfg.apiKey) {
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(LLM_CACHE_PATH, JSON.stringify(cfg, null, 2), "utf-8");
|
|
84
|
+
} catch {}
|
|
85
|
+
console.error(
|
|
86
|
+
`[watchdog-standalone] LLM 配置已从 watch 刷新并更新本地缓存 (model=${cfg.model})`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
|
|
92
|
+
if (!cfg || !cfg.apiKey) {
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(LLM_CACHE_PATH)) {
|
|
95
|
+
cfg = JSON.parse(readFileSync(LLM_CACHE_PATH, "utf-8"));
|
|
96
|
+
if (cfg && cfg.apiKey) {
|
|
97
|
+
console.error(
|
|
98
|
+
"[watchdog-standalone] watch LLM 端点不可达,使用本地缓存配置",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (cfg) {
|
|
106
|
+
if (cfg.apiKey) process.env.APIKEY = cfg.apiKey;
|
|
107
|
+
if (cfg.url) process.env.LLM_URL = cfg.url;
|
|
108
|
+
if (cfg.model) process.env.LLM_MODEL = cfg.model;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const { reloadLlmConfig } = await import("../watch/llm-config.js");
|
|
112
|
+
reloadLlmConfig();
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const MIME_TYPES = {
|
|
117
|
+
".html": "text/html; charset=utf-8",
|
|
118
|
+
".js": "application/javascript; charset=utf-8",
|
|
119
|
+
".css": "text/css; charset=utf-8",
|
|
120
|
+
".json": "application/json; charset=utf-8",
|
|
121
|
+
".svg": "image/svg+xml",
|
|
122
|
+
".png": "image/png",
|
|
123
|
+
".ico": "image/x-icon",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function readBody(req) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
let body = "";
|
|
129
|
+
req.on("data", (chunk) => (body += chunk));
|
|
130
|
+
req.on("end", () => {
|
|
131
|
+
try {
|
|
132
|
+
resolve(body ? JSON.parse(body) : {});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
reject(e);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sendJSON(res, code, data) {
|
|
142
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
143
|
+
res.end(JSON.stringify(data));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 静态文件(仅限 public 目录内,防路径穿越)
|
|
147
|
+
function serveStatic(res, pathname) {
|
|
148
|
+
let decoded;
|
|
149
|
+
try {
|
|
150
|
+
decoded = decodeURIComponent(pathname);
|
|
151
|
+
} catch {
|
|
152
|
+
sendJSON(res, 400, { error: "bad request" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const filePath =
|
|
156
|
+
decoded === "/"
|
|
157
|
+
? join(PUBLIC_DIR, "index.html")
|
|
158
|
+
: join(PUBLIC_DIR, decoded);
|
|
159
|
+
const normalized = normalize(filePath);
|
|
160
|
+
const base = normalize(PUBLIC_DIR);
|
|
161
|
+
if (normalized !== base && !normalized.startsWith(base + sep)) {
|
|
162
|
+
sendJSON(res, 403, { error: "forbidden" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!existsSync(normalized)) {
|
|
166
|
+
sendJSON(res, 404, { error: "not found" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const ext = extname(normalized).toLowerCase();
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
|
|
172
|
+
});
|
|
173
|
+
createReadStream(normalized).pipe(res);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const server = http.createServer(async (req, res) => {
|
|
177
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
178
|
+
const routePath = url.pathname;
|
|
179
|
+
const params = Object.fromEntries(url.searchParams.entries());
|
|
180
|
+
|
|
181
|
+
// analyze-logs 前动态刷新 LLM 配置(watch 换 key 无需重启本服务)
|
|
182
|
+
if (req.method === "POST" && routePath === "/api/watchdog/analyze-logs") {
|
|
183
|
+
await refreshLlmConfig();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 看门狗 API:复用 watchdog-server.js 的完整路由
|
|
187
|
+
if (routePath.startsWith("/api/watchdog/")) {
|
|
188
|
+
try {
|
|
189
|
+
let body = {};
|
|
190
|
+
if (req.method === "POST") body = await readBody(req);
|
|
191
|
+
const handled = await tryHandleWatchdogRoute(
|
|
192
|
+
routePath,
|
|
193
|
+
req.method,
|
|
194
|
+
params,
|
|
195
|
+
body,
|
|
196
|
+
res,
|
|
197
|
+
);
|
|
198
|
+
if (handled) return;
|
|
199
|
+
sendJSON(res, 404, { error: "watchdog route not found" });
|
|
200
|
+
return;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
sendJSON(res, 400, { error: e.message });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 其余 GET:serve watch 的前端静态页(看门狗页签同源可用)
|
|
208
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
209
|
+
serveStatic(res, routePath);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
sendJSON(res, 404, { error: "not found" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const port = resolvePort();
|
|
217
|
+
registerWatchdogRoutes(server);
|
|
218
|
+
server.listen(port, "0.0.0.0", () => {
|
|
219
|
+
console.error(
|
|
220
|
+
`[watchdog-standalone] 看门狗独立服务已启动: http://0.0.0.0:${port}`,
|
|
221
|
+
);
|
|
222
|
+
console.error(`[watchdog-standalone] 前端页面: http://<本机IP>:${port}/`);
|
|
223
|
+
console.error(
|
|
224
|
+
`[watchdog-standalone] LLM 配置源: http://127.0.0.1:${WATCH_PORT}/api/llm-config/raw(本地缓存兜底: ${LLM_CACHE_PATH})`,
|
|
225
|
+
);
|
|
226
|
+
// 启动后异步预热 LLM 配置(不阻塞启动,失败静默)
|
|
227
|
+
refreshLlmConfig().catch(() => {});
|
|
228
|
+
});
|
package/src/webserver/server.mjs
CHANGED
|
@@ -80,6 +80,28 @@ function apiLog(msg) {
|
|
|
80
80
|
if (SCRAPER_CONFIG.enableLog) console.error(msg);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// 限频错误日志:同类错误在窗口内只打首条,其余计数汇总,避免 CDN 限流等高频错误刷屏
|
|
84
|
+
const ERROR_LOG_THROTTLE_MS = 10000;
|
|
85
|
+
let lastThrottledLogTime = 0;
|
|
86
|
+
let throttledLogCount = 0;
|
|
87
|
+
let throttledLogLabel = "";
|
|
88
|
+
function logErrorThrottled(label, msg) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
if (now - lastThrottledLogTime >= ERROR_LOG_THROTTLE_MS) {
|
|
91
|
+
if (throttledLogCount > 0) {
|
|
92
|
+
console.error(
|
|
93
|
+
`[webserver] ${throttledLogLabel} | 窗口内共 ${throttledLogCount} 条同类错误(已限频)`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
console.error(`[webserver] ${label} | ${msg}`);
|
|
97
|
+
lastThrottledLogTime = now;
|
|
98
|
+
throttledLogCount = 0;
|
|
99
|
+
throttledLogLabel = label;
|
|
100
|
+
} else {
|
|
101
|
+
throttledLogCount++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
let scraperInstance = null;
|
|
84
106
|
let scraperClosing = false;
|
|
85
107
|
let scraperInitPromise = null;
|
|
@@ -257,16 +279,19 @@ async function handleTiktokUser(req, res) {
|
|
|
257
279
|
|
|
258
280
|
if (result.error && result.statusCode) {
|
|
259
281
|
// TikTok 明确返回的状态码(209002 验证码、10202 被封、10221 不存在等):
|
|
260
|
-
// 属业务结果而非 webserver 故障,不计入连续错误、不触发 scraper
|
|
282
|
+
// 属业务结果而非 webserver 故障,不计入连续错误、不触发 scraper 重启;
|
|
283
|
+
// 返回 200 而非 503:避免 nginx 将其当作节点故障去 failover/熔断
|
|
284
|
+
//(账号/IP 级风控,切换后端无意义,只会放大对 TikTok 的访问)
|
|
261
285
|
const retryable = TIKTOK_RETRYABLE_NORMAL_CODES.has(result.statusCode);
|
|
262
286
|
apiLog(
|
|
263
287
|
`[webserver] user business | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | statusCode=${result.statusCode}${retryable ? " (可重试)" : ""}`,
|
|
264
288
|
);
|
|
265
289
|
recordRequest(true);
|
|
266
|
-
return sendJSON(res,
|
|
290
|
+
return sendJSON(res, 200, {
|
|
267
291
|
error: `TikTok statusCode=${result.statusCode}`,
|
|
268
292
|
retryable,
|
|
269
|
-
|
|
293
|
+
statusCode: result.statusCode, // 后端 data-store / attach 外层读取此字段
|
|
294
|
+
tiktokStatusCode: result.statusCode, // 兼容保留
|
|
270
295
|
});
|
|
271
296
|
}
|
|
272
297
|
if (result.error) {
|
|
@@ -560,22 +585,26 @@ export async function startWebserver(
|
|
|
560
585
|
try {
|
|
561
586
|
return await route.handler(req, res);
|
|
562
587
|
} catch (e) {
|
|
563
|
-
//
|
|
564
|
-
consecutiveErrors
|
|
588
|
+
// 错误分类:CDN 限流是环境问题(代理/IP 被 TikTok CDN 拦截),重启 scraper 无法解决,
|
|
589
|
+
// 故不累计 consecutiveErrors、不触发重启;但仍计入 recentErrors(/health 展示错误率)。
|
|
590
|
+
const isCdnBlocked = e.name === "CDNBlockedError";
|
|
591
|
+
if (!isCdnBlocked) consecutiveErrors++;
|
|
565
592
|
recordRequest(false);
|
|
566
593
|
|
|
567
594
|
// 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
|
|
568
595
|
let status = 500;
|
|
569
596
|
let retryable = false;
|
|
570
597
|
|
|
571
|
-
if (
|
|
572
|
-
// CDN 限流(Akamai Access Denied)→ 503
|
|
598
|
+
if (isCdnBlocked) {
|
|
599
|
+
// CDN 限流(Akamai Access Denied)→ 503 临时不可用(节点级,nginx 可 failover 到其他节点)
|
|
573
600
|
status = 503;
|
|
574
601
|
retryable = true;
|
|
602
|
+
logErrorThrottled("request CDN限流", e.message);
|
|
575
603
|
} else if (e.message?.includes("Server is shutting down")) {
|
|
576
604
|
// 服务正在关闭 → 503
|
|
577
605
|
status = 503;
|
|
578
606
|
retryable = true;
|
|
607
|
+
console.error(`[webserver] request 服务关闭中: ${e.message}`);
|
|
579
608
|
} else if (
|
|
580
609
|
e.message?.includes("Timeout") ||
|
|
581
610
|
e.message?.includes("timeout") ||
|
|
@@ -584,6 +613,7 @@ export async function startWebserver(
|
|
|
584
613
|
// 超时 → 504
|
|
585
614
|
status = 504;
|
|
586
615
|
retryable = true;
|
|
616
|
+
console.error(`[webserver] request 超时: ${e.message}`);
|
|
587
617
|
} else if (
|
|
588
618
|
e.message?.includes("Protocol error") ||
|
|
589
619
|
e.message?.includes("Target closed") ||
|
|
@@ -593,6 +623,9 @@ export async function startWebserver(
|
|
|
593
623
|
// 浏览器/页面崩溃 → 503
|
|
594
624
|
status = 503;
|
|
595
625
|
retryable = true;
|
|
626
|
+
console.error(`[webserver] request 浏览器异常: ${e.message}`);
|
|
627
|
+
} else {
|
|
628
|
+
console.error(`[webserver] request 未知错误: ${e.message}`);
|
|
596
629
|
}
|
|
597
630
|
|
|
598
631
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|