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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.114",
3
+ "version": "1.4.115",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const data = await res.json();
151
- if (!res.ok || data.error) return null;
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
- if (scraper) {
353
- const cdnRatio = cdnBlockedCount / tasks.length;
354
- if (cdnRatio > 0.3) {
355
- const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
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}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
398
+ ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),已连续 ${consecutiveCdnBlocks} 次切换仍限流(可能账号/整体风控),停止切换,长冷却 ${coolDownSeconds} 秒等待窗口过去...`,
358
399
  );
359
- await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
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(
@@ -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
- const data = await res.json();
167
- if (!res.ok || data.error) return null;
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
- if (scraper) {
402
- const cdnRatio =
403
- candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
404
- if (cdnRatio > 0.3) {
405
- const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
406
- precheckLog(
407
- ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
408
- );
409
- await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
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(
@@ -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, 503, {
290
+ return sendJSON(res, 200, {
267
291
  error: `TikTok statusCode=${result.statusCode}`,
268
292
  retryable,
269
- tiktokStatusCode: result.statusCode,
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
- // 自动恢复:连续错误超过阈值时重启 scraper
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 (e.name === "CDNBlockedError") {
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) {