tt-help-cli-ycl 1.4.114 → 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/precheck-monitor.sh +37 -0
- package/src/cli/attach.js +57 -10
- package/src/cli/precheck.js +42 -14
- package/src/webserver/server.mjs +40 -7
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 远程服务器 precheck / pending_refresh 持续监控
|
|
3
|
+
# 每 15 分钟采样一次,追加写入日志(按天一个文件)
|
|
4
|
+
# 用法: nohup ./precheck-monitor.sh [日志文件] >/dev/null 2>&1 &
|
|
5
|
+
|
|
6
|
+
LOG="${1:-/Users/yinchengliang/soft/tiktok/newdata/precheck-monitor-$(date +%Y%m%d).log}"
|
|
7
|
+
TARGETS="'CZ','GR','HU','PT','ES','PL','NL','BE','DE','FR','IT','IE','AT','US'"
|
|
8
|
+
SERVER="http://117.71.53.99:17301"
|
|
9
|
+
|
|
10
|
+
count_sql() { # $1 = 已 URL 编码的 SQL
|
|
11
|
+
curl -s --max-time 20 "$SERVER/api/db-query?sql=$1" | python3 -c '
|
|
12
|
+
import json, sys
|
|
13
|
+
try:
|
|
14
|
+
print(json.load(sys.stdin)["rows"][0]["cnt"])
|
|
15
|
+
except Exception:
|
|
16
|
+
print("ERR")
|
|
17
|
+
'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
while true; do
|
|
21
|
+
TS=$(date "+%Y-%m-%d %H:%M:%S")
|
|
22
|
+
THRESHOLD=$(( $(date +%s)000 - 172800000 ))
|
|
23
|
+
|
|
24
|
+
SQL_PC=$(python3 -c "
|
|
25
|
+
import urllib.parse
|
|
26
|
+
print(urllib.parse.quote(\"SELECT COUNT(*) AS cnt FROM jobs WHERE tt_seller=1 AND verified=0 AND (pending_refresh IS NULL OR pending_refresh=0) AND COALESCE(refresh_time, 1452000000000) < $THRESHOLD AND location_created IN ($TARGETS)\"))
|
|
27
|
+
")
|
|
28
|
+
SQL_PR=$(python3 -c "
|
|
29
|
+
import urllib.parse
|
|
30
|
+
print(urllib.parse.quote(\"SELECT COUNT(*) AS cnt FROM jobs WHERE tt_seller=1 AND verified=0 AND pending_refresh=1 AND location_created IN ($TARGETS)\"))
|
|
31
|
+
")
|
|
32
|
+
|
|
33
|
+
PC=$(count_sql "$SQL_PC")
|
|
34
|
+
PR=$(count_sql "$SQL_PR")
|
|
35
|
+
echo "$TS precheck=$PC pending_refresh=$PR" >> "$LOG"
|
|
36
|
+
sleep 900
|
|
37
|
+
done
|
package/src/cli/attach.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
2
2
|
import { CDNBlockedError } from "../lib/parse-ssr.mjs";
|
|
3
|
+
import { writeCommand } from "../clash/ipc.js";
|
|
3
4
|
import {
|
|
4
5
|
proxy as configuredProxy,
|
|
5
6
|
userId as configuredUserId,
|
|
@@ -11,6 +12,8 @@ import v8 from "node:v8";
|
|
|
11
12
|
|
|
12
13
|
const HEAP_RESTART_RATIO = 0.72;
|
|
13
14
|
const MAX_TASK_BATCHES_BEFORE_RESTART = 200;
|
|
15
|
+
const MAX_CDN_SWITCH_ATTEMPTS = 3; // 连续 CDN 限流最多切换几次节点,之后切换无效则改纯等待(账号/整体风控时切换无用)
|
|
16
|
+
const MAX_CDN_COOLDOWN_SEC = 300; // CDN 冷却上限(指数退避:60s→120s→240s→300s)
|
|
14
17
|
|
|
15
18
|
function formatNow() {
|
|
16
19
|
return new Date().toLocaleString("zh-CN", {
|
|
@@ -147,8 +150,27 @@ export async function handleAttach(options) {
|
|
|
147
150
|
body: JSON.stringify({ uniqueId }),
|
|
148
151
|
signal: AbortSignal.timeout(30000),
|
|
149
152
|
});
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
let data = null;
|
|
154
|
+
try {
|
|
155
|
+
data = await res.json();
|
|
156
|
+
} catch {}
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
// CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)
|
|
159
|
+
if (
|
|
160
|
+
data &&
|
|
161
|
+
typeof data.error === "string" &&
|
|
162
|
+
data.error.includes("CDN限流")
|
|
163
|
+
) {
|
|
164
|
+
throw new CDNBlockedError(data.error);
|
|
165
|
+
}
|
|
166
|
+
// 其余失败(瞬时 503/5xx)返回 null,由服务端后续重新排队
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
|
|
170
|
+
if (data && data.error && data.tiktokStatusCode) {
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
if (data && data.error) return null;
|
|
152
174
|
return data;
|
|
153
175
|
};
|
|
154
176
|
} else {
|
|
@@ -185,6 +207,7 @@ export async function handleAttach(options) {
|
|
|
185
207
|
let loopCount = 0;
|
|
186
208
|
let browserRestartCount = 0;
|
|
187
209
|
let taskBatchCount = 0;
|
|
210
|
+
let consecutiveCdnBlocks = 0; // 连续 CDN 限流轮数(自适应:前几次切换节点,之后长等待)
|
|
188
211
|
|
|
189
212
|
while (!shuttingDown) {
|
|
190
213
|
loopCount++;
|
|
@@ -348,23 +371,47 @@ export async function handleAttach(options) {
|
|
|
348
371
|
|
|
349
372
|
attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
|
|
350
373
|
|
|
351
|
-
// CDN 限流比例超过 30%
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
374
|
+
// CDN 限流比例超过 30% 时:自适应策略(前几次切节点,连续限流则长等待)
|
|
375
|
+
const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
|
|
376
|
+
if (cdnRatio > 0.3) {
|
|
377
|
+
consecutiveCdnBlocks++;
|
|
378
|
+
// 冷却指数退避:60s→120s→240s→…上限 300s(切换后短等,持续限流则长等窗口过去)
|
|
379
|
+
const coolDownSeconds = Math.min(
|
|
380
|
+
60 * Math.pow(2, consecutiveCdnBlocks - 1),
|
|
381
|
+
MAX_CDN_COOLDOWN_SEC,
|
|
382
|
+
);
|
|
383
|
+
const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
|
|
384
|
+
if (shouldSwitch) {
|
|
385
|
+
// 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
386
|
+
try {
|
|
387
|
+
writeCommand({
|
|
388
|
+
action: "failover",
|
|
389
|
+
reason: `attach CDN限流 连续${consecutiveCdnBlocks}次 (${(cdnRatio * 100).toFixed(0)}%)`,
|
|
390
|
+
});
|
|
391
|
+
} catch {}
|
|
392
|
+
attachLog(
|
|
393
|
+
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),第${consecutiveCdnBlocks}次连续限流,已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
// 连续多次切换仍限流 → 大概率账号/整体风控,切换无用,纯等待窗口过去
|
|
356
397
|
attachLog(
|
|
357
|
-
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length})
|
|
398
|
+
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),已连续 ${consecutiveCdnBlocks} 次切换仍限流(可能账号/整体风控),停止切换,长冷却 ${coolDownSeconds} 秒等待窗口过去...`,
|
|
358
399
|
);
|
|
359
|
-
|
|
400
|
+
}
|
|
401
|
+
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
402
|
+
if (scraper) {
|
|
360
403
|
await recycleScraper(
|
|
361
404
|
scraper,
|
|
362
405
|
`CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
|
|
363
406
|
);
|
|
364
407
|
browserRestartCount++;
|
|
365
|
-
taskBatchCount = 0;
|
|
366
408
|
}
|
|
409
|
+
taskBatchCount = 0;
|
|
410
|
+
} else {
|
|
411
|
+
consecutiveCdnBlocks = 0; // 正常批次重置连续限流计数
|
|
412
|
+
}
|
|
367
413
|
|
|
414
|
+
if (scraper) {
|
|
368
415
|
const heap = getHeapUsage();
|
|
369
416
|
if (heap.ratio >= HEAP_RESTART_RATIO) {
|
|
370
417
|
await recycleScraper(
|
package/src/cli/precheck.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
2
2
|
import { CDNBlockedError } from "../lib/parse-ssr.mjs";
|
|
3
|
+
import { writeCommand } from "../clash/ipc.js";
|
|
3
4
|
import {
|
|
4
5
|
proxy as configuredProxy,
|
|
5
6
|
userId as configuredUserId,
|
|
@@ -163,8 +164,27 @@ export async function handlePrecheck(options) {
|
|
|
163
164
|
body: JSON.stringify({ uniqueId }),
|
|
164
165
|
signal: AbortSignal.timeout(30000),
|
|
165
166
|
});
|
|
166
|
-
|
|
167
|
-
|
|
167
|
+
let data = null;
|
|
168
|
+
try {
|
|
169
|
+
data = await res.json();
|
|
170
|
+
} catch {}
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
// CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)
|
|
173
|
+
if (
|
|
174
|
+
data &&
|
|
175
|
+
typeof data.error === "string" &&
|
|
176
|
+
data.error.includes("CDN限流")
|
|
177
|
+
) {
|
|
178
|
+
throw new CDNBlockedError(data.error);
|
|
179
|
+
}
|
|
180
|
+
// 其余失败(瞬时 503/5xx)返回 null,由服务端后续重新排队
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
|
|
184
|
+
if (data && data.error && data.tiktokStatusCode) {
|
|
185
|
+
return data;
|
|
186
|
+
}
|
|
187
|
+
if (data && data.error) return null;
|
|
168
188
|
return data;
|
|
169
189
|
};
|
|
170
190
|
} else {
|
|
@@ -397,24 +417,32 @@ export async function handlePrecheck(options) {
|
|
|
397
417
|
` 本轮结果: ${needRefreshCount} 需刷新, ${skipCount} 跳过, ${failCount} 失败`,
|
|
398
418
|
);
|
|
399
419
|
|
|
400
|
-
// CDN
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
// CDN 限流比例超过 30% 时:通知 clash daemon 故障切换(换出口 IP 绕过限流)+ 冷却
|
|
421
|
+
const cdnRatio =
|
|
422
|
+
candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
|
|
423
|
+
if (cdnRatio > 0.3) {
|
|
424
|
+
// 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
425
|
+
try {
|
|
426
|
+
writeCommand({
|
|
427
|
+
action: "failover",
|
|
428
|
+
reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
|
|
429
|
+
});
|
|
430
|
+
} catch {}
|
|
431
|
+
// 切换节点后新 IP 生效需短暂等待,冷却可缩短(替代纯硬等 120s)
|
|
432
|
+
const coolDownSeconds = cdnRatio > 0.8 ? 90 : 60;
|
|
433
|
+
precheckLog(
|
|
434
|
+
` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
|
|
435
|
+
);
|
|
436
|
+
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
437
|
+
if (scraper) {
|
|
410
438
|
await recycleScraper(
|
|
411
439
|
scraper,
|
|
412
440
|
`CDN限流比例过高 (${cdnBlockedCount}/${candidates.length})`,
|
|
413
441
|
);
|
|
414
442
|
browserRestartCount++;
|
|
415
|
-
taskBatchCount = 0;
|
|
416
443
|
}
|
|
417
|
-
|
|
444
|
+
taskBatchCount = 0;
|
|
445
|
+
} else if (scraper) {
|
|
418
446
|
const heap = getHeapUsage();
|
|
419
447
|
if (heap.ratio >= HEAP_RESTART_RATIO) {
|
|
420
448
|
await recycleScraper(
|
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) {
|