tt-help-cli-ycl 1.4.144 → 1.4.145

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/src/cli/tag.js CHANGED
@@ -1,16 +1,12 @@
1
1
  import { writeFileSync } from "fs";
2
2
  import { randomUUID } from "crypto";
3
- import http from "http";
4
3
  import readline from "readline";
5
4
  import os from "os";
6
5
  import {
7
6
  getBrowserMemory,
8
7
  formatMemoryWithBrowser,
9
8
  } from "../lib/browser-memory.js";
10
- import {
11
- reportConsecutiveTaskErrors,
12
- reportClientErrorAndFailover,
13
- } from "../lib/client-error-report.js";
9
+ import { reportClientErrorAndFailover } from "../lib/client-error-report.js";
14
10
  import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
15
11
  import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
16
12
  import { getOrCreatePage, isBrowserClosedError } from "../lib/browser/page.js";
@@ -55,38 +51,6 @@ function isBrowserDisconnected(browser, e) {
55
51
  return false;
56
52
  }
57
53
 
58
- /**
59
- * 探测端口是否已被监听(CDP /json 端点)。
60
- * 与 cdp.js 的 checkCDPPort 等价,但 tag.js 独立实现,不依赖未导出的内部函数。
61
- * 用于端口池挑选时跳过被占用端口、以及关闭旧浏览器后确认端口已释放。
62
- */
63
- function checkPortListening(port) {
64
- return new Promise((resolve) => {
65
- const req = http.get(`http://127.0.0.1:${port}/json`, (res) => {
66
- res.on("data", () => {});
67
- res.on("end", () => resolve(res.statusCode === 200));
68
- });
69
- req.on("error", () => resolve(false));
70
- req.setTimeout(2000, () => {
71
- resolve(false);
72
- req.destroy();
73
- });
74
- });
75
- }
76
-
77
- /**
78
- * 等待端口不再被监听(浏览器进程真正退出、端口释放)。
79
- * @returns {Promise<boolean>} 端口已释放返回 true
80
- */
81
- async function waitForPortReleased(port, timeoutMs = 10000) {
82
- const deadline = Date.now() + timeoutMs;
83
- while (Date.now() < deadline) {
84
- if (!(await checkPortListening(port))) return true;
85
- await new Promise((r) => setTimeout(r, 500));
86
- }
87
- return !(await checkPortListening(port));
88
- }
89
-
90
54
  // 构建带客户端追踪 header 的 fetch 封装
91
55
  function buildClientHeaders(clientId, meta, extra = {}) {
92
56
  return {
@@ -808,9 +772,8 @@ export async function handleScoreAll(parsed) {
808
772
  } = tagScoreAll || {};
809
773
 
810
774
  const baseUrl = serverUrl || DEFAULT_SERVER;
811
- const defaultScoreAllPort =
812
- parseInt(process.env.TAG_SCOREALL_PORT_POOL_START || "7222", 10) || 7222;
813
- let cdpPort = port || defaultScoreAllPort;
775
+ // 固定端口:一次会话一个浏览器,全程不换端口、不重启浏览器(错误只打印,不自愈)
776
+ const cdpPort = port || 7222;
814
777
  const effectiveProxy = cliProxy || configuredProxy;
815
778
  const targetCountries = resolveTargetCountries(countries);
816
779
 
@@ -863,62 +826,7 @@ export async function handleScoreAll(parsed) {
863
826
 
864
827
  await setupPageRequestBlocking(page);
865
828
 
866
- // 运行期用过的所有 CDP 端口(含换端口历史),退出时统一清理,防止旧浏览器窗口残留
867
- const usedPorts = new Set([cdpPort]);
868
-
869
- // 端口池起始以 CLI --port / 环境变量 / 默认 7222 为准
870
- const portPoolStart = cdpPort;
871
- const portPoolSize = Math.max(
872
- 2,
873
- parseInt(process.env.TAG_SCOREALL_PORT_POOL_SIZE || "10", 10) || 10,
874
- );
875
-
876
- async function pickNextPort(currentPort) {
877
- const candidates = [];
878
- for (let i = 0; i < portPoolSize; i++) {
879
- const p = portPoolStart + i;
880
- if (p !== currentPort) candidates.push(p);
881
- }
882
- // 跳过已被监听的端口(其他 score-all/attach 等程序的浏览器),
883
- // 避免 attach 到别人的实例、后续 close/kill 时互相干扰导致浏览器多开
884
- const freePorts = [];
885
- for (const p of candidates) {
886
- if (!(await checkPortListening(p))) freePorts.push(p);
887
- }
888
- const pool = freePorts.length > 0 ? freePorts : candidates;
889
- if (pool.length === 0) return currentPort;
890
- return pool[Math.floor(Math.random() * pool.length)];
891
- }
892
-
893
- // ── 内存回收配置 ──
894
- const RECYCLE_CONFIG = {
895
- nodeRssMb: Math.max(
896
- 256,
897
- parseInt(process.env.TAG_SCOREALL_RECYCLE_RSS_MB || "2000", 10) || 2000,
898
- ),
899
- nodeHeapMb: Math.max(
900
- 128,
901
- parseInt(process.env.TAG_SCOREALL_RECYCLE_HEAP_MB || "2000", 10) || 2000,
902
- ),
903
- edgeMb: Math.max(
904
- 500,
905
- parseInt(process.env.TAG_SCOREALL_RECYCLE_EDGE_MB || "2000", 10) || 2000,
906
- ),
907
- cooldownMs: Math.max(
908
- 0,
909
- parseInt(process.env.TAG_SCOREALL_RECYCLE_COOLDOWN_MS || "180000", 10) ||
910
- 180000,
911
- ),
912
- periodicEvery: Math.max(
913
- 0,
914
- parseInt(process.env.TAG_SCOREALL_PERIODIC_RECYCLE_EVERY || "0", 10) || 0,
915
- ),
916
- switchPortOnRecycle:
917
- String(process.env.TAG_SCOREALL_SWITCH_PORT_ON_RECYCLE || "0") !== "0",
918
- };
919
- let lastRecycleAt = 0;
920
-
921
- // ── 轻量回收:只清空当前 tab ──
829
+ // ── 轻量回收:只清空当前 tab(不重启浏览器) ──
922
830
  async function recyclePage() {
923
831
  if (!page || page.isClosed()) {
924
832
  page = await getOrCreatePage(browser);
@@ -937,117 +845,29 @@ export async function handleScoreAll(parsed) {
937
845
  }
938
846
  }
939
847
 
940
- // ── 深度回收:关闭浏览器 + 可选切换端口 ──
941
- async function recycleCdpSession(reason, { switchPort = true } = {}) {
942
- const oldPort = cdpPort;
943
- const shouldSwitch = switchPort && RECYCLE_CONFIG.switchPortOnRecycle;
944
- if (shouldSwitch) {
945
- cdpPort = await pickNextPort(cdpPort);
946
- cdpOpts.port = cdpPort;
947
- clientMeta.port = cdpPort;
948
- usedPorts.add(cdpPort);
949
- }
950
- const portHint =
951
- oldPort === cdpPort ? `port=${cdpPort}` : `port ${oldPort}→${cdpPort}`;
952
- log(` ♻️ 重建 CDP 会话 (${reason}; ${portHint})...`);
953
- if (page) {
954
- await page.close().catch(() => {});
955
- page = null;
956
- }
957
- // CDP 模式下 browser.close() 只断开连接不杀进程,必须显式 kill 旧端口实例;
958
- // kill 后确认端口已释放,再启动新实例,防止旧浏览器窗口残留(多开)
959
- if (oldPort !== cdpPort) {
960
- await killEdgeProcesses(null, oldPort).catch(() => {});
961
- if (!(await waitForPortReleased(oldPort))) {
962
- log(` ⚠️ 旧端口 ${oldPort} 未释放,再次尝试关闭...`);
963
- await killEdgeProcesses(null, oldPort).catch(() => {});
964
- if (!(await waitForPortReleased(oldPort, 5000))) {
965
- log(
966
- ` ⚠️ 旧端口 ${oldPort} 仍未释放,继续使用新端口(旧浏览器窗口可能残留,退出时会统一清理)`,
967
- );
968
- }
969
- }
970
- }
971
- await browser.close().catch(() => {});
972
- browser = await ensureBrowserReady(cdpOpts);
973
- page = await getOrCreatePage(browser);
974
- await setupPageRequestBlocking(page);
975
- lastRecycleAt = Date.now();
976
- }
977
-
978
- // ── 内存检查 + 自动回收 ──
979
- // 策略:Node.js 内存超阈值 → 不换端口(只重建连接)
980
- // Edge 内存超阈值 → 换端口(杀旧浏览器 + 启新实例,彻底释放)
981
- // 定期轮次 → 不换端口
982
- async function maybeRecycleForMemory() {
983
- const mem = process.memoryUsage();
984
- const rssMB = mem.rss / 1024 / 1024;
985
- const heapMB = mem.heapUsed / 1024 / 1024;
986
- const edgeMB = getBrowserMemory(cdpPort);
987
-
988
- const nodeOver =
989
- rssMB >= RECYCLE_CONFIG.nodeRssMb || heapMB >= RECYCLE_CONFIG.nodeHeapMb;
990
- const edgeOver = edgeMB !== null && edgeMB >= RECYCLE_CONFIG.edgeMb;
991
-
992
- if (nodeOver || edgeOver) {
993
- // 冷却期内跳过
994
- if (
995
- RECYCLE_CONFIG.cooldownMs > 0 &&
996
- Date.now() - lastRecycleAt < RECYCLE_CONFIG.cooldownMs
997
- )
998
- return;
999
-
1000
- const parts = [];
1001
- if (rssMB >= RECYCLE_CONFIG.nodeRssMb)
1002
- parts.push(`rss=${rssMB.toFixed(0)}≥${RECYCLE_CONFIG.nodeRssMb}`);
1003
- if (heapMB >= RECYCLE_CONFIG.nodeHeapMb)
1004
- parts.push(`heap=${heapMB.toFixed(0)}≥${RECYCLE_CONFIG.nodeHeapMb}`);
1005
- if (edgeOver)
1006
- parts.push(`edge:${cdpPort}=${edgeMB}≥${RECYCLE_CONFIG.edgeMb}`);
1007
-
1008
- await recycleCdpSession(`mem ${parts.join("+")}`, {
1009
- switchPort: edgeOver,
1010
- });
1011
- return;
1012
- }
1013
-
1014
- // 定期轮次回收(默认关闭)
1015
- if (
1016
- RECYCLE_CONFIG.periodicEvery > 0 &&
1017
- totalScored > 0 &&
1018
- totalScored % RECYCLE_CONFIG.periodicEvery === 0
1019
- ) {
1020
- await recycleCdpSession(`periodic #${totalScored}`, {
1021
- switchPort: false,
1022
- });
1023
- }
1024
- }
1025
-
1026
848
  let totalScored = 0;
1027
849
  let emptyRounds = 0; // 连续无任务的轮数
1028
- let consecutiveCaptcha = 0;
1029
- const MAX_CONSECUTIVE_CAPTCHA = 3;
1030
- let consecutiveTaskErrors = 0; // 连续任务处理错误计数(达阈值后上报服务端)
1031
- const CONSECUTIVE_TASK_ERRORS_MAX = Math.max(
1032
- 1,
1033
- parseInt(
1034
- process.env.TAG_SCOREALL_CONSECUTIVE_TASK_ERRORS_MAX || "20",
1035
- 10,
1036
- ) || 20,
1037
- ); // 连续任务错误阈值,达到后上报 /api/error-report
1038
-
1039
- // ── 403 判封 + 重试机制 ──
1040
- let currentTag = null; // 当前正在处理的 tag(403 重试时复用,不消耗下一个)
1041
- let retrySameTag = false; // 是否重试同一个 tag
1042
- let tag403Attempts = 0; // 当前 tag 已发生 403 的次数
1043
- let bannedCount = 0; // 连续判定“被封”的次数(成功 tag 清零)
1044
- // 403 渐退重试间隔(秒):30/40/50s 各重试一次,仍 403 才判定“被封”一次。
1045
- // 与 api-interceptor 被封重试(30/40/50s 共 3 次)保持一致
1046
- const BAN_RETRY_WAITS = [
1047
- (parseInt(process.env.TAG_SCOREALL_403_RETRY_1 || "30", 10) || 30) * 1000,
1048
- (parseInt(process.env.TAG_SCOREALL_403_RETRY_2 || "40", 10) || 40) * 1000,
1049
- (parseInt(process.env.TAG_SCOREALL_403_RETRY_3 || "50", 10) || 50) * 1000,
1050
- ];
850
+
851
+ // ── 错误计数(纯观察,不做任何自愈) ──
852
+ // 阈值语义:连续超过 N 次触发动作(即第 N+1 次)
853
+ // 403 连续 >2 次 → 上报服务端 + 强制 fallback(daemon 换节点),清零后继续跑
854
+ // 网络错误 连续 >10 次 → 打印退出(服务端不可达时 fallback 请求也发不出去)
855
+ // 验证码 连续 >3 次(人工一直未解决)→ 打印退出
856
+ // 其他错误 连续 >10 次 → 打印退出
857
+ // 清零规则:成功处理一个 tag → 全部清零;fallback 触发 → 403 计数清零;
858
+ // 人工点掉验证码 → 验证码计数清零
859
+ const errorCounts = { http403: 0, network: 0, captcha: 0, other: 0 };
860
+ const ERROR_LIMITS = { http403: 2, network: 10, captcha: 3, other: 10 };
861
+ const resetAllErrorCounts = () => {
862
+ errorCounts.http403 = 0;
863
+ errorCounts.network = 0;
864
+ errorCounts.captcha = 0;
865
+ errorCounts.other = 0;
866
+ };
867
+ const bumpErrorCount = (type) => {
868
+ errorCounts[type]++;
869
+ return errorCounts[type] > ERROR_LIMITS[type];
870
+ };
1051
871
 
1052
872
  // 生成客户端 ID,用于服务端追踪
1053
873
  const clientId = randomUUID();
@@ -1080,14 +900,10 @@ export async function handleScoreAll(parsed) {
1080
900
  if (enrichScraper) await enrichScraper.close().catch(() => {});
1081
901
  log("正在关闭 CDP 浏览器...");
1082
902
  try {
1083
- // 逐个关闭运行期用过的所有端口(含换端口历史),防止旧浏览器窗口残留
1084
- const ports = [...new Set([...usedPorts, cdpPort])];
1085
- for (const p of ports) {
1086
- await Promise.race([
1087
- killEdgeProcesses(null, p),
1088
- new Promise((r) => setTimeout(r, 5000)),
1089
- ]).catch(() => {});
1090
- }
903
+ await Promise.race([
904
+ killEdgeProcesses(null, cdpPort),
905
+ new Promise((r) => setTimeout(r, 5000)),
906
+ ]).catch(() => {});
1091
907
  log("浏览器已关闭");
1092
908
  } catch (e) {
1093
909
  log(`关闭浏览器失败: ${e.message}`);
@@ -1116,43 +932,38 @@ export async function handleScoreAll(parsed) {
1116
932
  return;
1117
933
  }
1118
934
  try {
1119
- let tag;
1120
- if (retrySameTag) {
1121
- // 403 重试:复用当前 tag,不消耗队列里的下一个
1122
- tag = currentTag;
1123
- } else {
1124
- // 从服务端取下一个 new 标签(同时查剩余数量)
1125
- const tagsRes = await fetch(
1126
- `${baseUrl}/api/tags?status=new&limit=1&userId=${encodeURIComponent(userId)}`,
1127
- {
1128
- headers: buildClientHeaders(clientId, clientMeta),
1129
- },
1130
- );
1131
- const tagsData = await tagsRes.json();
935
+ // 从服务端取下一个 new 标签(同时查剩余数量)
936
+ const tagsRes = await fetch(
937
+ `${baseUrl}/api/tags?status=new&limit=1&userId=${encodeURIComponent(userId)}`,
938
+ {
939
+ headers: buildClientHeaders(clientId, clientMeta),
940
+ },
941
+ );
942
+ const tagsData = await tagsRes.json();
1132
943
 
1133
- if (!tagsData.tags || tagsData.tags.length === 0) {
1134
- emptyRounds++;
944
+ if (!tagsData.tags || tagsData.tags.length === 0) {
945
+ emptyRounds++;
1135
946
 
1136
- // 服务端定时发现会自动补充标签,客户端只需等待
1137
- if (emptyRounds >= 5) {
1138
- log(
1139
- `⏳ 连续 ${emptyRounds} 轮无标签,等待服务端发现补充(30 秒后重试)...`,
1140
- );
1141
- await new Promise((r) => setTimeout(r, 30000));
1142
- emptyRounds = 0;
1143
- continue;
1144
- }
1145
- log(`⏳ 暂无待打分标签(连续 ${emptyRounds} 轮),10 秒后重试...`);
1146
- await new Promise((r) => setTimeout(r, 10000));
947
+ // 服务端定时发现会自动补充标签,客户端只需等待
948
+ if (emptyRounds >= 5) {
949
+ log(
950
+ `⏳ 连续 ${emptyRounds} 轮无标签,等待服务端发现补充(30 秒后重试)...`,
951
+ );
952
+ await new Promise((r) => setTimeout(r, 30000));
953
+ emptyRounds = 0;
1147
954
  continue;
1148
955
  }
1149
-
1150
- // 有任务了,重置计数器
1151
- emptyRounds = 0;
1152
- tag = tagsData.tags[0].tag.replace(/^#+/, "").trim().toLowerCase();
1153
- currentTag = tag;
1154
- tag403Attempts = 0;
956
+ log(`⏳ 暂无待打分标签(连续 ${emptyRounds} 轮),10 秒后重试...`);
957
+ await new Promise((r) => setTimeout(r, 10000));
958
+ continue;
1155
959
  }
960
+
961
+ // 有任务了,重置计数器
962
+ emptyRounds = 0;
963
+ const tag = tagsData.tags[0].tag
964
+ .replace(/^#+/, "")
965
+ .trim()
966
+ .toLowerCase();
1156
967
  const startTime = Date.now();
1157
968
 
1158
969
  log(`[${totalScored + 1}] 正在打分 #${tag} ...`);
@@ -1170,8 +981,7 @@ export async function handleScoreAll(parsed) {
1170
981
  };
1171
982
 
1172
983
  // 锁定 tag(meta 中不放入 tag,避免非 ASCII 字符导致 header ByteString 报错)
1173
- // 403 重试时 tag 已被本客户端锁定,跳过 claim,直接复用
1174
- if (!retrySameTag) {
984
+ {
1175
985
  const claimRes = await fetch(
1176
986
  `${baseUrl}/api/tags/claim?userId=${encodeURIComponent(userId)}`,
1177
987
  {
@@ -1233,7 +1043,6 @@ export async function handleScoreAll(parsed) {
1233
1043
  totalScored++;
1234
1044
  await randomDelay(0, 5000);
1235
1045
  await recyclePage();
1236
- await maybeRecycleForMemory();
1237
1046
  continue;
1238
1047
  }
1239
1048
  // 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
@@ -1295,18 +1104,14 @@ export async function handleScoreAll(parsed) {
1295
1104
  );
1296
1105
  videos = enriched.videos;
1297
1106
 
1298
- // CDN 限流检测:有拦截则冷却 + 重启 scraper
1107
+ // CDN 限流:打印并计入其他错误计数(不自愈、不重启 scraper)
1299
1108
  const cdnBlocked = enriched.cdnBlockedCount || 0;
1300
1109
  if (cdnBlocked > 0) {
1301
1110
  const cdnRatio = cdnBlocked / (videos.length || 1);
1302
- const coolSec = cdnRatio > 0.3 ? 120 : 60;
1111
+ bumpErrorCount("other");
1303
1112
  log(
1304
- ` ⚠️ CDN 限流: ${cdnBlocked}/${videos.length} (${(cdnRatio * 100).toFixed(0)}%),冷却 ${coolSec} 秒后重启 scraper`,
1113
+ ` ⚠️ CDN 限流: ${cdnBlocked}/${videos.length} (${(cdnRatio * 100).toFixed(0)}%),计入其他错误计数 (${errorCounts.other}/${ERROR_LIMITS.other})`,
1305
1114
  );
1306
- await new Promise((r) => setTimeout(r, coolSec * 1000));
1307
- log(` 正在重启 TikTokScraper...`);
1308
- await enrichScraper.restart();
1309
- log(` ✅ TikTokScraper 已重启`);
1310
1115
  }
1311
1116
  }
1312
1117
 
@@ -1334,7 +1139,6 @@ export async function handleScoreAll(parsed) {
1334
1139
  ` ⏭️ 重试 ${COUNTRY_MAX_RETRIES} 次后国家识别率仍 ${(rate * 100).toFixed(0)}% < ${(COUNTRY_RATE_MIN * 100).toFixed(0)}%,跳过该标签,处理下一个`,
1335
1140
  );
1336
1141
  await recyclePage();
1337
- await maybeRecycleForMemory();
1338
1142
  await randomDelay(3000, 7000);
1339
1143
  continue;
1340
1144
  }
@@ -1379,10 +1183,7 @@ export async function handleScoreAll(parsed) {
1379
1183
  ` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}${memStr}`,
1380
1184
  );
1381
1185
  log("");
1382
- consecutiveTaskErrors = 0; // 成功完成,重置连续任务错误计数
1383
- retrySameTag = false; // 成功,不再重试
1384
- tag403Attempts = 0;
1385
- bannedCount = 0; // 成功 tag,重置连续判封计数
1186
+ resetAllErrorCounts(); // 成功一个 tag → 所有错误计数清零
1386
1187
  // 每 10 个标签输出一次内存汇总
1387
1188
  if (totalScored % 10 === 0) {
1388
1189
  log(
@@ -1390,7 +1191,6 @@ export async function handleScoreAll(parsed) {
1390
1191
  );
1391
1192
  }
1392
1193
  await recyclePage();
1393
- await maybeRecycleForMemory();
1394
1194
  await randomDelay(3000, 7000);
1395
1195
  } catch (e) {
1396
1196
  // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
@@ -1409,7 +1209,17 @@ export async function handleScoreAll(parsed) {
1409
1209
  e.message.includes("fetch failed") ||
1410
1210
  e.message.includes("network")));
1411
1211
  if (isNetworkError) {
1412
- log(` ⚠️ 服务端连接失败 (${e.message}),15 秒后重试...`);
1212
+ const over = bumpErrorCount("network");
1213
+ log(
1214
+ ` ⚠️ 服务端连接失败 (${e.message}),15 秒后重试(连续网络错误 ${errorCounts.network} 次)...`,
1215
+ );
1216
+ if (over) {
1217
+ log(
1218
+ ` ❌ 连续网络错误超过 ${ERROR_LIMITS.network} 次 → 打印并退出(服务端不可达,fallback 发不出去)`,
1219
+ );
1220
+ await cleanup();
1221
+ return;
1222
+ }
1413
1223
  await new Promise((r) => setTimeout(r, 15000));
1414
1224
  continue;
1415
1225
  }
@@ -1423,35 +1233,16 @@ export async function handleScoreAll(parsed) {
1423
1233
  e.message &&
1424
1234
  (e.message.includes("验证码") || e.message.includes("captcha"));
1425
1235
 
1426
- // ── 403:渐退等待 30/40/50s 各重试一次,仍 403 判定“被封” ──
1427
- // 首次被封重启浏览器(同端口换新会话),后续仍 403 只清 tab
1236
+ // ── 403:当前 tag 不上报(留在服务端 scoring 状态超时回收),
1237
+ // 只计数;连续 >2 次上报服务端 + 强制 fallback(daemon 换节点),
1238
+ // 计数清零后继续跑 ──
1428
1239
  if (is403) {
1429
- const retryIndex = tag403Attempts;
1430
- tag403Attempts++;
1431
- if (retryIndex < BAN_RETRY_WAITS.length) {
1432
- const waitMs = BAN_RETRY_WAITS[retryIndex];
1433
- retrySameTag = true;
1434
- if (retryIndex === 0) {
1435
- log(
1436
- ` 🚫 HTTP 403,判定被封,重启浏览器后 ${waitMs / 1000}s 第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
1437
- );
1438
- await recycleCdpSession(`首次被封`, { switchPort: false });
1439
- } else {
1440
- log(
1441
- ` 🚫 HTTP 403,判定被封,${waitMs / 1000}s 后第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
1442
- );
1443
- await recyclePage();
1444
- }
1445
- await new Promise((r) => setTimeout(r, waitMs));
1446
- continue;
1447
- }
1448
- // 重试耗尽:判定“被封”
1449
- bannedCount++;
1450
- log(
1451
- ` 🚫 30/40/50s 重试 ${BAN_RETRY_WAITS.length} 次仍 403,判定账号被封(连续判封 ${bannedCount} 次)`,
1452
- );
1453
- if (bannedCount >= 2) {
1454
- log(` ⚠️ 连续两次判封 → 汇报服务端 + 触发 daemon fallback`);
1240
+ const over = bumpErrorCount("http403");
1241
+ log(` 🚫 HTTP 403(连续第 ${errorCounts.http403} 次)`);
1242
+ if (over) {
1243
+ log(
1244
+ ` ⚠️ 连续 403 超过 ${ERROR_LIMITS.http403} 次 → 上报服务端 + 触发 daemon fallback`,
1245
+ );
1455
1246
  await reportClientErrorAndFailover({
1456
1247
  post: (url, body) =>
1457
1248
  fetch(url, {
@@ -1464,16 +1255,13 @@ export async function handleScoreAll(parsed) {
1464
1255
  baseUrl,
1465
1256
  userId,
1466
1257
  errorType: "consecutive_blocked",
1467
- message: `连续两次判定账号被封 (port=${cdpPort})`,
1258
+ message: `连续 ${ERROR_LIMITS.http403 + 1} 次 403 (port=${cdpPort})`,
1468
1259
  stage: "tag-scoring",
1469
1260
  log,
1470
1261
  });
1471
- bannedCount = 0;
1262
+ errorCounts.http403 = 0; // fallback 触发后清零
1472
1263
  }
1473
- retrySameTag = false;
1474
- tag403Attempts = 0;
1475
1264
  await recyclePage();
1476
- await maybeRecycleForMemory();
1477
1265
  await randomDelay(3000, 7000);
1478
1266
  continue;
1479
1267
  }
@@ -1482,9 +1270,9 @@ export async function handleScoreAll(parsed) {
1482
1270
  log(` ❌ 失败: ${e.message}`);
1483
1271
 
1484
1272
  if (isCaptcha) {
1485
- consecutiveCaptcha++;
1273
+ bumpErrorCount("captcha");
1486
1274
  log(
1487
- ` 🔐 验证码 (${consecutiveCaptcha}/${MAX_CONSECUTIVE_CAPTCHA}),等待用户手动解决...`,
1275
+ ` 🔐 验证码(连续第 ${errorCounts.captcha} 次),等待用户手动解决...`,
1488
1276
  );
1489
1277
 
1490
1278
  // 上报验证码错误
@@ -1507,24 +1295,31 @@ export async function handleScoreAll(parsed) {
1507
1295
  } catch {}
1508
1296
 
1509
1297
  // 等待用户手动解决验证码(最多 3 分钟)
1298
+ let captchaResolved = false;
1510
1299
  try {
1511
1300
  const captchaResult = await waitAndGetCaptcha(page, {
1512
1301
  waitMs: 180000,
1513
1302
  pollInterval: 5000,
1514
1303
  log: () => {},
1515
1304
  });
1305
+ captchaResolved = !!captchaResult.resolved;
1516
1306
  if (captchaResult.detected && !captchaResult.resolved) {
1517
1307
  await closeCaptcha(page).catch(() => {});
1518
1308
  }
1519
1309
  } catch {}
1520
1310
 
1521
- if (consecutiveCaptcha >= MAX_CONSECUTIVE_CAPTCHA) {
1522
- log(` ♻️ 连续${MAX_CONSECUTIVE_CAPTCHA}次验证码 → 重启浏览器`);
1523
- await recycleCdpSession("连续验证码", { switchPort: true });
1524
- consecutiveCaptcha = 0;
1311
+ if (captchaResolved) {
1312
+ // 人工点掉验证码 → 验证码计数清零
1313
+ log(` ✅ 验证码已被人工处理,验证码计数清零`);
1314
+ errorCounts.captcha = 0;
1315
+ } else if (errorCounts.captcha > ERROR_LIMITS.captcha) {
1316
+ log(
1317
+ ` ❌ 连续验证码超过 ${ERROR_LIMITS.captcha} 次且未解决 → 打印并退出`,
1318
+ );
1319
+ await cleanup();
1320
+ return;
1525
1321
  }
1526
- } else {
1527
- consecutiveCaptcha = 0;
1322
+ continue;
1528
1323
  }
1529
1324
 
1530
1325
  try {
@@ -1543,34 +1338,14 @@ export async function handleScoreAll(parsed) {
1543
1338
  );
1544
1339
  } catch {}
1545
1340
 
1546
- // 连续任务错误达到阈值,上报服务端 + 触发 daemon fallback 换节点
1547
- consecutiveTaskErrors++;
1548
- if (consecutiveTaskErrors >= CONSECUTIVE_TASK_ERRORS_MAX) {
1549
- log(
1550
- ` ⚠️ 连续任务错误 ${consecutiveTaskErrors}/${CONSECUTIVE_TASK_ERRORS_MAX} → 汇报服务端 + 触发 fallback`,
1551
- );
1552
- await reportConsecutiveTaskErrors({
1553
- post: (url, body) =>
1554
- fetch(url, {
1555
- method: "POST",
1556
- headers: buildClientHeaders(clientId, clientMeta, {
1557
- "Content-Type": "application/json",
1558
- }),
1559
- body: JSON.stringify(body),
1560
- }),
1561
- baseUrl,
1562
- userId,
1563
- message: `连续 ${consecutiveTaskErrors} 个标签处理失败 (port=${cdpPort})`,
1564
- stage: "tag-scoring",
1565
- log,
1566
- });
1567
- consecutiveTaskErrors = 0;
1568
- }
1569
1341
  totalScored++;
1570
- retrySameTag = false;
1571
- tag403Attempts = 0;
1342
+ const over = bumpErrorCount("other");
1343
+ if (over) {
1344
+ log(` ❌ 连续其他错误超过 ${ERROR_LIMITS.other} 次 → 打印并退出`);
1345
+ await cleanup();
1346
+ return;
1347
+ }
1572
1348
  await recyclePage();
1573
- await maybeRecycleForMemory();
1574
1349
  }
1575
1350
  }
1576
1351
  } finally {
@@ -360,15 +360,16 @@ const HELP_TEXT = [
360
360
  " 选项:",
361
361
  " --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
362
362
  " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
363
- " --port <N> 起始 CDP 端口,端口池默认为 N~N+9(默认 7222)",
363
+ " --port <N> 固定 CDP 端口(默认 7222,全程不切换)",
364
364
  " --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
365
365
  " --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
366
366
  " 行为:",
367
- " 连续 3 次 HTTP 403 → 判断账号被封 → 等 2 分钟后自动切换到池中另一端口",
367
+ " 错误只打印不自愈:403 连续 3 次 → 上报服务端并触发 daemon 换节点;",
368
+ " 网络错误/其他错误连续 11 次 → 退出;验证码连续 4 次未人工解决 → 退出",
369
+ " 成功一个 tag 全部计数清零;浏览器窗口被手动关闭 → 自动退出",
368
370
  " 环境变量:",
369
- " TAG_SCOREALL_PORT_POOL_SIZE=N 端口池大小(默认 10)",
370
- " TAG_SCOREALL_RECYCLE_RSS_MB=N 内存回收阈值 RSS MB(默认 2000)",
371
- " TAG_SCOREALL_RECYCLE_HEAP_MB=N 内存回收阈值 Heap MB(默认 2000)",
371
+ " TAG_SCOREALL_MIN_COUNTRY_RATE=0.8 国家识别率下限(低于则重试)",
372
+ " TAG_SCOREALL_COUNTRY_MAX_RETRIES=2 国家识别率不足最大重试次数",
372
373
  " 示例: tt-help tag score-all",
373
374
  " tt-help tag score-all --countries ES -s http://127.0.0.1:3001",
374
375
  " tt-help tag score-all --port 9222 -s http://127.0.0.1:3001 --discover",