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
|
@@ -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("
|
|
72
|
+
check("只返回缺商品的(sellerA/sellerF,不含 B/C/D/E)", () => {
|
|
58
73
|
const ids = tasks.map((t) => t.uniqueId);
|
|
59
|
-
assert(ids.length ===
|
|
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
|
-
|
|
63
|
-
assert(
|
|
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:回写商品详情");
|
package/src/cli/product-fill.js
CHANGED
|
@@ -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.
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.json().catch(() => ({}));
|
|
35
36
|
const err = new Error(
|
|
36
|
-
`
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
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
|
-
`
|
|
208
|
+
` 本批: 成功 ${batchSuccess} | 失败 ${batchFailed} | 跳过 ${batchSkipped}(累计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped})\n`,
|
|
194
209
|
);
|
|
195
210
|
await new Promise((r) => setTimeout(r, 300));
|
|
196
211
|
}
|
package/src/watch/data-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2605
|
-
*
|
|
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(
|
|
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
|
-
|
|
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,
|
package/src/watch/db-columns.js
CHANGED
package/src/watch/public/app.js
CHANGED
|
@@ -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",
|
package/src/watch/server.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
}
|