tt-help-cli-ycl 1.4.110 → 1.4.112

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.110",
3
+ "version": "1.4.112",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,21 @@ db.prepare(
37
37
  `INSERT INTO jobs (unique_id, status, tt_seller, top_video_seo_url, product_name, updated_at)
38
38
  VALUES ('sellerC', 'done', 1, NULL, NULL, ?)`,
39
39
  ).run(now);
40
+ // D: 有 seoUrl、name 空但 rating 有值 → 已抓到数据,不应被领取(防死循环)
41
+ db.prepare(
42
+ `INSERT INTO jobs (unique_id, status, tt_seller, top_video_seo_url, product_name, product_rating, updated_at)
43
+ VALUES ('sellerD', 'done', 1, 'https://www.tiktok.com/shop/pdp/d1', NULL, 4.5, ?)`,
44
+ ).run(now);
45
+ // E: 有 seoUrl、name 空但 sold_count 有值 → 已抓到数据,不应被领取(防死循环)
46
+ db.prepare(
47
+ `INSERT INTO jobs (unique_id, status, tt_seller, top_video_seo_url, product_name, product_sold_count, updated_at)
48
+ VALUES ('sellerE', 'done', 1, 'https://www.tiktok.com/shop/pdp/e1', '', 120, ?)`,
49
+ ).run(now);
50
+ // F: 有 seoUrl、无商品 → 与 A 一起验证 claim 防重复领取
51
+ db.prepare(
52
+ `INSERT INTO jobs (unique_id, status, tt_seller, top_video_seo_url, product_name, updated_at)
53
+ VALUES ('sellerF', 'done', 1, 'https://www.tiktok.com/shop/pdp/f1', NULL, ?)`,
54
+ ).run(now + 1);
40
55
 
41
56
  let passed = 0;
42
57
  function check(name, fn) {
@@ -54,16 +69,61 @@ console.log("===== product-fill 服务端数据层测试 =====");
54
69
 
55
70
  console.log("\n[1] getPendingProductTasks:只领缺商品的");
56
71
  const tasks = store.getPendingProductTasks(5);
57
- check("返回 1 条(仅 sellerA)", () => {
72
+ check("只返回缺商品的(sellerA/sellerF,不含 B/C/D/E)", () => {
58
73
  const ids = tasks.map((t) => t.uniqueId);
59
- assert(ids.length === 1 && ids[0] === "sellerA", `实际: ${ids.join(",")}`);
74
+ assert(ids.length === 2, `实际: ${ids.join(",")}`);
75
+ assert(
76
+ ids.includes("sellerA") && ids.includes("sellerF"),
77
+ `实际: ${ids.join(",")}`,
78
+ );
60
79
  });
61
80
  check("任务带 seoUrl / productId / productTitle", () => {
62
- assert(tasks[0].seoUrl === "https://www.tiktok.com/shop/pdp/a1");
63
- assert("productId" in tasks[0] && "productTitle" in tasks[0]);
81
+ const a = tasks.find((t) => t.uniqueId === "sellerA");
82
+ assert(a.seoUrl === "https://www.tiktok.com/shop/pdp/a1");
83
+ assert("productId" in a && "productTitle" in a);
64
84
  });
65
85
  check("limit=1 只取 1 条", () => {
86
+ db.prepare(
87
+ "UPDATE jobs SET claimed_at = NULL, claimed_by = NULL WHERE unique_id IN ('sellerA','sellerF')",
88
+ ).run();
66
89
  assert(store.getPendingProductTasks(1).length === 1);
90
+ db.prepare(
91
+ "UPDATE jobs SET claimed_at = NULL, claimed_by = NULL WHERE unique_id IN ('sellerA','sellerF')",
92
+ ).run();
93
+ });
94
+ check("领取即锁定:写入 claimed_by/claimed_at", () => {
95
+ const claimed = store.getPendingProductTasks(5, "testerA");
96
+ assert(claimed.length === 2, `实际: ${claimed.length}`);
97
+ const row = db
98
+ .prepare(
99
+ "SELECT claimed_by, claimed_at FROM jobs WHERE unique_id = 'sellerA'",
100
+ )
101
+ .get();
102
+ assert(row.claimed_by === "testerA", JSON.stringify(row));
103
+ assert(row.claimed_at !== null && row.claimed_at !== undefined);
104
+ });
105
+ check("锁定期间不重复领取(多客户端防重复)", () => {
106
+ const again = store.getPendingProductTasks(5, "testerB");
107
+ assert(
108
+ again.length === 0,
109
+ `实际领到: ${again.map((t) => t.uniqueId).join(",")}`,
110
+ );
111
+ });
112
+ check("TTL 过期后自动回收", () => {
113
+ db.prepare("UPDATE jobs SET claimed_at = ? WHERE unique_id = 'sellerF'").run(
114
+ Date.now() - 6 * 60 * 1000,
115
+ );
116
+ const recycled = store.getPendingProductTasks(5, "testerC");
117
+ assert(
118
+ recycled.some((t) => t.uniqueId === "sellerF"),
119
+ `实际: ${recycled.map((t) => t.uniqueId).join(",")}`,
120
+ );
121
+ });
122
+ check("name 空但 rating 有值 → 不进候选", () => {
123
+ assert(!tasks.some((t) => t.uniqueId === "sellerD"));
124
+ });
125
+ check("name 空但 soldCount 有值 → 不进候选", () => {
126
+ assert(!tasks.some((t) => t.uniqueId === "sellerE"));
67
127
  });
68
128
 
69
129
  console.log("\n[2] updateProductInfo:回写商品详情");
@@ -31,16 +31,15 @@ async function fetchProductDetail(webserverUrl, seoUrl) {
31
31
  body: JSON.stringify({ url: seoUrl }),
32
32
  signal: AbortSignal.timeout(30_000),
33
33
  });
34
- if (res.status === 503) {
34
+ if (!res.ok) {
35
+ const body = await res.json().catch(() => ({}));
35
36
  const err = new Error(
36
- `captcha: ${(await res.json().catch(() => ({}))).error || "Blocked"}`,
37
+ `${body.error || `HTTP ${res.status}`}${res.status === 503 ? " (captcha)" : ""}`,
37
38
  );
38
- err.retryable = true;
39
- throw err;
40
- }
41
- if (!res.ok) {
42
- const err = new Error(`HTTP ${res.status}`);
43
39
  err.status = res.status;
40
+ // webserver 约定:503=验证码;504/502 响应体带 retryable=true 表示可重试
41
+ err.captcha = res.status === 503;
42
+ err.retryable = !!body.retryable || res.status === 503;
44
43
  throw err;
45
44
  }
46
45
  return res.json();
@@ -126,6 +125,17 @@ export async function handleProductFill(options) {
126
125
  loopCount++;
127
126
  if (shuttingDown) break;
128
127
 
128
+ // 验证码冷却期间不领任务(避免反复触发 webserver 限流)
129
+ const now = Date.now();
130
+ if (now < captchaBackoffUntil) {
131
+ const waitMs = captchaBackoffUntil - now;
132
+ log(
133
+ `[ProductFill] ⏳ 验证码冷却中(剩余 ${Math.ceil(waitMs / 1000)}s),暂不领取任务...`,
134
+ );
135
+ await new Promise((r) => setTimeout(r, waitMs));
136
+ continue;
137
+ }
138
+
129
139
  const { tasks = [] } = await apiGet(
130
140
  `${serverUrl}/api/product-tasks?limit=${productFillLimit}&userId=${encodeURIComponent(userId)}`,
131
141
  );
@@ -142,20 +152,16 @@ export async function handleProductFill(options) {
142
152
 
143
153
  log(`\n[ProductFill] 获取到 ${tasks.length} 个补商品任务...`);
144
154
 
145
- // 验证码冷却期间整批跳过(避免反复触发 webserver 限流)
146
- const now = Date.now();
147
- if (now < captchaBackoffUntil) {
148
- const waitSec = Math.ceil((captchaBackoffUntil - now) / 1000);
149
- log(` ⏳ 验证码冷却中(剩余 ${waitSec}s),本批跳过`);
150
- await new Promise((r) => setTimeout(r, captchaBackoffUntil - now));
151
- continue;
152
- }
155
+ let batchSuccess = 0;
156
+ let batchFailed = 0;
157
+ let batchSkipped = 0;
153
158
 
154
159
  for (const task of tasks) {
155
160
  const uniqueId = task.uniqueId;
156
161
  const seoUrl = task.seoUrl;
157
162
  if (!seoUrl) {
158
163
  log(` ✗ @${uniqueId} 无 seoUrl,跳过`);
164
+ batchSkipped++;
159
165
  totalSkipped++;
160
166
  continue;
161
167
  }
@@ -167,22 +173,31 @@ export async function handleProductFill(options) {
167
173
  { productDetail: detail },
168
174
  );
169
175
  if (post && post.ok) {
176
+ batchSuccess++;
170
177
  totalSuccess++;
171
178
  log(
172
179
  ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"})`,
173
180
  );
174
181
  } else {
182
+ batchFailed++;
175
183
  totalFailed++;
176
184
  log(` ✗ @${uniqueId} 回传失败: ${post?.error || "未知"}`);
177
185
  }
178
186
  } catch (e) {
179
- if (e.retryable) {
187
+ if (e.captcha) {
188
+ batchSkipped++;
180
189
  totalSkipped++;
181
190
  captchaBackoffUntil = Date.now() + CAPTCHA_BACKOFF_MS;
182
191
  log(
183
192
  ` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
184
193
  );
194
+ } else if (e.retryable) {
195
+ // 504 超时等可重试错误:未回写,下轮自然重试,不触发全局冷却
196
+ batchSkipped++;
197
+ totalSkipped++;
198
+ log(` ⏳ @${uniqueId} 临时失败(可重试): ${e.message}`);
185
199
  } else {
200
+ batchFailed++;
186
201
  totalFailed++;
187
202
  log(` ✗ @${uniqueId} 商品详情失败: ${e.message || "未知错误"}`);
188
203
  }
@@ -190,7 +205,7 @@ export async function handleProductFill(options) {
190
205
  }
191
206
 
192
207
  log(
193
- ` 本批结果: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped}\n`,
208
+ ` 本批: 成功 ${batchSuccess} | 失败 ${batchFailed} | 跳过 ${batchSkipped}(累计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped})\n`,
194
209
  );
195
210
  await new Promise((r) => setTimeout(r, 300));
196
211
  }
@@ -2029,7 +2029,14 @@ export function createStore(filePath, options = {}) {
2029
2029
  const existing = getJob(uniqueId);
2030
2030
  if (existing) {
2031
2031
  updateUserFromResult(existing, result);
2032
- const persistRet = updateJobInfo(uniqueId, existing, false);
2032
+ // 用户在哪个表就更新哪个表(task_queue 热表 / jobs 归档),
2033
+ // 避免固定写 jobs 导致的假成功(UPDATE 0 行仍返回 ok)
2034
+ const persistRet = updateJobInfo(
2035
+ uniqueId,
2036
+ existing,
2037
+ false,
2038
+ getUserTable(uniqueId) || "jobs",
2039
+ );
2033
2040
  if (persistRet.error) {
2034
2041
  return { saved: false, error: persistRet.error };
2035
2042
  }
@@ -2049,6 +2056,13 @@ export function createStore(filePath, options = {}) {
2049
2056
  };
2050
2057
  updateUserFromResult(userObj, result);
2051
2058
  addJob(userObj);
2059
+ // addJobToDb 的 INSERT 列不含商品列(top_video_* / product_*),
2060
+ // 新用户行需显式补写 task_queue,否则探索发现的商品线索丢失,
2061
+ // 且与 product-fill 领取/回写的位置(jobs 归档表)断链
2062
+ const persistRet = updateJobInfo(uniqueId, userObj, false, "task_queue");
2063
+ if (persistRet.error) {
2064
+ return { saved: false, error: persistRet.error };
2065
+ }
2052
2066
  const newUsers = processDiscoveredUsers(result);
2053
2067
  return { saved: true, created: true, status: userObj.status, newUsers };
2054
2068
  }
@@ -2601,14 +2615,28 @@ export function createStore(filePath, options = {}) {
2601
2615
  // ===== product-fill:给缺商品详情的商家补商品信息(jobs 表) =====
2602
2616
 
2603
2617
  /**
2604
- * 领取缺商品详情任务:jobs 表中有 top_video_seo_url 但未补过商品信息(product_name 为空)
2605
- * 补全是幂等的(同一 seoUrl 补出相同详情),成功回写后自动移出候选,无需额外锁定
2618
+ * 领取缺商品详情任务:jobs 表中有 top_video_seo_url 但未补过商品信息
2619
+ * 完成判定为「名称/评分/销量三者都空」:商品页可能解析不出 name,
2620
+ * 若只以 product_name 为空作候选条件,这类任务会永远留在候选池重复抓取
2621
+ *
2622
+ * 多客户端防重复领取(claim 机制):
2623
+ * - 领取时写入 claimed_at/claimed_by,锁定期间不再返回同一任务
2624
+ * - 回写成功(product_* 有值)后自然移出候选;回写失败等 TTL 过期重新可领
2625
+ * - 客户端崩溃/断连 → claimed_at 超过 TTL 自动回收(与 claimNextJob 的 5 分钟一致)
2606
2626
  * @param {number|string} limit
2627
+ * @param {string|null} claimBy 领取者标识(userId),仅作标记/日志
2628
+ * @param {number} claimTtlMs 锁定超时(默认 5 分钟)
2607
2629
  * @returns {{uniqueId:string, nickname:string|null, seoUrl:string, productId:string|null, productTitle:string|null}[]}
2608
2630
  */
2609
- function getPendingProductTasks(limit) {
2631
+ function getPendingProductTasks(
2632
+ limit,
2633
+ claimBy = null,
2634
+ claimTtlMs = 5 * 60 * 1000,
2635
+ ) {
2610
2636
  if (getDb()) {
2611
2637
  const l = Math.max(1, parseInt(limit) || 5);
2638
+ const now = Date.now();
2639
+ const claimExpireBefore = now - claimTtlMs;
2612
2640
  const rows = getDb()
2613
2641
  .prepare(
2614
2642
  `
@@ -2617,11 +2645,24 @@ export function createStore(filePath, options = {}) {
2617
2645
  FROM jobs
2618
2646
  WHERE top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
2619
2647
  AND (product_name IS NULL OR product_name = '')
2620
- ORDER BY updated_at DESC
2648
+ AND product_rating IS NULL
2649
+ AND product_sold_count IS NULL
2650
+ AND (claimed_at IS NULL OR claimed_at < ?)
2651
+ ORDER BY COALESCE(claimed_at, 0) ASC, updated_at DESC
2621
2652
  LIMIT ?
2622
2653
  `,
2623
2654
  )
2624
- .all(l);
2655
+ .all(claimExpireBefore, l);
2656
+ if (rows.length > 0) {
2657
+ // 领取即锁定:写入 claimed_at/claimed_by,避免多客户端重复抓取同一商品
2658
+ const ids = rows.map((r) => r.unique_id);
2659
+ const ph = ids.map(() => "?").join(", ");
2660
+ getDb()
2661
+ .prepare(
2662
+ `UPDATE jobs SET claimed_at = ?, claimed_by = ? WHERE unique_id IN (${ph})`,
2663
+ )
2664
+ .run(now, claimBy || null, ...ids);
2665
+ }
2625
2666
  return rows.map((row) => ({
2626
2667
  uniqueId: row.unique_id,
2627
2668
  nickname: row.nickname || null,
@@ -114,6 +114,7 @@ export const JOB_BOOLEAN_COLUMNS = new Set([
114
114
  "verified",
115
115
  "pending_refresh",
116
116
  "error",
117
+ "top_video_is_ad",
117
118
  ]);
118
119
 
119
120
  export const VIDEO_BOOLEAN_COLUMNS = new Set(["tt_seller"]);
@@ -296,6 +296,7 @@ function renderActiveClients(clients) {
296
296
  "attach",
297
297
  "recheck",
298
298
  "comments",
299
+ "product-fill",
299
300
  "scoring",
300
301
  "tag-refresh",
301
302
  "tag-search",
@@ -307,6 +308,7 @@ function renderActiveClients(clients) {
307
308
  attach: "Attach",
308
309
  recheck: "Recheck",
309
310
  comments: "Comments",
311
+ "product-fill": "ProductFill",
310
312
  scoring: "Scoring",
311
313
  "tag-refresh": "TagRefresh",
312
314
  "tag-search": "Search",
@@ -957,9 +957,15 @@ export function startWatchServer(
957
957
  // ── product-fill 端点:补商品信息(缺商品详情的商家) ──
958
958
  if (req.method === "GET" && routePath === "/api/product-tasks") {
959
959
  const limit = params.limit;
960
- const tasks = store.getPendingProductTasks(limit);
960
+ const clientId = params.userId || "?";
961
+ const tasks = store.getPendingProductTasks(
962
+ limit,
963
+ params.userId || null,
964
+ );
961
965
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
962
- console.error(`[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks`);
966
+ console.error(
967
+ `[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId})`,
968
+ );
963
969
  sendJSON(res, 200, { total: tasks.length, tasks });
964
970
  return;
965
971
  }