tt-help-cli-ycl 1.4.144 → 1.4.146

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/lib/args.js CHANGED
@@ -894,15 +894,25 @@ function parseProductFillArgs(args) {
894
894
  let limit = 5;
895
895
  let interval = 10;
896
896
  let serverUrl = defaultServer;
897
- let webserverUrl = null;
898
897
  let proxyUrl = null;
898
+ /** 端口/国家配置:--profiles US/9223,JP/1222 → [{country:"US",port:9223},...] */
899
+ let profiles = [];
899
900
 
900
901
  for (let i = 0; i < args.length; i++) {
901
902
  const arg = args[i];
902
903
  if (arg === "-s" || arg === "--server") {
903
904
  serverUrl = args[++i];
904
- } else if (arg === "-w" || arg === "--webserver") {
905
- webserverUrl = args[++i];
905
+ } else if (arg === "--profiles") {
906
+ for (const seg of String(args[++i] || "").split(",")) {
907
+ const [country, portStr] = seg.trim().split("/");
908
+ const port = parseInt(portStr, 10);
909
+ const cc = String(country || "")
910
+ .trim()
911
+ .toUpperCase();
912
+ if (port && /^[A-Z]{2}$/.test(cc)) {
913
+ profiles.push({ country: cc, port });
914
+ }
915
+ }
906
916
  } else if (arg === "--proxy") {
907
917
  proxyUrl = args[++i] || null;
908
918
  } else if (arg === "-i" || arg === "--interval") {
@@ -917,8 +927,8 @@ function parseProductFillArgs(args) {
917
927
  productFillLimit: limit,
918
928
  productFillInterval: interval,
919
929
  serverUrl,
920
- productFillWebserver: webserverUrl,
921
930
  productFillProxy: proxyUrl,
931
+ productFillProfiles: profiles,
922
932
  urls: [],
923
933
  outputFormat: "json",
924
934
  exploreCount: 0,
@@ -1564,7 +1574,7 @@ export function parseArgs() {
1564
1574
  productFillLimit: 5,
1565
1575
  productFillInterval: 10,
1566
1576
  serverUrl: defaultServer,
1567
- productFillWebserver: null,
1577
+ productFillProfiles: [],
1568
1578
  showHelp: true,
1569
1579
  showVersion: false,
1570
1580
  urls: [],
@@ -222,14 +222,17 @@ const HELP_TEXT = [
222
222
  " 示例: tt-help attach -p 5 -i 10",
223
223
  " tt-help attach -c PL,DE,FR -p 5",
224
224
  "",
225
- " tt-help product-fill [-s 服务端地址] [-w webserver地址] [-i 间隔秒数] [-l 每批上限]",
226
- " 循环领取缺商品详情任务,通过 webserver 抓取评分/销量/评论并回写 jobs 表",
225
+ " tt-help product-fill --profiles <XX/port>[,<XX/port>...] [-s 服务端地址] [-i 间隔秒数] [-l 每批上限] [--proxy URL]",
226
+ " 循环领取缺商品详情任务,抓取评分/销量/评论并回写 jobs 表",
227
+ " --profiles 国家/端口对绑定(端口 profile 区域需与商品国家一致,如 US/9223,JP/1222);",
228
+ " 每轮探测代理出口,在配置中匹配端口领取对应国家任务,出口不在配置范围则跳过",
227
229
  " -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
228
- " -w, --webserver <URL> 商品详情 webserver 地址(必填)",
230
+ " --proxy <URL> 浏览器启动代理及出口探测(默认: http://127.0.0.1:7897)",
229
231
  " -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
230
232
  " -l, --limit <N> 每批领取任务数上限(默认: 5)",
231
- " 示例: tt-help product-fill -w http://127.0.0.1:3000",
232
- " tt-help product-fill -s http://127.0.0.1:3001 -w http://117.71.53.99:17300 -i 5 -l 10",
233
+ " 下架壳页/连续失败3次/无效URL → 回写 rating=-1 哨兵永久移出候选",
234
+ " 示例: tt-help product-fill --profiles US/9223",
235
+ " tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
233
236
  "",
234
237
  " tt-help recheck [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
235
238
  " 重检 jobs 表中 done 非商家用户(tt_seller!=1),7 天未更新才检测",
@@ -360,15 +363,16 @@ const HELP_TEXT = [
360
363
  " 选项:",
361
364
  " --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
362
365
  " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
363
- " --port <N> 起始 CDP 端口,端口池默认为 N~N+9(默认 7222)",
366
+ " --port <N> 固定 CDP 端口(默认 7222,全程不切换)",
364
367
  " --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
365
368
  " --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
366
369
  " 行为:",
367
- " 连续 3 次 HTTP 403 → 判断账号被封 → 等 2 分钟后自动切换到池中另一端口",
370
+ " 错误只打印不自愈:403 连续 3 次 → 上报服务端并触发 daemon 换节点;",
371
+ " 网络错误/其他错误连续 11 次 → 退出;验证码连续 4 次未人工解决 → 退出",
372
+ " 成功一个 tag 全部计数清零;浏览器窗口被手动关闭 → 自动退出",
368
373
  " 环境变量:",
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)",
374
+ " TAG_SCOREALL_MIN_COUNTRY_RATE=0.8 国家识别率下限(低于则重试)",
375
+ " TAG_SCOREALL_COUNTRY_MAX_RETRIES=2 国家识别率不足最大重试次数",
372
376
  " 示例: tt-help tag score-all",
373
377
  " tt-help tag score-all --countries ES -s http://127.0.0.1:3001",
374
378
  " tt-help tag score-all --port 9222 -s http://127.0.0.1:3001 --discover",
@@ -190,6 +190,8 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
190
190
  const agent = proxy ? new ProxyAgent(proxy) : null;
191
191
 
192
192
  let lastError;
193
+ let captchaRetries = 0; // 验证码弹浏览器重试次数(防无限重试)
194
+ const MAX_CAPTCHA_RETRIES = 2;
193
195
  for (let attempt = 1; attempt <= 3; attempt++) {
194
196
  try {
195
197
  const headers = { ...PDP_HEADERS };
@@ -216,8 +218,15 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
216
218
  err.message?.includes("Blocked by TikTok") &&
217
219
  options.captchaFallback
218
220
  ) {
221
+ if (captchaRetries >= MAX_CAPTCHA_RETRIES) {
222
+ console.error(
223
+ `[shop-scraper] Captcha persists after ${captchaRetries} browser attempts, giving up`,
224
+ );
225
+ throw err;
226
+ }
227
+ captchaRetries++;
219
228
  console.error(
220
- `[shop-scraper] Captcha on ${proxy}, clearing cached cookies...`,
229
+ `[shop-scraper] Captcha on ${proxy}, clearing cached cookies (browser attempt ${captchaRetries}/${MAX_CAPTCHA_RETRIES})...`,
221
230
  );
222
231
  clearCachedCookies(proxy);
223
232
  console.error("[shop-scraper] Opening browser for manual captcha...");
@@ -255,6 +264,12 @@ export function parseProductHtml(html) {
255
264
  throw new Error("Blocked by TikTok security check (captcha)");
256
265
  }
257
266
 
267
+ // 页面不含任何商品数据标识 → 空壳页(风控降级/跳转/空白),
268
+ // 抛可重试错误,避免把全 null 数据当成功回写
269
+ if (!html.includes('"product_model"') && !html.includes('"review_ratings"')) {
270
+ throw new Error("Empty product page (no product data markers)");
271
+ }
272
+
258
273
  const result = {
259
274
  rating: null,
260
275
  reviewCount: null,
@@ -293,6 +308,19 @@ export function parseProductHtml(html) {
293
308
  result.ratingBreakdown = ratings.rating_result ?? null;
294
309
  }
295
310
 
311
+ // 壳页识别:有 product_id 骨架但 seller_id 为空且无任何销售/名称数据
312
+ // → 商品已下架/店铺异常(200 空壳页),抛 delisted 供调用方标记放弃
313
+ const hasSaleData =
314
+ result.soldCount !== null ||
315
+ result.rating !== null ||
316
+ result.reviewCount !== null ||
317
+ result.productName !== null;
318
+ if (result.productId && !result.sellerId && !hasSaleData) {
319
+ const err = new Error("Product delisted (seller_id empty, no sale data)");
320
+ err.delisted = true;
321
+ throw err;
322
+ }
323
+
296
324
  return result;
297
325
  }
298
326
 
@@ -303,11 +331,29 @@ export function parseProductHtml(html) {
303
331
  * @param {string} url - TikTok Shop PDP URL
304
332
  * @returns {Promise<{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}>}
305
333
  */
306
- export async function getProductInfoCDP(page, url) {
307
- await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
308
- // 等待 JS 渲染完成(CSR 页面需要)
309
- await new Promise((r) => setTimeout(r, 3000));
310
- const html = await page.content();
334
+ export async function getProductInfoCDP(page, url, options = {}) {
335
+ const { gotoTimeout = 60000, renderWaitMs = 15000 } = options;
336
+ await page.goto(url, {
337
+ waitUntil: "domcontentloaded",
338
+ timeout: gotoTimeout,
339
+ });
340
+ // 轮询等待 CSR 渲染出商品数据标识(或出现验证码标记),
341
+ // 不用 networkidle:TikTok 页面持续请求,networkidle 极易超时
342
+ const deadline = Date.now() + renderWaitMs;
343
+ let html = "";
344
+ while (Date.now() < deadline) {
345
+ html = await page.content();
346
+ if (
347
+ html.includes('"product_model"') ||
348
+ html.includes('"review_ratings"') ||
349
+ html.includes("captcha-verify-container") ||
350
+ html.includes("<title>Security Check</title>")
351
+ ) {
352
+ break;
353
+ }
354
+ await new Promise((r) => setTimeout(r, 1000));
355
+ }
356
+ if (!html) html = await page.content();
311
357
  return parseProductHtml(html);
312
358
  }
313
359
 
@@ -320,3 +366,20 @@ export function formatCount(n) {
320
366
  if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
321
367
  return String(n);
322
368
  }
369
+
370
+ /**
371
+ * 判断商品详情是否全空(页面被拦截/结构变化时解析结果全为 null)。
372
+ * 只判实际回写的四个字段:rating/soldCount/reviewCount/productName。
373
+ * productId/sellerId 不回写,有值也不能视为"抓到了数据",
374
+ * 否则壳页(仅含 product_id 骨架)会被误报成功、任务永久留在候选池。
375
+ * @param {object} detail - parseProductHtml 的返回值
376
+ * @returns {boolean}
377
+ */
378
+ export function isEmptyProductDetail(detail) {
379
+ return (
380
+ (detail?.rating ?? null) === null &&
381
+ (detail?.reviewCount ?? null) === null &&
382
+ (detail?.soldCount ?? null) === null &&
383
+ !detail?.productName
384
+ );
385
+ }
@@ -608,6 +608,92 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
608
608
  };
609
609
  }
610
610
 
611
+ /**
612
+ * 精确商家:目标商家(tt_seller=1 且 verified=0 且 目标国家)中,
613
+ * 最近 7 天有 isECVideo 播放 > 1000 的视频,且有商品、商品评论数=0、销量 > 10。
614
+ * 支持分页与搜索(unique_id/nickname),返回 total + users。
615
+ */
616
+ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
617
+ if (!getDb()) return null;
618
+ if (!targetLocations.length) return { total: 0, users: [] };
619
+
620
+ const { limit, offset, search } = options;
621
+ const placeholders = targetLocations.map(() => "?").join(", ");
622
+
623
+ const where = `
624
+ tt_seller = 1
625
+ AND verified = 0
626
+ AND location_created IN (${placeholders})
627
+ AND top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
628
+ AND product_review_count = 0
629
+ AND product_sold_count > 10
630
+ AND recent_ec_videos IS NOT NULL AND recent_ec_videos != ''
631
+ AND EXISTS (
632
+ SELECT 1 FROM json_each(recent_ec_videos)
633
+ WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000
634
+ )
635
+ `;
636
+
637
+ let searchSql = "";
638
+ const searchParams = [];
639
+ if (search) {
640
+ searchSql = ` AND (unique_id LIKE ? OR nickname LIKE ?)`;
641
+ searchParams.push(`%${search}%`, `%${search}%`);
642
+ }
643
+
644
+ const totalRow = getDb()
645
+ .prepare(`SELECT COUNT(*) AS n FROM jobs WHERE ${where}${searchSql}`)
646
+ .get(...targetLocations, ...searchParams);
647
+ const total = totalRow?.n || 0;
648
+
649
+ const selectSql = `
650
+ SELECT
651
+ unique_id,
652
+ nickname,
653
+ sec_uid,
654
+ status,
655
+ follower_count,
656
+ video_count,
657
+ location_created,
658
+ latest_video_time,
659
+ refresh_time,
660
+ top_video_play_count,
661
+ top_video_href,
662
+ top_video_is_ad,
663
+ top_video_product_id,
664
+ top_video_product_title,
665
+ top_video_seo_url,
666
+ product_rating,
667
+ product_review_count,
668
+ product_sold_count,
669
+ product_name,
670
+ recent_ec_videos,
671
+ (SELECT COUNT(*) FROM json_each(recent_ec_videos)
672
+ WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) AS ecOver1k,
673
+ (SELECT MAX(CAST(json_extract(value, '$.playCount') AS INTEGER))
674
+ FROM json_each(recent_ec_videos)) AS maxEcPlay
675
+ FROM jobs
676
+ WHERE ${where}${searchSql}
677
+ ORDER BY product_sold_count DESC, unique_id ASC
678
+ `;
679
+
680
+ if (limit !== undefined) {
681
+ const safeLimit = Math.max(Math.min(Math.floor(limit), 1000), 1);
682
+ const safeOffset = Math.max(Math.floor(offset || 0), 0);
683
+ const rows = getDb()
684
+ .prepare(`${selectSql} LIMIT ? OFFSET ?`)
685
+ .all(...targetLocations, ...searchParams, safeLimit, safeOffset)
686
+ .map(mapJobRow);
687
+ return { total, limit: safeLimit, offset: safeOffset, users: rows };
688
+ }
689
+
690
+ const rows = getDb()
691
+ .prepare(selectSql)
692
+ .all(...targetLocations, ...searchParams)
693
+ .map(mapJobRow);
694
+ return { total, users: rows };
695
+ }
696
+
611
697
  /**
612
698
  * 查询已修改(modified_at IS NOT NULL)且 location_created 不在 targetLocations 中的目标商家,
613
699
  * 按国家分组返回。
@@ -2686,20 +2772,27 @@ export function createStore(filePath, options = {}) {
2686
2772
  * @param {number|string} limit
2687
2773
  * @param {string|null} claimBy 领取者标识(userId),仅作标记/日志
2688
2774
  * @param {number} claimTtlMs 锁定超时(默认 5 分钟)
2775
+ * @param {string|null} country 国家过滤(大写两位,如 US/JP);非空时只返回
2776
+ * location_created 匹配的任务,location_created 为 NULL 的任务不分发;
2777
+ * 传 null/空串表示不限(兼容旧客户端)
2689
2778
  * @returns {{uniqueId:string, nickname:string|null, seoUrl:string, productId:string|null, productTitle:string|null}[]}
2690
2779
  */
2691
2780
  function getPendingProductTasks(
2692
2781
  limit,
2693
2782
  claimBy = null,
2694
2783
  claimTtlMs = 5 * 60 * 1000,
2784
+ country = null,
2695
2785
  ) {
2696
2786
  if (getDb()) {
2697
2787
  const l = Math.max(1, parseInt(limit) || 5);
2698
2788
  const now = Date.now();
2699
2789
  const claimExpireBefore = now - claimTtlMs;
2700
- const rows = getDb()
2701
- .prepare(
2702
- `
2790
+ // 国家过滤:只认大写两位字母,避免异常值影响;无国家段的任务
2791
+ // 不分发给任何按国家领取的客户端(后续人工回填处理)
2792
+ const countryFilter = country
2793
+ ? String(country).trim().toUpperCase().slice(0, 2)
2794
+ : "";
2795
+ let sql = `
2703
2796
  SELECT unique_id, nickname, top_video_seo_url, top_video_product_id,
2704
2797
  top_video_product_title
2705
2798
  FROM jobs
@@ -2707,12 +2800,21 @@ export function createStore(filePath, options = {}) {
2707
2800
  AND (product_name IS NULL OR product_name = '')
2708
2801
  AND product_rating IS NULL
2709
2802
  AND product_sold_count IS NULL
2803
+ `;
2804
+ const params = [];
2805
+ if (countryFilter.length === 2) {
2806
+ sql += ` AND location_created = ?`;
2807
+ params.push(countryFilter);
2808
+ }
2809
+ sql += `
2710
2810
  AND (claimed_at IS NULL OR claimed_at < ?)
2711
2811
  ORDER BY COALESCE(claimed_at, 0) ASC, updated_at DESC
2712
2812
  LIMIT ?
2713
- `,
2714
- )
2715
- .all(claimExpireBefore, l);
2813
+ `;
2814
+ params.push(claimExpireBefore, l);
2815
+ const rows = getDb()
2816
+ .prepare(sql)
2817
+ .all(...params);
2716
2818
  if (rows.length > 0) {
2717
2819
  // 领取即锁定:写入 claimed_at/claimed_by,避免多客户端重复抓取同一商品
2718
2820
  const ids = rows.map((r) => r.unique_id);
@@ -2739,6 +2841,7 @@ export function createStore(filePath, options = {}) {
2739
2841
  * 回写商品详情到 jobs 表(product_* 列)
2740
2842
  * @param {string} uniqueId
2741
2843
  * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
2844
+ * rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
2742
2845
  */
2743
2846
  function updateProductInfo(uniqueId, productDetail) {
2744
2847
  if (getDb()) {
@@ -2757,6 +2860,24 @@ export function createStore(filePath, options = {}) {
2757
2860
  "jobs",
2758
2861
  );
2759
2862
  if (ret.error) return ret;
2863
+ // 校验:写入后 product_* 仍全空 → 回写无效(壳页假成功防御),
2864
+ // 防止任务在候选池里无限轮转
2865
+ const row = getDb()
2866
+ .prepare(
2867
+ `SELECT product_name, product_rating, product_sold_count,
2868
+ product_review_count
2869
+ FROM jobs WHERE unique_id = ?`,
2870
+ )
2871
+ .get(uniqueId);
2872
+ if (
2873
+ row &&
2874
+ (row.product_name === null || row.product_name === "") &&
2875
+ row.product_rating === null &&
2876
+ row.product_sold_count === null &&
2877
+ row.product_review_count === null
2878
+ ) {
2879
+ return { ok: false, error: "product 字段仍全空,回写无效" };
2880
+ }
2760
2881
  return { ok: true };
2761
2882
  }
2762
2883
  return { ok: false, error: "db not initialized" };
@@ -3549,6 +3670,8 @@ export function createStore(filePath, options = {}) {
3549
3670
  getRawJobsPage: getRawJobsPageFromDb,
3550
3671
  getTargetUsers: getTargetUsersFromDb,
3551
3672
  getTargetUsersByCountry: getTargetUsersByCountryFromDb,
3673
+ getPreciseMerchants: (targetLocations, options) =>
3674
+ getPreciseMerchantsFromDb(targetLocations, options),
3552
3675
  getModifiedTargetUsersOutsideLocations: (loc) =>
3553
3676
  getModifiedTargetUsersOutsideLocations(loc),
3554
3677
  getStats,