tt-help-cli-ycl 1.4.147 → 1.4.148

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.147",
3
+ "version": "1.4.148",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/attach.js CHANGED
@@ -143,12 +143,17 @@ export async function handleAttach(options) {
143
143
 
144
144
  if (webserverUrl) {
145
145
  attachLog(`[Attach] webserver 模式: ${webserverUrl}`);
146
+ // webserver 端共享 scraper(poolSize 默认 8,每 slot 串行),当 -p 并发数大于
147
+ // poolSize 时,请求会在 webserver 内排队,端到端耗时可达数分钟(实测 270~310s)。
148
+ // 30s 超时太短会让整批任务被误判失败(aborted due to timeout),故放宽到 10 分钟
149
+ // 作为兜底——webserver 进程若真的挂了,连接会立即断开报错,不会真的空等 10 分钟。
150
+ const WEBSERVER_FETCH_TIMEOUT_MS = 600000;
146
151
  getUserInfo = async (uniqueId) => {
147
152
  const res = await fetch(`${webserverUrl}/api/tiktok/user`, {
148
153
  method: "POST",
149
154
  headers: { "Content-Type": "application/json" },
150
155
  body: JSON.stringify({ uniqueId }),
151
- signal: AbortSignal.timeout(30000),
156
+ signal: AbortSignal.timeout(WEBSERVER_FETCH_TIMEOUT_MS),
152
157
  });
153
158
  let data = null;
154
159
  try {
@@ -342,29 +342,68 @@ export async function handleProductFill(options) {
342
342
  };
343
343
 
344
344
  /** CDP 直抓:验证码在已登录浏览器中弹出时,等待人工解决后自动重试 */
345
- const fetchViaCDP = async (seoUrl) => {
345
+ const fetchViaCDP = async (seoUrl, uniqueId) => {
346
346
  try {
347
347
  return await getProductInfoCDP(page, seoUrl);
348
348
  } catch (e) {
349
349
  if (!e.message?.includes("Blocked by TikTok")) throw e;
350
- // 验证码:优先检测可见容器并等待人工解决(浏览器就在本机屏幕上)
351
- const cap = await detectCaptcha(page);
352
- if (cap?.exists) {
353
- log(" ⏳ 检测到验证码,请在已登录的浏览器窗口中手动解决...");
350
+ // 撞码即向服务端报一次(errorType: captcha),之后无论人工解决、
351
+ // 自动放行还是超时冷却,本任务流程不再重复上报
352
+ await apiPost(`${serverUrl}/api/error-report`, {
353
+ userId,
354
+ username: uniqueId,
355
+ errorType: "captcha",
356
+ errorMessage: e.message,
357
+ stage: "product-fill",
358
+ }).catch(() => {});
359
+ // 验证码拦截统一等待(上限 3 分钟):容器可能稍后才渲染(HTML 已含
360
+ // 标记、DOM 未挂载),也可能全程不出现(风控软拦/结构变化)。
361
+ // 轮询期间容器出现 → 交人工解决;容器未出现但页面恢复出商品数据 →
362
+ // 直接重试;到点仍拦截 → 抛 captcha 错误走 30s 冷却
363
+ log(
364
+ ` ⏳ 验证码拦截,等待处理(上限 ${CDP_CAPTCHA_WAIT_MS / 1000}s,可在浏览器中人工解决)...`,
365
+ );
366
+ const deadline = Date.now() + CDP_CAPTCHA_WAIT_MS;
367
+ let cap = await detectCaptcha(page);
368
+ while (!cap && Date.now() < deadline) {
369
+ await new Promise((r) => setTimeout(r, 5000));
370
+ cap = await detectCaptcha(page);
371
+ if (!cap) {
372
+ // 容器始终没出现:探测页面是否已自动放行
373
+ const html = await page.content().catch(() => "");
374
+ if (
375
+ html.includes('"product_model"') ||
376
+ html.includes('"review_ratings"')
377
+ ) {
378
+ log(" ✓ 拦截已自动解除,重新抓取...");
379
+ return getProductInfoCDP(page, seoUrl);
380
+ }
381
+ }
382
+ }
383
+ if (cap) {
384
+ log(" ⏳ 请在已登录的浏览器窗口中手动解决验证码...");
354
385
  const r = await waitAndGetCaptcha(page, {
355
- waitMs: CDP_CAPTCHA_WAIT_MS,
386
+ waitMs: Math.max(0, deadline - Date.now()),
356
387
  pollInterval: 5000,
357
388
  log,
358
389
  });
359
390
  if (r.resolved) {
360
391
  log(" ✓ 验证码已解决,重新抓取...");
361
- return getProductInfoCDP(page, seoUrl);
392
+ try {
393
+ return await getProductInfoCDP(page, seoUrl);
394
+ } catch (retryErr) {
395
+ // 重试又撞码:仍标记 captcha 走冷却,避免误计 noteFail 哨兵
396
+ if (retryErr.message?.includes("Blocked by TikTok")) {
397
+ retryErr.captcha = true;
398
+ }
399
+ throw retryErr;
400
+ }
362
401
  }
363
402
  }
364
403
  const err = new Error(
365
- cap?.exists
404
+ cap
366
405
  ? "验证码等待人工解决超时"
367
- : "验证码拦截(无可见验证码容器)",
406
+ : "验证码拦截(3 分钟内未出现容器且未自动解除)",
368
407
  );
369
408
  err.captcha = true;
370
409
  throw err;
@@ -372,10 +411,10 @@ export async function handleProductFill(options) {
372
411
  };
373
412
 
374
413
  /** 抓取商品详情,带浏览器断连恢复与空数据防御 */
375
- const getTaskDetail = async (seoUrl, country) => {
414
+ const getTaskDetail = async (seoUrl, country, uniqueId) => {
376
415
  let detail;
377
416
  try {
378
- detail = await fetchViaCDP(seoUrl);
417
+ detail = await fetchViaCDP(seoUrl, uniqueId);
379
418
  } catch (e) {
380
419
  if (isBrowserDisconnected(browser, e)) {
381
420
  // 确凿断连(CDP 断开 / 明确关闭消息)才重启浏览器
@@ -399,7 +438,7 @@ export async function handleProductFill(options) {
399
438
  err.retryable = true;
400
439
  throw err;
401
440
  }
402
- detail = await fetchViaCDP(seoUrl);
441
+ detail = await fetchViaCDP(seoUrl, uniqueId);
403
442
  } else if (isNetworkError(e)) {
404
443
  // 网络层错误:代理断连/Clash 切节点,浏览器没死,不杀浏览器,
405
444
  // 记为可重试错误跳过本轮,下轮代理探测自然跟上
@@ -492,7 +531,7 @@ export async function handleProductFill(options) {
492
531
  }
493
532
  log(` → 补商品 @${uniqueId} ...`);
494
533
  try {
495
- const detail = await getTaskDetail(seoUrl, profile.country);
534
+ const detail = await getTaskDetail(seoUrl, profile.country, uniqueId);
496
535
  const post = await apiPost(
497
536
  `${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
498
537
  { productDetail: detail },
@@ -518,6 +557,9 @@ export async function handleProductFill(options) {
518
557
  log(
519
558
  ` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
520
559
  );
560
+ // 验证码是全局风控:立即终止本批次剩余任务,避免批次内
561
+ // 继续 goto 顶着风控连刷;30s 冷却在 while 顶部领取前生效
562
+ break;
521
563
  } else if (e.delisted) {
522
564
  // 壳页/下架:seller_id 为空且无销售数据 → 立即哨兵放弃
523
565
  batchSkipped++;
@@ -103,6 +103,9 @@ export function initDb(filePath) {
103
103
  db = new Database(dbPath);
104
104
  db.pragma("journal_mode = WAL");
105
105
  db.pragma("busy_timeout = 5000");
106
+ // 调大 SQLite 页缓存:主库 7.3GB,task_queue 等热表的回表若走磁盘 I/O 会非常慢
107
+ // (实测同规模数据 16MB 缓存 1.5s → 256MB 1.1s)。512MB 足以让 task_queue 全量驻留内存。
108
+ db.pragma("cache_size = -524288");
106
109
 
107
110
  // ===== users 表 =====
108
111
  db.exec(`
@@ -423,10 +426,11 @@ export function initDb(filePath) {
423
426
  `CREATE INDEX IF NOT EXISTS idx_jobs_list_done ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
424
427
  `CREATE INDEX IF NOT EXISTS idx_jobs_list_error ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'error'`,
425
428
  `CREATE INDEX IF NOT EXISTS idx_jobs_list_restricted ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'restricted'`,
426
- // jobs_done 冷归档表索引(done 列表 / 国家统计 / 重检)
429
+ // jobs_done 冷归档表索引(done 列表 / 国家统计 / 重检 / no_video 计数)
427
430
  `CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
428
431
  `CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)`,
429
432
  `CREATE INDEX IF NOT EXISTS idx_jobs_done_status_updated ON jobs_done(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
433
+ `CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1`,
430
434
  ];
431
435
  for (const sql of indexes) {
432
436
  db.exec(sql);
@@ -5286,21 +5286,20 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis, clientId) {
5286
5286
  styled(
5287
5287
  "span",
5288
5288
  "display:flex;gap:2px",
5289
- (isOnline
5290
- ? tag(
5291
- "button",
5292
- {
5293
- onclick:
5294
- "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
5295
- style:
5296
- "padding:1px 5px;font-size:11px;background:" +
5297
- (c.enabled ? "#f59e0b" : "#4ade80") +
5298
- ";color:#000;border:none;border-radius:2px;cursor:pointer",
5299
- title: c.enabled ? "停止" : "启动",
5300
- },
5301
- c.enabled ? "⏹" : "▶",
5302
- )
5303
- : "") +
5289
+ tag(
5290
+ "button",
5291
+ {
5292
+ onclick: "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
5293
+ style:
5294
+ "padding:1px 5px;font-size:11px;background:" +
5295
+ (c.enabled ? "#f59e0b" : "#4ade80") +
5296
+ ";color:#000;border:none;border-radius:2px;cursor:pointer",
5297
+ title:
5298
+ (c.enabled ? "停止" : "启动") +
5299
+ (isOnline ? "" : "(设备离线,上线后生效)"),
5300
+ },
5301
+ c.enabled ? "⏹" : "▶",
5302
+ ) +
5304
5303
  tag(
5305
5304
  "button",
5306
5305
  {
@@ -104,14 +104,16 @@ const STAT_GROUPS = {
104
104
  SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
105
105
  FROM jobs
106
106
  UNION ALL
107
+ -- jobs_done 冷归档表:全部是 done 且只增不改。改用覆盖索引/部分索引计数,
108
+ -- 避免对 147 万行全表回表(原 SUM(CASE WHEN no_video=1) 无法匹配索引)
107
109
  SELECT
108
- COUNT(*) as jobsTotal,
109
- COUNT(*) as done,
110
+ c.cnt as jobsTotal,
111
+ c.cnt as done,
110
112
  0 as error,
111
113
  0 as restricted,
112
114
  0 as targetUsers,
113
- SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
114
- FROM jobs_done
115
+ (SELECT COUNT(*) FROM jobs_done WHERE no_video = 1) as noVideo
116
+ FROM (SELECT COUNT(*) as cnt FROM jobs_done) c
115
117
  )`,
116
118
  },
117
119
  {
@@ -389,8 +391,15 @@ function computeStatsIncremental(st) {
389
391
  };
390
392
  }
391
393
 
392
- // 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast 组后台刷新
394
+ // 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast 组后台刷新。
395
+ // 节流:53 个采集进程高频领/交任务,若每次都失效会导致 fast 组几乎每 10s 全量重算,
396
+ // 把失效限制为至少间隔 FAST_DIRTY_MIN_INTERVAL,重算节奏由 fast TTL(30s) 主导。
397
+ let lastFastDirtyTime = 0;
398
+ const FAST_DIRTY_MIN_INTERVAL = 30 * 1000;
393
399
  function markFastStatsDirty() {
400
+ const now = Date.now();
401
+ if (now - lastFastDirtyTime < FAST_DIRTY_MIN_INTERVAL) return;
402
+ lastFastDirtyTime = now;
394
403
  groupState.fast.cacheTime = 0;
395
404
  }
396
405
 
@@ -579,6 +588,7 @@ export function startWatchServer(
579
588
  "CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status)",
580
589
  "CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'",
581
590
  "CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)",
591
+ "CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1",
582
592
  "CREATE INDEX IF NOT EXISTS idx_videos_user_update ON videos(user_update_count)",
583
593
  // product-fill 领任务查询的部分索引:只索引候选行,避免全表扫描 164 万行
584
594
  "CREATE INDEX IF NOT EXISTS idx_jobs_product_fill ON jobs(claimed_at, updated_at) WHERE top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND (product_name IS NULL OR product_name = '') AND product_rating IS NULL AND product_sold_count IS NULL",