tt-help-cli-ycl 1.4.145 → 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 天未更新才检测",
@@ -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
+ }
@@ -2772,20 +2772,27 @@ export function createStore(filePath, options = {}) {
2772
2772
  * @param {number|string} limit
2773
2773
  * @param {string|null} claimBy 领取者标识(userId),仅作标记/日志
2774
2774
  * @param {number} claimTtlMs 锁定超时(默认 5 分钟)
2775
+ * @param {string|null} country 国家过滤(大写两位,如 US/JP);非空时只返回
2776
+ * location_created 匹配的任务,location_created 为 NULL 的任务不分发;
2777
+ * 传 null/空串表示不限(兼容旧客户端)
2775
2778
  * @returns {{uniqueId:string, nickname:string|null, seoUrl:string, productId:string|null, productTitle:string|null}[]}
2776
2779
  */
2777
2780
  function getPendingProductTasks(
2778
2781
  limit,
2779
2782
  claimBy = null,
2780
2783
  claimTtlMs = 5 * 60 * 1000,
2784
+ country = null,
2781
2785
  ) {
2782
2786
  if (getDb()) {
2783
2787
  const l = Math.max(1, parseInt(limit) || 5);
2784
2788
  const now = Date.now();
2785
2789
  const claimExpireBefore = now - claimTtlMs;
2786
- const rows = getDb()
2787
- .prepare(
2788
- `
2790
+ // 国家过滤:只认大写两位字母,避免异常值影响;无国家段的任务
2791
+ // 不分发给任何按国家领取的客户端(后续人工回填处理)
2792
+ const countryFilter = country
2793
+ ? String(country).trim().toUpperCase().slice(0, 2)
2794
+ : "";
2795
+ let sql = `
2789
2796
  SELECT unique_id, nickname, top_video_seo_url, top_video_product_id,
2790
2797
  top_video_product_title
2791
2798
  FROM jobs
@@ -2793,12 +2800,21 @@ export function createStore(filePath, options = {}) {
2793
2800
  AND (product_name IS NULL OR product_name = '')
2794
2801
  AND product_rating IS NULL
2795
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 += `
2796
2810
  AND (claimed_at IS NULL OR claimed_at < ?)
2797
2811
  ORDER BY COALESCE(claimed_at, 0) ASC, updated_at DESC
2798
2812
  LIMIT ?
2799
- `,
2800
- )
2801
- .all(claimExpireBefore, l);
2813
+ `;
2814
+ params.push(claimExpireBefore, l);
2815
+ const rows = getDb()
2816
+ .prepare(sql)
2817
+ .all(...params);
2802
2818
  if (rows.length > 0) {
2803
2819
  // 领取即锁定:写入 claimed_at/claimed_by,避免多客户端重复抓取同一商品
2804
2820
  const ids = rows.map((r) => r.unique_id);
@@ -2825,6 +2841,7 @@ export function createStore(filePath, options = {}) {
2825
2841
  * 回写商品详情到 jobs 表(product_* 列)
2826
2842
  * @param {string} uniqueId
2827
2843
  * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
2844
+ * rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
2828
2845
  */
2829
2846
  function updateProductInfo(uniqueId, productDetail) {
2830
2847
  if (getDb()) {
@@ -2843,6 +2860,24 @@ export function createStore(filePath, options = {}) {
2843
2860
  "jobs",
2844
2861
  );
2845
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
+ }
2846
2881
  return { ok: true };
2847
2882
  }
2848
2883
  return { ok: false, error: "db not initialized" };
@@ -1394,6 +1394,16 @@ function navigateToPrecise() {
1394
1394
  window.location.hash = "#precise";
1395
1395
  }
1396
1396
 
1397
+ // product-fill 任务查询条件表单:折叠 / 展开
1398
+ function togglePreciseCriteria() {
1399
+ const body = document.getElementById("preciseCriteriaBody");
1400
+ const arrow = document.getElementById("preciseCriteriaArrow");
1401
+ if (!body) return;
1402
+ const collapsed = body.style.display === "none";
1403
+ body.style.display = collapsed ? "" : "none";
1404
+ if (arrow) arrow.textContent = collapsed ? "▼" : "▶";
1405
+ }
1406
+
1397
1407
  function navigateToTags() {
1398
1408
  window.location.hash = "#tags";
1399
1409
  }
@@ -3801,12 +3811,14 @@ fetchUsers();
3801
3811
  fetchClientErrors();
3802
3812
  initTableSorting();
3803
3813
  initTargetTableSorting();
3814
+ fetchWatchdogBadge();
3804
3815
 
3805
3816
  // 错峰轮询(服务端已缓存,瞬时响应无压力)
3806
3817
  setInterval(fetchStats, 10000);
3807
3818
  setTimeout(() => setInterval(fetchUsers, 10000), 2500);
3808
3819
  setTimeout(() => setInterval(fetchClientErrors, 10000), 5000);
3809
3820
  setTimeout(() => setInterval(refreshUserUpdateIfActive, 10000), 7500);
3821
+ setTimeout(() => setInterval(fetchWatchdogBadge, 30000), 9000);
3810
3822
 
3811
3823
  // ========== 看门狗页面 ==========
3812
3824
 
@@ -3830,6 +3842,7 @@ async function fetchWatchdogData() {
3830
3842
  try {
3831
3843
  const res = await fetch("/api/watchdog/clients");
3832
3844
  _wdData = await res.json();
3845
+ updateWatchdogBadge(_wdData.clients || [], _wdData.clientCommands || {});
3833
3846
  renderWatchdogDashboard(
3834
3847
  _wdData.clients || [],
3835
3848
  _wdData.clientCommands || {},
@@ -3845,6 +3858,51 @@ async function fetchWatchdogData() {
3845
3858
  }
3846
3859
  }
3847
3860
 
3861
+ // ── 导航栏"看门狗"离线 badge ──
3862
+ // 离线数口径与看门狗页面卡片一致:
3863
+ // = 已注册但离线的客户端数 + 配置了命令但从未注册(renderOfflineCard)的 userId 数
3864
+ function updateWatchdogBadge(clients, clientCommands) {
3865
+ const badge = document.getElementById("wdOfflineBadge");
3866
+ if (!badge) return;
3867
+ const offlineClients = clients.filter(function (c) {
3868
+ return !c.online;
3869
+ }).length;
3870
+ const registeredUids = {};
3871
+ clients.forEach(function (c) {
3872
+ if (c.config && c.config.userId) {
3873
+ registeredUids[c.config.userId] = true;
3874
+ }
3875
+ });
3876
+ let neverRegistered = 0;
3877
+ for (var uid in clientCommands) {
3878
+ if (
3879
+ clientCommands[uid] &&
3880
+ clientCommands[uid].length > 0 &&
3881
+ !registeredUids[uid]
3882
+ ) {
3883
+ neverRegistered++;
3884
+ }
3885
+ }
3886
+ const total = offlineClients + neverRegistered;
3887
+ if (total > 0) {
3888
+ badge.textContent = total;
3889
+ badge.style.display = "";
3890
+ } else {
3891
+ badge.style.display = "none";
3892
+ }
3893
+ }
3894
+
3895
+ // 独立轻量轮询:导航栏 badge 在任何页面都实时显示离线数
3896
+ async function fetchWatchdogBadge() {
3897
+ try {
3898
+ const res = await fetch("/api/watchdog/clients");
3899
+ const data = await res.json();
3900
+ updateWatchdogBadge(data.clients || [], data.clientCommands || {});
3901
+ } catch (e) {
3902
+ // 静默失败,保留旧值
3903
+ }
3904
+ }
3905
+
3848
3906
  // ── 渲染辅助 ──
3849
3907
 
3850
3908
  function tag(tagName, attrs, content) {
@@ -21,7 +21,9 @@
21
21
  <span>🎆</span>
22
22
  </label>
23
23
  <a class="script-link" onclick="navigateToSettings()" style="cursor:pointer">⚙️ 配置</a>
24
- <a class="script-link" onclick="navigateToWatchdog()" style="cursor:pointer">🐕 看门狗</a>
24
+ <a class="script-link" onclick="navigateToWatchdog()" style="cursor:pointer;position:relative"
25
+ title="看门狗离线客户端数">🐕 看门狗<span id="wdOfflineBadge"
26
+ style="display:none;position:absolute;top:-9px;right:-8px;background:#ef4444;color:#fff;border-radius:10px;font-size:10px;font-weight:700;line-height:1;padding:3px 6px;box-shadow:0 0 6px rgba(239,68,68,0.6);white-space:nowrap">0</span></a>
25
27
  <span class="status" id="lastUpdate">--</span>
26
28
  </div>
27
29
  </div>
@@ -393,6 +395,49 @@
393
395
  <div class="label">← 返回主页面</div>
394
396
  </div>
395
397
  </div>
398
+ <!-- 精准商家检索条件(静态表单展示,与 src/watch/data-store.js getPreciseMerchantsFromDb 保持一致) -->
399
+ <div style="margin-bottom:16px;background:#1a1a24;border:1px solid #333;border-radius:8px;overflow:hidden">
400
+ <div
401
+ style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;cursor:pointer;user-select:none"
402
+ onclick="togglePreciseCriteria()" title="点击展开/收起">
403
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
404
+ <span style="color:#f59e0b;font-size:14px">🎯</span>
405
+ <span style="font-size:13px;color:#e0e0e0;font-weight:600">精准商家检索条件</span>
406
+ <span style="font-size:11px;color:#666">(阈值参数 · jobs 表)</span>
407
+ </div>
408
+ <span id="preciseCriteriaArrow" style="color:#888;font-size:12px">▼</span>
409
+ </div>
410
+ <div id="preciseCriteriaBody" style="padding:2px 14px 14px">
411
+ <div style="font-size:11px;color:#888;margin-bottom:10px">以下阈值条件全部满足(AND),其余条件为固有规则:</div>
412
+ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px">
413
+ <div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
414
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
415
+ <span style="font-size:12px;color:#60a5fa;font-weight:600">商品评论数</span>
416
+ <span style="font-size:10px;color:#666;font-family:monospace">product_review_count</span>
417
+ </div>
418
+ <div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">= 0</div>
419
+ <div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">商品评论数为 0</div>
420
+ </div>
421
+ <div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
422
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
423
+ <span style="font-size:12px;color:#60a5fa;font-weight:600">商品销量</span>
424
+ <span style="font-size:10px;color:#666;font-family:monospace">product_sold_count</span>
425
+ </div>
426
+ <div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">&gt; 10</div>
427
+ <div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">商品销量大于 10</div>
428
+ </div>
429
+ <div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
430
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
431
+ <span style="font-size:12px;color:#60a5fa;font-weight:600">EC 视频播放量</span>
432
+ <span style="font-size:10px;color:#666;font-family:monospace">playCount</span>
433
+ </div>
434
+ <div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">&gt; 1000</div>
435
+ <div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">最近 7 天发布的 EC 视频(isECVideo)中,存在播放量超过
436
+ 1000 的视频</div>
437
+ </div>
438
+ </div>
439
+ </div>
440
+ </div>
396
441
  <div class="table-wrap">
397
442
  <div class="precise-list-header">
398
443
  <h3>精准商家列表</h3>
@@ -1289,13 +1289,16 @@ export function startWatchServer(
1289
1289
  if (req.method === "GET" && routePath === "/api/product-tasks") {
1290
1290
  const limit = params.limit;
1291
1291
  const clientId = params.userId || "?";
1292
+ const country = params.country || null;
1292
1293
  const tasks = store.getPendingProductTasks(
1293
1294
  limit,
1294
1295
  params.userId || null,
1296
+ undefined, // claimTtlMs 用默认 5 分钟
1297
+ country,
1295
1298
  );
1296
1299
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1297
1300
  console.error(
1298
- `[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId})`,
1301
+ `[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId}${country ? `, country=${country}` : ""})`,
1299
1302
  );
1300
1303
  sendJSON(res, 200, { total: tasks.length, tasks });
1301
1304
  return;