tt-help-cli-ycl 1.4.9 → 1.4.11

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.9",
3
+ "version": "1.4.11",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,8 +5,9 @@
5
5
  // 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
6
6
  // ────────────────────────────────────────
7
7
 
8
- import { chromium } from "playwright";
8
+ import { writeFileSync, unlinkSync } from "fs";
9
9
  import os from "os";
10
+ import path from "path";
10
11
  import { execSync } from "child_process";
11
12
 
12
13
  import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
@@ -17,6 +18,8 @@ import {
17
18
  import {
18
19
  server as defaultServer,
19
20
  proxy as configuredProxy,
21
+ userId as configuredUserId,
22
+ saveUserId,
20
23
  } from "../lib/constants.js";
21
24
  import {
22
25
  DEFAULT_TARGET_LOCATIONS,
@@ -31,6 +34,7 @@ import {
31
34
  closeCaptcha,
32
35
  } from "../scraper/modules/captcha-handler.js";
33
36
  import { detectModals, closeAllModals } from "../lib/modal-handler.js";
37
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
34
38
 
35
39
  // ────────────────────────────────────────
36
40
  // Section 1: 常量 & 配置默认值
@@ -52,9 +56,9 @@ const CFG = {
52
56
  /** 连续验证码后切换端口 */
53
57
  maxConsecutiveCaptcha: 3,
54
58
  /** 内存回收 — 触发回收的 RSS(MB) 阈值 */
55
- recycleRssMb: 900,
59
+ recycleRssMb: 2000,
56
60
  /** 内存回收 — 触发回收的 Heap(MB) 阈值 */
57
- recycleHeapMb: 512,
61
+ recycleHeapMb: 2000,
58
62
  /** 内存回收 — 触发回收的 Edge 进程(MB) 阈值 */
59
63
  recycleEdgeMb: 2000,
60
64
  /** 内存回收 — 两次回收最小间隔(ms) */
@@ -63,6 +67,8 @@ const CFG = {
63
67
  periodicRecycleEvery: 0,
64
68
  /** 内存回收 — 回收时是否切换端口 */
65
69
  switchPortOnRecycle: true,
70
+ /** 连续任务处理错误阈值(达到后上报服务端) */
71
+ consecutiveTaskErrorsMax: 20,
66
72
  };
67
73
 
68
74
  /** 从环境变量覆写配置 */
@@ -95,6 +101,10 @@ function loadEnvConfig() {
95
101
  );
96
102
  CFG.switchPortOnRecycle =
97
103
  String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
104
+ CFG.consecutiveTaskErrorsMax = Math.max(
105
+ 1,
106
+ int("COMMENTS_CONSECUTIVE_TASK_ERRORS_MAX", CFG.consecutiveTaskErrorsMax),
107
+ );
98
108
  }
99
109
 
100
110
  // ────────────────────────────────────────
@@ -106,32 +116,82 @@ function formatMemoryUsage(mem = process.memoryUsage()) {
106
116
  return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
107
117
  }
108
118
 
109
- function getBrowserMemory(port) {
119
+ /**
120
+ * 获取 Edge 浏览器进程的内存占用(跨平台:macOS/Linux/Windows)
121
+ */
122
+ function getBrowserMemory(port = null) {
110
123
  try {
111
124
  const platform = os.platform();
112
- if (platform !== "darwin" && platform !== "linux") return null;
113
- const procName = platform === "darwin" ? "Microsoft Edge" : "msedge";
114
- const c = procName[0];
115
- const rest = procName.slice(1);
116
125
  let cmd;
117
- if (port) {
118
- cmd = `ps aux | grep "[${c}]${rest}" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
119
- } else {
120
- cmd = `ps aux | grep "[${c}]${rest}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
126
+ if (platform === "darwin") {
127
+ if (port) {
128
+ cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
129
+ } else {
130
+ cmd =
131
+ 'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
132
+ }
133
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
134
+ const mb = parseInt(result, 10);
135
+ return mb > 0 ? mb : null;
136
+ } else if (platform === "linux") {
137
+ if (port) {
138
+ cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
139
+ } else {
140
+ cmd =
141
+ 'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
142
+ }
143
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
144
+ const mb = parseInt(result, 10);
145
+ return mb > 0 ? mb : null;
146
+ } else if (platform === "win32") {
147
+ const ps1Path = path.join(os.tmpdir(), `edge-mem-${Date.now()}.ps1`);
148
+ try {
149
+ let ps1Content;
150
+ if (port) {
151
+ ps1Content =
152
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
153
+ `$sum = 0; ` +
154
+ `foreach ($p in $procs) { ` +
155
+ ` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
156
+ ` $sum += $p.WorkingSetSize ` +
157
+ ` } ` +
158
+ `}; ` +
159
+ `Write-Output $sum`;
160
+ } else {
161
+ ps1Content =
162
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
163
+ `$sum = 0; ` +
164
+ `foreach ($p in $procs) { $sum += $p.WorkingSetSize }; ` +
165
+ `Write-Output $sum`;
166
+ }
167
+ writeFileSync(ps1Path, ps1Content);
168
+ cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`;
169
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
170
+ try {
171
+ unlinkSync(ps1Path);
172
+ } catch {}
173
+ const bytes = parseInt(result, 10);
174
+ const mb = bytes > 0 ? Math.round(bytes / 1048576) : 0;
175
+ return mb > 0 ? mb : null;
176
+ } catch {
177
+ try {
178
+ unlinkSync(ps1Path);
179
+ } catch {}
180
+ return null;
181
+ }
121
182
  }
122
- const mb = parseInt(execSync(cmd, { timeout: 5000 }).toString().trim(), 10);
123
- return mb > 0 ? mb : null;
183
+ return null;
124
184
  } catch {
125
185
  return null;
126
186
  }
127
187
  }
128
188
 
129
- function formatMemoryWithBrowser(port) {
189
+ function formatMemoryWithBrowser(port = null) {
130
190
  const nodeMem = formatMemoryUsage();
131
191
  const edgeMB = getBrowserMemory(port);
132
192
  if (!edgeMB) return nodeMem;
133
- const label = port ? ` edge:${port}:${edgeMB}MB` : ` edge:${edgeMB}MB`;
134
- return nodeMem + label;
193
+ const portLabel = port ? `:${port}` : "";
194
+ return `${nodeMem} edge${portLabel}:${edgeMB}MB`;
135
195
  }
136
196
 
137
197
  // ────────────────────────────────────────
@@ -175,7 +235,43 @@ function getPage(browser) {
175
235
  }
176
236
 
177
237
  // ────────────────────────────────────────
178
- // Section 4: CDP 会话管理(端口池/浏览器/页面/内存回收)
238
+ // Section 4: 页面请求拦截(独立函数,供自动/手动模式共用)
239
+ // ────────────────────────────────────────
240
+
241
+ const _blockedPages = new WeakSet();
242
+
243
+ async function setupPageBlocking(page) {
244
+ if (!page || _blockedPages.has(page)) return;
245
+ await page.route("**/*", (route) => {
246
+ const req = route.request();
247
+ const t = req.resourceType();
248
+ const url = req.url();
249
+
250
+ // 资源类型拦截
251
+ if (t === "image" || t === "stylesheet" || t === "media") {
252
+ return route.abort();
253
+ }
254
+
255
+ // 视频流拦截(TikTok 视频通过 fetch/xhr 加载 mp4 分片,
256
+ // resourceType 为 fetch/xhr,需按 URL 模式匹配)
257
+ // 真实 URL:
258
+ // v16-webapp-prime.us.tiktok.com/video/tos/...?mime_type=video_mp4
259
+ // v16m-webapp.tiktokcdn-us.com/.../video/tos/...?mime_type=video_mp4
260
+ if (
261
+ /webapp.*(tiktok\.com|tiktokcdn).*\/video\/tos\//i.test(url) ||
262
+ /mime_type=video_mp4/i.test(url) ||
263
+ /\.(mp4|m3u8|ts)(\?|$)/i.test(url)
264
+ ) {
265
+ return route.abort();
266
+ }
267
+
268
+ route.continue();
269
+ });
270
+ _blockedPages.add(page);
271
+ }
272
+
273
+ // ────────────────────────────────────────
274
+ // Section 5: CDP 会话管理(端口池/浏览器/页面/内存回收)
179
275
  // ────────────────────────────────────────
180
276
 
181
277
  class CdpSessionManager {
@@ -187,7 +283,8 @@ class CdpSessionManager {
187
283
  this.browser = null;
188
284
  this.page = null;
189
285
  this.lastRecycleAt = 0;
190
- this._blockedPages = new WeakSet();
286
+ this._lastMemCheck = 0;
287
+ this._shuttingDown = false;
191
288
  }
192
289
 
193
290
  pickNextPort() {
@@ -211,7 +308,7 @@ class CdpSessionManager {
211
308
  /** 获取或恢复 page */
212
309
  async _ensurePage() {
213
310
  let p = getPage(this.browser);
214
- if (p) await this._setupBlocking(p);
311
+ if (p) await setupPageBlocking(p);
215
312
  return p;
216
313
  }
217
314
 
@@ -222,17 +319,6 @@ class CdpSessionManager {
222
319
  return this.page;
223
320
  }
224
321
 
225
- /** 页面请求拦截:屏蔽图片/样式减少内存 */
226
- async _setupBlocking(page) {
227
- if (!page || this._blockedPages.has(page)) return;
228
- await page.route("**/*", (route) => {
229
- const t = route.request().resourceType();
230
- if (t === "image" || t === "stylesheet") route.abort();
231
- else route.continue();
232
- });
233
- this._blockedPages.add(page);
234
- }
235
-
236
322
  /** 轻量回收:清空当前 tab */
237
323
  async recyclePage() {
238
324
  if (!this.page || this.page.isClosed()) {
@@ -278,8 +364,12 @@ class CdpSessionManager {
278
364
  this.lastRecycleAt = Date.now();
279
365
  }
280
366
 
281
- /** 内存检查 + 按需回收 */
367
+ /** 内存检查 + 按需回收(每 30s 最多采样一次) */
282
368
  async maybeRecycleForMemory(processedCount) {
369
+ const now = Date.now();
370
+ if (now - this._lastMemCheck < 30000) return;
371
+ this._lastMemCheck = now;
372
+
283
373
  const mem = process.memoryUsage();
284
374
  const rssMB = mem.rss / 1024 / 1024;
285
375
  const heapMB = mem.heapUsed / 1024 / 1024;
@@ -334,6 +424,8 @@ class CdpSessionManager {
334
424
  }
335
425
 
336
426
  async shutdown() {
427
+ if (this._shuttingDown) return;
428
+ this._shuttingDown = true;
337
429
  if (this.page) await this.page.close().catch(() => {});
338
430
  await this.browser?.close().catch(() => {});
339
431
  killEdgeProcesses(null, this.port);
@@ -341,7 +433,7 @@ class CdpSessionManager {
341
433
  }
342
434
 
343
435
  // ────────────────────────────────────────
344
- // Section 5: 共享操作(视频页准备 & 评论提取 & 保存)
436
+ // Section 6: 共享操作(视频页准备 & 评论提取 & 保存)
345
437
  // ────────────────────────────────────────
346
438
 
347
439
  async function prepareVideoPage(page, href) {
@@ -390,13 +482,12 @@ function extractUniqueAuthors(comments) {
390
482
  ];
391
483
  }
392
484
 
393
- async function saveAuthorsToServer(serverUrl, authors, guessedLocation) {
394
- const { apiPost } = createApiClient({
395
- checkStatus: true,
396
- maxRetries: 2,
397
- backoff: 2000,
398
- log: true,
399
- });
485
+ async function saveAuthorsToServer(
486
+ apiPost,
487
+ serverUrl,
488
+ authors,
489
+ guessedLocation,
490
+ ) {
400
491
  return apiPost(`${serverUrl}/api/users`, {
401
492
  usernames: authors.map((a) => "@" + a),
402
493
  sources: ["comment"],
@@ -405,7 +496,7 @@ async function saveAuthorsToServer(serverUrl, authors, guessedLocation) {
405
496
  }
406
497
 
407
498
  // ────────────────────────────────────────
408
- // Section 6: 自动模式 — 循环从服务端取任务、抓评论、提交
499
+ // Section 7: 自动模式 — 循环从服务端取任务、抓评论、提交
409
500
  // ────────────────────────────────────────
410
501
 
411
502
  async function runAutoMode(options) {
@@ -426,7 +517,14 @@ async function runAutoMode(options) {
426
517
 
427
518
  loadEnvConfig();
428
519
 
429
- const { apiGet, apiPut } = createApiClient({
520
+ // ── 设备标识 ──
521
+ let userId = configuredUserId;
522
+ if (!userId) {
523
+ userId = await getMacOrUuid();
524
+ saveUserId(userId);
525
+ }
526
+
527
+ const { apiGet, apiPost, apiPut } = createApiClient({
430
528
  checkStatus: true,
431
529
  maxRetries: 2,
432
530
  backoff: 2000,
@@ -441,7 +539,8 @@ async function runAutoMode(options) {
441
539
  browserRestartCount = 0;
442
540
  let consecutiveErrors = 0,
443
541
  consecutive403 = 0,
444
- consecutiveCaptchaCount = 0;
542
+ consecutiveCaptchaCount = 0,
543
+ consecutiveTaskErrors = 0;
445
544
 
446
545
  // ── CDP 会话 ──
447
546
  const session = new CdpSessionManager({
@@ -617,6 +716,16 @@ async function runAutoMode(options) {
617
716
  });
618
717
 
619
718
  if (captchaResult.detected) {
719
+ // 上报验证码错误
720
+ apiPost(`${serverUrl}/api/error-report`, {
721
+ userId,
722
+ username: "",
723
+ errorType: "captcha",
724
+ errorMessage: resultNote || "评论采集时出现验证码",
725
+ stage: "comments",
726
+ errorStack: "",
727
+ }).catch(() => {});
728
+
620
729
  if (captchaResult.resolved) {
621
730
  resultNote = "验证码已解决";
622
731
  consecutiveCaptchaCount = 0;
@@ -648,6 +757,7 @@ async function runAutoMode(options) {
648
757
 
649
758
  if (authors.length > 0) {
650
759
  const saveResult = await saveAuthorsToServer(
760
+ apiPost,
651
761
  serverUrl,
652
762
  authors,
653
763
  loc,
@@ -669,6 +779,7 @@ async function runAutoMode(options) {
669
779
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
670
780
  processedCount++;
671
781
  consecutive403 = 0;
782
+ consecutiveTaskErrors = 0;
672
783
 
673
784
  // 每 10 个任务输出内存汇总
674
785
  if (totalTasks() % 10 === 0) {
@@ -678,6 +789,7 @@ async function runAutoMode(options) {
678
789
  }
679
790
  } catch (err) {
680
791
  errorCount++;
792
+ consecutiveTaskErrors++;
681
793
  const isBrowserClosed = isBrowserClosedError(err);
682
794
  const is403 =
683
795
  err.message &&
@@ -703,6 +815,22 @@ async function runAutoMode(options) {
703
815
  } else {
704
816
  console.error(` ${err.message}`);
705
817
  }
818
+
819
+ // 连续任务错误达到阈值,汇报服务端
820
+ if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
821
+ console.error(
822
+ ` ⚠️ 连续任务错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
823
+ );
824
+ apiPost(`${serverUrl}/api/error-report`, {
825
+ userId,
826
+ username: "",
827
+ errorType: "consecutive_task_errors",
828
+ errorMessage: `连续 ${consecutiveTaskErrors} 个任务处理失败 (port=${session.port})`,
829
+ stage: "comments",
830
+ errorStack: "",
831
+ }).catch(() => {});
832
+ consecutiveTaskErrors = 0;
833
+ }
706
834
  }
707
835
 
708
836
  // 任务间内存检查
@@ -710,6 +838,7 @@ async function runAutoMode(options) {
710
838
  }
711
839
  } catch (err) {
712
840
  errorCount++;
841
+ consecutiveTaskErrors++;
713
842
  if (isBrowserClosedError(err)) {
714
843
  browserRestartCount++;
715
844
  console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
@@ -717,6 +846,22 @@ async function runAutoMode(options) {
717
846
  } else {
718
847
  console.error(` × ${err.message}`);
719
848
  }
849
+
850
+ // 连续轮间错误达到阈值,汇报服务端
851
+ if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
852
+ console.error(
853
+ ` ⚠️ 连续轮间错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
854
+ );
855
+ apiPost(`${serverUrl}/api/error-report`, {
856
+ userId,
857
+ username: "",
858
+ errorType: "consecutive_task_errors",
859
+ errorMessage: `连续 ${consecutiveTaskErrors} 个轮次处理失败 (port=${session.port})`,
860
+ stage: "comments",
861
+ errorStack: "",
862
+ }).catch(() => {});
863
+ consecutiveTaskErrors = 0;
864
+ }
720
865
  }
721
866
 
722
867
  // 轮间内存检查 + 等待
@@ -734,7 +879,7 @@ async function runAutoMode(options) {
734
879
  }
735
880
 
736
881
  // ────────────────────────────────────────
737
- // Section 7: 手动模式 — 单视频评论提取 + 输出/保存
882
+ // Section 8: 手动模式 — 单视频评论提取 + 输出/保存
738
883
  // ────────────────────────────────────────
739
884
 
740
885
  async function runManualMode(url, options) {
@@ -742,17 +887,24 @@ async function runManualMode(url, options) {
742
887
  maxComments = CFG.maxComments,
743
888
  save: shouldSave,
744
889
  serverUrl = defaultServer,
890
+ basePort = 9222,
891
+ proxy: proxyOption,
745
892
  } = options;
893
+ const effectiveProxy = proxyOption || configuredProxy;
746
894
 
747
895
  let browser;
748
896
  try {
749
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
897
+ const { chromium } = await import("playwright");
898
+ browser = await chromium.connectOverCDP(`http://127.0.0.1:${basePort}`);
750
899
  const page = getPage(browser);
751
900
  if (!page) {
752
901
  console.error("未找到可用页面");
753
902
  process.exit(1);
754
903
  }
755
904
 
905
+ // 拦截图片/视频请求
906
+ await setupPageBlocking(page);
907
+
756
908
  console.error(`正在打开: ${url}`);
757
909
  await prepareVideoPage(page, url);
758
910
 
@@ -788,7 +940,14 @@ async function runManualMode(url, options) {
788
940
  if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
789
941
 
790
942
  try {
943
+ const { apiPost } = createApiClient({
944
+ checkStatus: true,
945
+ maxRetries: 2,
946
+ backoff: 2000,
947
+ log: true,
948
+ });
791
949
  const saveResult = await saveAuthorsToServer(
950
+ apiPost,
792
951
  serverUrl,
793
952
  authors,
794
953
  guessedLocation,
@@ -834,7 +993,7 @@ async function runManualMode(url, options) {
834
993
  }
835
994
 
836
995
  // ────────────────────────────────────────
837
- // Section 8: 入口 — handleComments
996
+ // Section 9: 入口 — handleComments
838
997
  // ────────────────────────────────────────
839
998
 
840
999
  export async function handleComments(options) {
@@ -866,5 +1025,7 @@ export async function handleComments(options) {
866
1025
  maxComments: commentsMax,
867
1026
  save: commentsSave,
868
1027
  serverUrl: commentsServer || defaultServer,
1028
+ basePort: commentsBasePort,
1029
+ proxy: commentsProxy,
869
1030
  });
870
1031
  }
package/src/cli/tag.js CHANGED
@@ -17,7 +17,14 @@ import { discoverTags } from "../lib/tag-discover.js";
17
17
  import {
18
18
  server as cfgServer,
19
19
  proxy as configuredProxy,
20
+ userId as configuredUserId,
21
+ saveUserId,
20
22
  } from "../lib/constants.js";
23
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
24
+ import {
25
+ waitAndGetCaptcha,
26
+ closeCaptcha,
27
+ } from "../scraper/modules/captcha-handler.js";
21
28
 
22
29
  const ALL_COUNTRIES = DEFAULT_TARGET_LOCATIONS;
23
30
  const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
@@ -688,6 +695,13 @@ export async function handleScoreAll(parsed) {
688
695
  log(`📊 启动内存: ${formatMemoryWithBrowser(cdpPort)}`);
689
696
  log("");
690
697
 
698
+ // ── 设备标识 ──
699
+ let userId = configuredUserId;
700
+ if (!userId) {
701
+ userId = await getMacOrUuid();
702
+ saveUserId(userId);
703
+ }
704
+
691
705
  // 连接 CDP 浏览器
692
706
  const cdpOpts = { port: cdpPort };
693
707
  if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
@@ -850,6 +864,8 @@ export async function handleScoreAll(parsed) {
850
864
 
851
865
  let totalScored = 0;
852
866
  let emptyRounds = 0; // 连续无任务的轮数
867
+ let consecutiveCaptcha = 0;
868
+ const MAX_CONSECUTIVE_CAPTCHA = 3;
853
869
  let consecutive403 = 0; // 连续 HTTP 403 次数,达 3 次认为被封
854
870
  const CONSECUTIVE_403_BLOCKED = 3; // 连续 3 次 403 认为账号被封
855
871
 
@@ -1098,6 +1114,57 @@ export async function handleScoreAll(parsed) {
1098
1114
  }
1099
1115
 
1100
1116
  log(` ❌ 失败: ${e.message}`);
1117
+
1118
+ // ── 验证码检测 ──
1119
+ const isCaptcha =
1120
+ e.message &&
1121
+ (e.message.includes("验证码") || e.message.includes("captcha"));
1122
+
1123
+ if (isCaptcha) {
1124
+ consecutiveCaptcha++;
1125
+ log(
1126
+ ` 🔐 验证码 (${consecutiveCaptcha}/${MAX_CONSECUTIVE_CAPTCHA}),等待用户手动解决...`,
1127
+ );
1128
+
1129
+ // 上报验证码错误
1130
+ try {
1131
+ await fetch(`${baseUrl}/api/error-report`, {
1132
+ method: "POST",
1133
+ headers: buildClientHeaders(clientId, clientMeta, {
1134
+ "Content-Type": "application/json",
1135
+ }),
1136
+ body: JSON.stringify({
1137
+ userId,
1138
+ username: "",
1139
+ errorType: "captcha",
1140
+ errorMessage: e.message,
1141
+ stage: "tag-scoring",
1142
+ errorStack: "",
1143
+ }),
1144
+ });
1145
+ } catch {}
1146
+
1147
+ // 等待用户手动解决验证码(最多 3 分钟)
1148
+ try {
1149
+ const captchaResult = await waitAndGetCaptcha(page, {
1150
+ waitMs: 180000,
1151
+ pollInterval: 5000,
1152
+ log: () => {},
1153
+ });
1154
+ if (captchaResult.detected && !captchaResult.resolved) {
1155
+ await closeCaptcha(page).catch(() => {});
1156
+ }
1157
+ } catch {}
1158
+
1159
+ if (consecutiveCaptcha >= MAX_CONSECUTIVE_CAPTCHA) {
1160
+ log(` ♻️ 连续${MAX_CONSECUTIVE_CAPTCHA}次验证码 → 切换端口`);
1161
+ await recycleCdpSession("连续验证码", { switchPort: true });
1162
+ consecutiveCaptcha = 0;
1163
+ }
1164
+ } else {
1165
+ consecutiveCaptcha = 0;
1166
+ }
1167
+
1101
1168
  try {
1102
1169
  // 使用当前正在处理的 tag(tag 变量在 try 块开头已赋值,catch 中仍可访问)
1103
1170
  const failedTag = typeof tag === "string" && tag ? tag : "unknown";
@@ -500,6 +500,8 @@ export function createStore(filePath, options = {}) {
500
500
  llmScore: false,
501
501
  llmMinScore: 60,
502
502
  llmSampleSize: 100,
503
+ llmTagRatio: 0.4,
504
+ llmCommentRatio: 0.3,
503
505
  ...options.refillLlm,
504
506
  };
505
507
 
@@ -957,27 +959,30 @@ export function createStore(filePath, options = {}) {
957
959
  return { moved: 0 };
958
960
  }
959
961
 
960
- // 如果启用 LLM 打分:先取 tag(一次性),再取非 tag 走 LLM 打分
962
+ // 如果启用 LLM 打分:tag 和 comment 免打分直接入围,其余走 LLM 打分
961
963
  if (useLlm && normalizedLocations && normalizedLocations.length > 0) {
962
964
  const llmTotal = options.llmTotal ?? 200; // 总条数
963
- const llmTagRatio = options.llmTagRatio ?? 0.6; // tag 占比 60%
964
- const llmTagLimit = Math.floor(llmTotal * llmTagRatio); // tag 上限 120
965
- const llmNonTagTarget = llmTotal - llmTagLimit; // 非 tag 目标 80
965
+ const llmTagRatio = options.llmTagRatio ?? 0.4; // tag 占比 40%
966
+ const llmCommentRatio = options.llmCommentRatio ?? 0.3; // comment 占比 30%
967
+ const llmTagLimit = Math.floor(llmTotal * llmTagRatio); // tag 上限
968
+ const llmCommentLimit = Math.floor(llmTotal * llmCommentRatio); // comment 上限
969
+ const llmLLMTarget = llmTotal - llmTagLimit - llmCommentLimit; // LLM 打分目标
966
970
  const llmMinScore = options.llmMinScore ?? 60;
967
971
  const llmSampleSize = options.llmSampleSize ?? 100;
968
972
  const maxBatches = options.llmMaxBatches ?? 10;
969
973
 
970
974
  console.error(
971
- `[data-store] LLM 打分开始: 总目标 ${llmTotal} 条,tag 最多 ${llmTagLimit} 条(一次性),非 tag 目标 ${llmNonTagTarget} 条(LLM 打分)`,
975
+ `[data-store] LLM 调度开始: 总目标 ${llmTotal} 条,tag ${llmTagLimit} 条,comment ${llmCommentLimit} 条(免打分),LLM 打分 ${llmLLMTarget} 条`,
972
976
  );
973
977
 
974
978
  // 返回 Promise,调用方需要 await
975
979
  return (async () => {
976
980
  const allTagQualified = [];
977
- const allNonTagQualified = [];
981
+ const allCommentQualified = [];
982
+ const allLLMQualified = [];
978
983
  const allScores = [];
979
984
 
980
- // ===== 第一步:一次性取所有 tag(全局,最多 llmTagLimit 条)=====
985
+ // ===== 第一步:一次性取 tag(全局,最多 llmTagLimit 条,免打分)=====
981
986
  let tagOffset = offsetStore.get("_tag") || 0;
982
987
  const tagGlobalCount =
983
988
  getDb()
@@ -1025,43 +1030,97 @@ export function createStore(filePath, options = {}) {
1025
1030
 
1026
1031
  offsetStore.set("_tag", tagOffset);
1027
1032
 
1028
- // ===== 第二步:按国家取非 tag,走 LLM 打分,直到合格数达到 llmNonTagTarget =====
1033
+ // ===== 第二步:一次性取 comment(全局,最多 llmCommentLimit 条,免打分)=====
1034
+ let commentOffset = offsetStore.get("_comment") || 0;
1035
+ const commentGlobalCount =
1036
+ getDb()
1037
+ .prepare(
1038
+ `SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND sources LIKE '%comment%' AND sources NOT LIKE '%tag%'`,
1039
+ )
1040
+ .get(...args)?.c || 0;
1041
+
1042
+ if (commentOffset >= commentGlobalCount) {
1043
+ commentOffset = 0;
1044
+ offsetStore.set("_comment", 0);
1045
+ }
1046
+
1047
+ console.error(
1048
+ `[data-store] Comment 全局共 ${commentGlobalCount} 条,从偏移量 ${commentOffset} 开始`,
1049
+ );
1050
+
1051
+ while (
1052
+ allCommentQualified.length < llmCommentLimit &&
1053
+ commentOffset < commentGlobalCount
1054
+ ) {
1055
+ const batch = getDb()
1056
+ .prepare(
1057
+ `
1058
+ SELECT * FROM raw_jobs WHERE ${whereSql} AND sources LIKE '%comment%' AND sources NOT LIKE '%tag%'
1059
+ ORDER BY COALESCE(video_count, 0) DESC, created_at DESC
1060
+ LIMIT ? OFFSET ?
1061
+ `,
1062
+ )
1063
+ .all(
1064
+ ...args,
1065
+ Math.min(
1066
+ llmSampleSize,
1067
+ llmCommentLimit - allCommentQualified.length,
1068
+ ),
1069
+ commentOffset,
1070
+ );
1071
+
1072
+ if (!batch.length) break;
1073
+
1074
+ allCommentQualified.push(...batch.map((s) => s.unique_id));
1075
+ commentOffset += batch.length;
1076
+
1077
+ console.error(
1078
+ `[data-store] Comment 本批 ${batch.length} 条,累计 ${allCommentQualified.length}/${llmCommentLimit}`,
1079
+ );
1080
+ }
1081
+
1082
+ offsetStore.set("_comment", commentOffset);
1083
+
1084
+ // ===== 第三步:按国家取非 tag 非 comment,走 LLM 打分,直到合格数达到 llmLLMTarget =====
1029
1085
  for (const location of normalizedLocations) {
1030
- if (allNonTagQualified.length >= llmNonTagTarget) break;
1086
+ if (allLLMQualified.length >= llmLLMTarget) break;
1031
1087
 
1032
- const nonTagOffsetKey = `${location}:nonTag`;
1033
- let offset = offsetStore.get(nonTagOffsetKey) || 0;
1088
+ const llmOffsetKey = `${location}:llm`;
1089
+ let offset = offsetStore.get(llmOffsetKey) || 0;
1034
1090
 
1035
1091
  const locationArgs = [...args, location];
1036
- const nonTagCount =
1092
+ const llmCount =
1037
1093
  getDb()
1038
1094
  .prepare(
1039
- `SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND guessed_location = ? AND (sources NOT LIKE '%tag%' OR sources IS NULL)`,
1095
+ `SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND guessed_location = ? AND (sources NOT LIKE '%tag%' OR sources IS NULL) AND (sources NOT LIKE '%comment%' OR sources IS NULL)`,
1040
1096
  )
1041
1097
  .get(...locationArgs)?.c || 0;
1042
1098
 
1043
- if (nonTagCount === 0) {
1044
- console.error(`[data-store] 国家 ${location}: 无非 tag 数据,跳过`);
1099
+ if (llmCount === 0) {
1100
+ console.error(
1101
+ `[data-store] 国家 ${location}: 无 LLM 打分候选数据,跳过`,
1102
+ );
1045
1103
  continue;
1046
1104
  }
1047
1105
 
1048
- if (offset >= nonTagCount) {
1106
+ if (offset >= llmCount) {
1049
1107
  offset = 0;
1050
- offsetStore.set(nonTagOffsetKey, 0);
1108
+ offsetStore.set(llmOffsetKey, 0);
1051
1109
  }
1052
1110
 
1053
1111
  console.error(
1054
- `[data-store] 国家 ${location}: 非 tag 共 ${nonTagCount} 条,从偏移量 ${offset} 开始`,
1112
+ `[data-store] 国家 ${location}: LLM 候选共 ${llmCount} 条,从偏移量 ${offset} 开始`,
1055
1113
  );
1056
1114
 
1057
1115
  for (let batch = 0; batch < maxBatches; batch++) {
1058
- if (allNonTagQualified.length >= llmNonTagTarget) break;
1116
+ if (allLLMQualified.length >= llmLLMTarget) break;
1059
1117
 
1060
1118
  const samples = getDb()
1061
1119
  .prepare(
1062
1120
  `
1063
1121
  SELECT * FROM raw_jobs WHERE ${whereSql} AND guessed_location = ?
1064
1122
  AND (sources NOT LIKE '%tag%' OR sources IS NULL)
1123
+ AND (sources NOT LIKE '%comment%' OR sources IS NULL)
1065
1124
  ORDER BY COALESCE(video_count, 0) DESC, created_at DESC
1066
1125
  LIMIT ? OFFSET ?
1067
1126
  `,
@@ -1075,24 +1134,28 @@ export function createStore(filePath, options = {}) {
1075
1134
  DEFAULT_TARGET_LOCATIONS,
1076
1135
  );
1077
1136
  const qualified = scores.filter((s) => s.score >= llmMinScore);
1078
- allNonTagQualified.push(...qualified.map((s) => s.uniqueId));
1137
+ allLLMQualified.push(...qualified.map((s) => s.uniqueId));
1079
1138
  allScores.push(...scores);
1080
1139
 
1081
1140
  offset += samples.length;
1082
- offsetStore.set(nonTagOffsetKey, offset);
1141
+ offsetStore.set(llmOffsetKey, offset);
1083
1142
 
1084
1143
  console.error(
1085
- `[data-store] ${location} 第 ${batch + 1} 批: 采样 ${samples.length} 条,本批合格 ${qualified.length} 条,非 tag 累计 ${allNonTagQualified.length}/${llmNonTagTarget}`,
1144
+ `[data-store] ${location} 第 ${batch + 1} 批: 采样 ${samples.length} 条,本批合格 ${qualified.length} 条,LLM 累计 ${allLLMQualified.length}/${llmLLMTarget}`,
1086
1145
  );
1087
1146
  }
1088
1147
  }
1089
1148
 
1090
1149
  // ===== 最终结果 =====
1091
- const qualified = [...allTagQualified, ...allNonTagQualified];
1150
+ const qualified = [
1151
+ ...allTagQualified,
1152
+ ...allCommentQualified,
1153
+ ...allLLMQualified,
1154
+ ];
1092
1155
 
1093
1156
  if (!qualified.length) {
1094
1157
  console.error(
1095
- `[data-store] LLM 打分后无符合条件的任务(tag: ${allTagQualified.length}, 非 tag: ${allNonTagQualified.length})`,
1158
+ `[data-store] LLM 调度后无符合条件的任务(tag: ${allTagQualified.length}, comment: ${allCommentQualified.length}, LLM: ${allLLMQualified.length})`,
1096
1159
  );
1097
1160
  return {
1098
1161
  moved: 0,
@@ -1103,7 +1166,7 @@ export function createStore(filePath, options = {}) {
1103
1166
  }
1104
1167
 
1105
1168
  console.error(
1106
- `[data-store] LLM 打分完成: tag ${allTagQualified.length} 条 + 非 tag ${allNonTagQualified.length} 条 = 共 ${qualified.length} 条`,
1169
+ `[data-store] LLM 调度完成: tag ${allTagQualified.length} + comment ${allCommentQualified.length} + LLM ${allLLMQualified.length} = 共 ${qualified.length} 条`,
1107
1170
  );
1108
1171
 
1109
1172
  // 移动符合条件的记录
@@ -1181,6 +1244,7 @@ export function createStore(filePath, options = {}) {
1181
1244
  WHERE __WHERE__
1182
1245
  ORDER BY
1183
1246
  CASE WHEN sources LIKE '%tag%' THEN 0 ELSE 1 END,
1247
+ CASE WHEN sources LIKE '%comment%' THEN 0 ELSE 1 END,
1184
1248
  COALESCE(video_count, 0) DESC, created_at DESC
1185
1249
  LIMIT ?
1186
1250
  `;
@@ -1192,6 +1256,7 @@ export function createStore(filePath, options = {}) {
1192
1256
  WHERE __WHERE__
1193
1257
  ORDER BY
1194
1258
  CASE WHEN sources LIKE '%tag%' THEN 0 ELSE 1 END,
1259
+ CASE WHEN sources LIKE '%comment%' THEN 0 ELSE 1 END,
1195
1260
  COALESCE(video_count, 0) DESC, created_at DESC
1196
1261
  LIMIT ?
1197
1262
  )