tt-help-cli-ycl 1.4.164 → 1.4.166
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/add-claim-fallback-index.mjs +25 -0
- package/scripts/add-claim-index.mjs +3 -5
- package/scripts/test-clash-hysteresis.mjs +131 -0
- package/scripts/test-client-error-monitor.mjs +130 -45
- package/scripts/test-webserver-error-classify.mjs +30 -0
- package/src/clash/checker.js +56 -3
- package/src/clash/daemon.js +17 -3
- package/src/clash/proxy-switch.js +5 -1
- package/src/clash/runtime.js +8 -1
- package/src/cli/tag.js +2 -0
- package/src/lib/api-client.js +16 -0
- package/src/lib/browser/cdp.js +104 -1
- package/src/lib/client-error-report.js +4 -1
- package/src/watch/data-store.js +5 -0
- package/src/watch/db-schema.js +5 -4
- package/src/watch/public/app.js +4 -0
- package/src/watch/server.js +1 -0
- package/src/watchdog/agent.js +41 -12
- package/src/watchdog/client-error-monitor.js +50 -14
- package/src/watchdog/reporter.js +42 -1
- package/src/webserver/server.mjs +100 -11
package/src/webserver/server.mjs
CHANGED
|
@@ -3,13 +3,23 @@ import os from "os";
|
|
|
3
3
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
4
4
|
import { getProductInfo } from "../lib/shop-scraper.mjs";
|
|
5
5
|
import { userId as configUserId } from "../lib/constants.js";
|
|
6
|
+
import { getWatchdogClientId } from "../lib/api-client.js";
|
|
6
7
|
import { killListeningOnPort } from "../lib/port.js";
|
|
7
8
|
import {
|
|
8
9
|
getCurrentNode,
|
|
9
10
|
listGroupNodes,
|
|
10
11
|
getProxyDelay,
|
|
12
|
+
getGroupDelay,
|
|
11
13
|
switchProxy,
|
|
14
|
+
configure as configureClashApi,
|
|
12
15
|
} from "../clash/api.js";
|
|
16
|
+
import { isUsableProxyNode } from "../clash/checker.js";
|
|
17
|
+
import {
|
|
18
|
+
proxyPortOf,
|
|
19
|
+
isDaemonAlive,
|
|
20
|
+
triggerProxySwitch,
|
|
21
|
+
} from "../clash/proxy-switch.js";
|
|
22
|
+
import { discoverAll } from "../clash/discover.js";
|
|
13
23
|
|
|
14
24
|
// ---- 工具函数 ----
|
|
15
25
|
|
|
@@ -298,7 +308,8 @@ async function restartScraper(reason) {
|
|
|
298
308
|
// ---- CDN 限流自愈(限流窗口计数 + 5 分钟结算 + 切节点 + 躺平)----
|
|
299
309
|
// 模型(用户定稿):只数"限流窗口",成功请求完全不参与计数。
|
|
300
310
|
// 每次限流(403/429)→ 计一个限流窗口 + 进入 100s 冷却(静默,请求直接 503);
|
|
301
|
-
// 5 分钟窗口内累计 2 个限流窗口 →
|
|
311
|
+
// 5 分钟窗口内累计 2 个限流窗口 → 请求切节点(汇聚到 clash-daemon:文件 IPC 强切
|
|
312
|
+
// 指令,候选集/白名单/同订阅互斥由 daemon 统一裁决;daemon 未运行时回退直连切换);
|
|
302
313
|
// 窗口到期(距第一个限流窗口超过 5 分钟)时结算:限流窗口 ≥2 → 切换并重新计数;<2 → 重新计数。
|
|
303
314
|
// 切节点成功 → 重新计数;连切 CDN_MAX_SWITCHES 个节点仍限流 → 躺平(持续 503 靠 nginx failover)。
|
|
304
315
|
let cdnCooling = false; // 当前是否处于冷却静默期
|
|
@@ -313,6 +324,7 @@ const CDN_SWITCH_COOLDOWN_COUNT = 2; // 5 分钟窗口内限流窗口数阈值
|
|
|
313
324
|
const CDN_SWITCH_WINDOW_MS = 5 * 60 * 1000; // 统计窗口:5 分钟
|
|
314
325
|
const CDN_MAX_SWITCHES = 3; // 连续切换节点次数上限,超过躺平
|
|
315
326
|
const CLASH_GROUP_NAME = "GLOBAL"; // Clash 选择器组名(与 clash/checker.js CONFIG 一致)
|
|
327
|
+
let cdnSwitchChannelPort = 0; // 切节点 IPC 通道 = webserver 代理端口(0=默认通道);startWebserver 注入
|
|
316
328
|
|
|
317
329
|
/**
|
|
318
330
|
* 5 分钟窗口结算(纯函数,便于单测):
|
|
@@ -360,34 +372,76 @@ function shouldFailFast() {
|
|
|
360
372
|
}
|
|
361
373
|
|
|
362
374
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
375
|
+
* 直连切换 Clash 组到第一个 delay 探测通过的候选节点。
|
|
376
|
+
* 回退路径:仅 daemon 未运行时使用(正常路径 switchNodeViaDaemon 汇聚到 daemon)。
|
|
365
377
|
*/
|
|
366
378
|
export async function switchClashNode(reason) {
|
|
367
379
|
const current = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
|
|
368
380
|
const nodes = await listGroupNodes(CLASH_GROUP_NAME);
|
|
369
|
-
|
|
381
|
+
// 过滤机场信息节点(剩余流量/套餐到期等,非真实出口)
|
|
382
|
+
const candidates = nodes.filter((n) => n !== current && isUsableProxyNode(n));
|
|
370
383
|
if (!candidates.length) {
|
|
371
384
|
throw new Error(
|
|
372
385
|
`切节点失败:组 ${CLASH_GROUP_NAME} 无候选节点 (当前 ${current})`,
|
|
373
386
|
);
|
|
374
387
|
}
|
|
388
|
+
const probeUrl = "https://www.tiktok.com";
|
|
389
|
+
// 批量测速优先(/group/{G}/delay 含 provider 节点):provider 模式下订阅节点
|
|
390
|
+
// 不在 /proxies 表内,逐个 /proxies/{name}/delay 必 404 → 全部误判 -1 秒失败
|
|
391
|
+
// (2026-09-28 生产实锤:44 个候选全 -1 → 误躺平);批量不可用再回退逐个探测
|
|
392
|
+
let delayMap = await getGroupDelay(CLASH_GROUP_NAME, probeUrl, 5000);
|
|
393
|
+
if (
|
|
394
|
+
!delayMap ||
|
|
395
|
+
(Object.keys(delayMap).length > 0 &&
|
|
396
|
+
!candidates.some((c) => typeof delayMap[c] === "number"))
|
|
397
|
+
) {
|
|
398
|
+
// 批量端点不可用或响应形状不符(不覆盖任何候选,可能是旧内核/中间层)→ 回退
|
|
399
|
+
console.error(
|
|
400
|
+
"[webserver] 批量测速不可用,回退逐个探测(provider 节点将不可见)",
|
|
401
|
+
);
|
|
402
|
+
delayMap = {};
|
|
403
|
+
for (const cand of candidates) {
|
|
404
|
+
delayMap[cand] = (await getProxyDelay(cand, probeUrl, 5000)).delay;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
375
407
|
for (const cand of candidates) {
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
408
|
+
const delay = delayMap[cand];
|
|
409
|
+
if (typeof delay === "number" && delay > 0) {
|
|
378
410
|
await switchProxy(CLASH_GROUP_NAME, cand);
|
|
379
411
|
console.error(
|
|
380
|
-
`[webserver] 已切换 Clash 节点: ${current} → ${cand} (delay=${
|
|
412
|
+
`[webserver] 已切换 Clash 节点: ${current} → ${cand} (delay=${delay}ms) [${reason}]`,
|
|
381
413
|
);
|
|
382
414
|
return cand;
|
|
383
415
|
}
|
|
384
416
|
console.error(
|
|
385
|
-
`[webserver] 候选节点探测不通,跳过: ${cand} (delay=${
|
|
417
|
+
`[webserver] 候选节点探测不通,跳过: ${cand} (delay=${delay ?? -1}ms)`,
|
|
386
418
|
);
|
|
387
419
|
}
|
|
388
420
|
throw new Error(`切节点失败:${candidates.length} 个候选均探测不通`);
|
|
389
421
|
}
|
|
390
422
|
|
|
423
|
+
/**
|
|
424
|
+
* 通过 daemon 切换节点(首选路径):文件 IPC 发 failover 强切指令(force=true——
|
|
425
|
+
* webserver 自身已完成节流:100s 冷却 + 5min 2 窗口 + 3 次上限,触发时必须真切),
|
|
426
|
+
* 等待 daemon(10s 轮询 + 探测/候选验证)完成后核对 GLOBAL 当前节点。
|
|
427
|
+
* 节点选择/白名单守卫/同订阅互斥全部由 daemon 统一裁决,webserver 不直接操作控制器。
|
|
428
|
+
* @returns {Promise<string|undefined|null>}
|
|
429
|
+
* 新节点名;undefined = daemon 未运行(调用方回退直连);null = 命令已发但节点未变化
|
|
430
|
+
*/
|
|
431
|
+
async function switchNodeViaDaemon(reason) {
|
|
432
|
+
if (!isDaemonAlive(cdnSwitchChannelPort)) return undefined;
|
|
433
|
+
const before = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
|
|
434
|
+
console.error(
|
|
435
|
+
`[webserver] 节点切换汇聚到 daemon (通道 ${cdnSwitchChannelPort || "默认"},当前 ${before}) [${reason}]`,
|
|
436
|
+
);
|
|
437
|
+
// daemon 10s 轮询命令 + 逐候选内容验证,给足等待
|
|
438
|
+
await triggerProxySwitch(reason, cdnSwitchChannelPort, 45_000, {
|
|
439
|
+
force: true,
|
|
440
|
+
});
|
|
441
|
+
const after = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
|
|
442
|
+
return after && after !== before ? after : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
391
445
|
// 触发切换 Clash 节点(防重入;连切上限;成功重置计数)
|
|
392
446
|
function startCdnSwitch() {
|
|
393
447
|
if (cdnSwitching || cdnGiveUp) return;
|
|
@@ -403,9 +457,20 @@ function startCdnSwitch() {
|
|
|
403
457
|
);
|
|
404
458
|
cdnSwitching = true;
|
|
405
459
|
cdnSwitchCount++;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
460
|
+
const switchReason = `5 分钟内累计 ${cdnCooldownCount} 个完整限流窗口,触发第 ${cdnSwitchCount} 次切节点`;
|
|
461
|
+
switchNodeViaDaemon(switchReason)
|
|
462
|
+
.then(async (node) => {
|
|
463
|
+
if (node === undefined) {
|
|
464
|
+
console.error("[webserver] daemon 未运行,回退直连切换 Clash 节点");
|
|
465
|
+
return switchClashNode(switchReason);
|
|
466
|
+
}
|
|
467
|
+
if (node === null) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
"daemon 已收到切换命令但节点未变化(候选集为空或候选均验证失败)",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return node;
|
|
473
|
+
})
|
|
409
474
|
.then((node) => {
|
|
410
475
|
// 切换成功:重新计数(窗口起点与限流窗口数都清零),新节点重新累计
|
|
411
476
|
cdnCooldownCount = 0;
|
|
@@ -742,6 +807,7 @@ async function notifyCaptchaToWatch(url, maxRetries = 3) {
|
|
|
742
807
|
stage: "product_info",
|
|
743
808
|
errorStack: url,
|
|
744
809
|
hostname: os.hostname(),
|
|
810
|
+
clientId: getWatchdogClientId(), // 看门狗按 clientId 关联本机异常
|
|
745
811
|
}),
|
|
746
812
|
});
|
|
747
813
|
console.error("[webserver] Captcha alert sent to watch server");
|
|
@@ -848,6 +914,29 @@ export async function startWebserver(
|
|
|
848
914
|
if (proxyServer) {
|
|
849
915
|
SCRAPER_CONFIG.proxyServer = proxyServer;
|
|
850
916
|
}
|
|
917
|
+
// 节点切换通道与控制器对齐(多实例各归各:代理 7897→控制器 9097,7898→9098)。
|
|
918
|
+
// 不对齐的话,第二个 webserver 实例的直连切换会打到默认 9097,切错控制器。
|
|
919
|
+
cdnSwitchChannelPort = proxyPortOf(SCRAPER_CONFIG.proxyServer);
|
|
920
|
+
try {
|
|
921
|
+
const controllers = await discoverAll();
|
|
922
|
+
const mine =
|
|
923
|
+
controllers.find((c) => c.proxyPort === cdnSwitchChannelPort) ||
|
|
924
|
+
(cdnSwitchChannelPort === 0 ? controllers[0] : null);
|
|
925
|
+
if (mine) {
|
|
926
|
+
configureClashApi({ port: mine.port, secret: mine.secret });
|
|
927
|
+
console.error(
|
|
928
|
+
`[webserver] Clash 控制器对齐: 代理 ${cdnSwitchChannelPort || "默认"} → api ${mine.port} (${mine.source})`,
|
|
929
|
+
);
|
|
930
|
+
} else {
|
|
931
|
+
console.error(
|
|
932
|
+
`[webserver] 未发现代理 ${cdnSwitchChannelPort} 对应的控制器,节点切换沿用默认端口`,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
} catch (e) {
|
|
936
|
+
console.error(
|
|
937
|
+
`[webserver] Clash 控制器发现失败,节点切换沿用默认端口: ${e.message}`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
851
940
|
if (captchaNotifyUrl) {
|
|
852
941
|
SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
|
|
853
942
|
}
|