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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.113",
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,161 @@
1
+ /**
2
+ * refresh-mini 运行状况监控(4222 / 5222)
3
+ *
4
+ * - 每 5 分钟采样:各端口 refresh-mini 进程 RSS + Edge 浏览器 RSS/进程数
5
+ * - 采样日志:../newdata/refresh-mini-monitor.log(JSONL)
6
+ * - 每天 08:00~08:10 自动生成报告(md 文件 + macOS 系统通知)
7
+ */
8
+ import { execSync } from "child_process";
9
+ import fs from "fs";
10
+
11
+ const LOG_FILE =
12
+ "/Users/yinchengliang/soft/tiktok/newdata/refresh-mini-monitor.log";
13
+ const REPORT_DIR = "/Users/yinchengliang/soft/tiktok/newdata";
14
+ const INTERVAL_MS = 5 * 60 * 1000;
15
+ const PORTS = [4222, 5222];
16
+
17
+ function execSilent(cmd) {
18
+ try {
19
+ return execSync(cmd, { encoding: "utf8" }).trim();
20
+ } catch {
21
+ return "";
22
+ }
23
+ }
24
+
25
+ /** 采样一次:refresh-mini 进程 + Edge 浏览器进程 */
26
+ function sample() {
27
+ const ts = new Date();
28
+ const entry = { time: ts.toISOString(), ports: {} };
29
+
30
+ // ps ax -o pid=,rss=,command= :一行一个进程(macOS/Linux 兼容)
31
+ const psOut = execSilent("ps ax -o pid=,rss=,command=");
32
+ const lines = psOut ? psOut.split("\n") : [];
33
+
34
+ for (const port of PORTS) {
35
+ const rmList = [];
36
+ const edgeList = [];
37
+ for (const line of lines) {
38
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
39
+ if (!m) continue;
40
+ const [, pid, rssKb, cmd] = m;
41
+ const is5222 = /--port(=|\s+)5222/.test(cmd);
42
+ // 排除监控脚本自身(文件名含 refresh-mini)
43
+ if (
44
+ cmd.includes("refresh-mini") &&
45
+ !cmd.includes("monitor-refresh-mini")
46
+ ) {
47
+ if ((port === 5222) === is5222) {
48
+ rmList.push({ pid: Number(pid), rssKb: Number(rssKb) });
49
+ }
50
+ }
51
+ if (cmd.includes(`remote-debugging-port=${port}`)) {
52
+ edgeList.push({ pid: Number(pid), rssKb: Number(rssKb) });
53
+ }
54
+ }
55
+ const sum = (list) => list.reduce((a, b) => a + b.rssKb, 0);
56
+ entry.ports[port] = {
57
+ refreshMini: {
58
+ processes: rmList.length,
59
+ rssMb: +(sum(rmList) / 1024).toFixed(1),
60
+ pids: rmList.map((p) => p.pid),
61
+ },
62
+ edge: {
63
+ processes: edgeList.length,
64
+ rssMb: +(sum(edgeList) / 1024).toFixed(1),
65
+ },
66
+ };
67
+ }
68
+
69
+ fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
70
+ const parts = Object.entries(entry.ports).map(
71
+ ([p, d]) =>
72
+ `${p}: rm ${d.refreshMini.processes}个/${d.refreshMini.rssMb}MB, Edge ${d.edge.processes}进程/${d.edge.rssMb}MB`,
73
+ );
74
+ console.log(`[${ts.toLocaleString("zh-CN")}] ${parts.join(" | ")}`);
75
+ return entry;
76
+ }
77
+
78
+ /** 统计日志生成报告 */
79
+ function generateReport() {
80
+ let rows = [];
81
+ try {
82
+ rows = fs
83
+ .readFileSync(LOG_FILE, "utf8")
84
+ .split("\n")
85
+ .filter(Boolean)
86
+ .map((l) => JSON.parse(l));
87
+ } catch {
88
+ /* 无日志 */
89
+ }
90
+ if (rows.length === 0) return;
91
+
92
+ const fmt = (v) => (v == null ? "-" : `${v} MB`);
93
+ const stats = (arr) => {
94
+ const vals = arr.filter((v) => v != null);
95
+ if (vals.length === 0) return { min: "-", max: "-", avg: "-" };
96
+ return {
97
+ min: Math.min(...vals).toFixed(1),
98
+ max: Math.max(...vals).toFixed(1),
99
+ avg: (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1),
100
+ };
101
+ };
102
+
103
+ const lines = [
104
+ `# refresh-mini 运行状况报告`,
105
+ ``,
106
+ `生成时间: ${new Date().toLocaleString("zh-CN")}`,
107
+ `采样频率: 5 分钟 | 采样点: ${rows.length} 个`,
108
+ ``,
109
+ ];
110
+ for (const port of PORTS) {
111
+ lines.push(`## 端口 ${port}`, ``);
112
+ const rmRss = rows.map((r) => r.ports?.[port]?.refreshMini?.rssMb ?? null);
113
+ const edgeRss = rows.map((r) => r.ports?.[port]?.edge?.rssMb ?? null);
114
+ const rmAlive = rows.filter(
115
+ (r) => (r.ports?.[port]?.refreshMini?.processes ?? 0) > 0,
116
+ ).length;
117
+ const edgeAlive = rows.filter(
118
+ (r) => (r.ports?.[port]?.edge?.processes ?? 0) > 0,
119
+ ).length;
120
+ const rmS = stats(rmRss);
121
+ const edgeS = stats(edgeRss);
122
+ const last = rows[rows.length - 1]?.ports?.[port] ?? {};
123
+ lines.push(
124
+ `- refresh-mini 进程:存活采样 ${rmAlive}/${rows.length} 次,内存 min/max/avg = ${fmt(rmS.min)} / ${fmt(rmS.max)} / ${fmt(rmS.avg)}`,
125
+ `- 当前:${last.refreshMini?.processes ?? 0} 个进程,${fmt(last.refreshMini?.rssMb)}(PID: ${(last.refreshMini?.pids ?? []).join(", ") || "无"})`,
126
+ `- Edge 浏览器:存活采样 ${edgeAlive}/${rows.length} 次,内存 min/max/avg = ${fmt(edgeS.min)} / ${fmt(edgeS.max)} / ${fmt(edgeS.avg)}`,
127
+ `- 当前:${last.edge?.processes ?? 0} 个进程,${fmt(last.edge?.rssMb)}`,
128
+ ``,
129
+ );
130
+ }
131
+
132
+ const now = new Date();
133
+ const file = `${REPORT_DIR}/refresh-mini-report-${now.toISOString().slice(0, 10)}.md`;
134
+ fs.writeFileSync(file, lines.join("\n"));
135
+ console.log(`\n=== 8 点报告已生成: ${file} ===`);
136
+ execSilent(
137
+ `osascript -e 'display notification "refresh-mini 运行报告已生成" with title "8点监控报告" subtitle "${file.split("/").pop()}"'`,
138
+ );
139
+ }
140
+
141
+ // 每天 08:00~08:10 窗口内报告一次
142
+ let reportedToday = false;
143
+ function maybeReport() {
144
+ const now = new Date();
145
+ const key = now.toISOString().slice(0, 10);
146
+ if (now.getHours() === 8 && now.getMinutes() < 10) {
147
+ if (reportedToday !== key) {
148
+ reportedToday = key;
149
+ generateReport();
150
+ }
151
+ }
152
+ }
153
+
154
+ console.log(
155
+ `[监控] 启动于 ${new Date().toLocaleString("zh-CN")},每 5 分钟采样,每天 08:00 报告`,
156
+ );
157
+ sample();
158
+ setInterval(() => {
159
+ sample();
160
+ maybeReport();
161
+ }, INTERVAL_MS);
@@ -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
@@ -173,13 +173,22 @@ export async function requireClashOrExit(label = "") {
173
173
 
174
174
  export function checkNodeHealth(url = CONFIG.testUrl) {
175
175
  const { body, metaLine, error } = _curlViaProxy(url);
176
- const { httpCode, timeTotal, sizeDownload } = _parseCurlMeta(metaLine);
176
+ const { httpCode, timeTotal, sizeDownload, numRedirects } =
177
+ _parseCurlMeta(metaLine);
177
178
  const title = _extractTitle(body);
178
179
  return {
179
- healthy: _isHealthyPage(error, httpCode, sizeDownload, title, body),
180
+ healthy: _isHealthyPage(
181
+ error,
182
+ httpCode,
183
+ sizeDownload,
184
+ title,
185
+ body,
186
+ numRedirects,
187
+ ),
180
188
  httpCode,
181
189
  timeTotal,
182
190
  sizeDownload,
191
+ numRedirects,
183
192
  title,
184
193
  error,
185
194
  };
@@ -189,7 +198,7 @@ function _curlViaProxy(url) {
189
198
  const args = [
190
199
  "-s",
191
200
  "-w",
192
- "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
201
+ "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}|%{num_redirects}",
193
202
  "--proxy",
194
203
  CONFIG.proxyUrl,
195
204
  "--max-time",
@@ -224,6 +233,7 @@ function _parseCurlMeta(line) {
224
233
  httpCode: parseInt(p[0]) || 0,
225
234
  timeTotal: parseFloat(p[1]) || 0,
226
235
  sizeDownload: parseInt(p[2]) || 0,
236
+ numRedirects: parseInt(p[3]) || 0,
227
237
  };
228
238
  }
229
239
 
@@ -232,10 +242,14 @@ function _extractTitle(body) {
232
242
  return m ? m[1].trim() : "";
233
243
  }
234
244
 
235
- function _isHealthyPage(error, httpCode, size, title, body) {
245
+ function _isHealthyPage(error, httpCode, size, title, body, numRedirects = 0) {
236
246
  return (
237
247
  !error &&
238
248
  httpCode === 200 &&
249
+ // 发生跳转即不健康:探测 URL 正常时应直接 200。
250
+ // 跳转意味着出口地区/节点异常(如香港停运 302 → /hk/about,
251
+ // 该页 title 仍是 TikTok,旧版 title 规则无法识别)
252
+ numRedirects === 0 &&
239
253
  size >= CONFIG.minPageSize &&
240
254
  title.includes("TikTok") &&
241
255
  !title.startsWith("About |") &&
@@ -212,6 +212,7 @@ export class ClashDaemon {
212
212
  if (!target) {
213
213
  // 全部候选不可用,恢复原节点
214
214
  await checker.switchToNode(node).catch(() => {});
215
+ await api.closeAllConnections().catch(() => {});
215
216
  this._log(`✗ 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`);
216
217
  ipc.writeHealth({
217
218
  node,
@@ -226,6 +227,9 @@ export class ClashDaemon {
226
227
  this._log(
227
228
  `⚠ 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
228
229
  );
230
+ // 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
231
+ // 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
232
+ await api.closeAllConnections().catch(() => {});
229
233
  ipc.writeHealth({
230
234
  node: target,
231
235
  healthy: h2.healthy,
@@ -312,6 +316,8 @@ export class ClashDaemon {
312
316
  });
313
317
  }
314
318
  const h = checker.checkNodeHealth();
319
+ // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
320
+ await api.closeAllConnections().catch(() => {});
315
321
  this._log(
316
322
  `🔄 看门狗指令: 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
317
323
  );
@@ -366,6 +372,7 @@ export class ClashDaemon {
366
372
  }
367
373
  if (!target) {
368
374
  await checker.switchToNode(node).catch(() => {});
375
+ await api.closeAllConnections().catch(() => {});
369
376
  this._log(
370
377
  `✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
371
378
  );
@@ -378,6 +385,8 @@ export class ClashDaemon {
378
385
  });
379
386
  }
380
387
  this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
388
+ // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
389
+ await api.closeAllConnections().catch(() => {});
381
390
  ipc.writeHealth({
382
391
  node: target,
383
392
  healthy: h2.healthy,
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(