tt-help-cli-ycl 1.4.145 → 1.4.147
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 +1 -1
- package/scripts/test-product-fill-cdp.mjs +292 -0
- package/src/cli/comments.js +265 -388
- package/src/cli/product-fill.js +379 -128
- package/src/cli/tag.js +43 -9
- package/src/lib/args.js +21 -5
- package/src/lib/constants.js +9 -5
- package/src/lib/shop-scraper.mjs +69 -6
- package/src/watch/data-store.js +41 -6
- package/src/watch/public/app.js +58 -0
- package/src/watch/public/index.html +46 -1
- package/src/watch/server.js +4 -1
- package/scripts/test-product-fill.mjs +0 -306
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 补商品命令核心逻辑测试
|
|
3
|
-
*
|
|
4
|
-
* 验证「领取任务 → 补商品详情 → 回传服务端」全链路,自包含:
|
|
5
|
-
* - mock 服务端:/api/product-tasks(领任务)、/api/product-fill/:uniqueId(收回传)
|
|
6
|
-
* - mock webserver:/api/tiktok/product(商品详情)
|
|
7
|
-
*
|
|
8
|
-
* 运行: TMPDIR=$(mktemp -d) node scripts/test-product-fill.mjs
|
|
9
|
-
*/
|
|
10
|
-
import http from "node:http";
|
|
11
|
-
import assert from "node:assert";
|
|
12
|
-
|
|
13
|
-
// ────────────────────────────────────────────────
|
|
14
|
-
// mock webserver:/api/tiktok/product
|
|
15
|
-
// failUrls → 502(普通失败)
|
|
16
|
-
// captchaUrls → 503 retryable(验证码,跳过)
|
|
17
|
-
// 其余 → 200 {rating, reviewCount, soldCount, productName}
|
|
18
|
-
// ────────────────────────────────────────────────
|
|
19
|
-
async function startMockWebserver({
|
|
20
|
-
failUrls = new Set(),
|
|
21
|
-
captchaUrls = new Set(),
|
|
22
|
-
} = {}) {
|
|
23
|
-
const server = http.createServer(async (req, res) => {
|
|
24
|
-
if (req.method === "POST" && req.url.startsWith("/api/tiktok/product")) {
|
|
25
|
-
const body = await readBody(req);
|
|
26
|
-
const { url } = JSON.parse(body || "{}");
|
|
27
|
-
if (captchaUrls.has(url)) {
|
|
28
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
29
|
-
res.end(
|
|
30
|
-
JSON.stringify({
|
|
31
|
-
error: "Blocked by TikTok security check (captcha)",
|
|
32
|
-
retryable: true,
|
|
33
|
-
}),
|
|
34
|
-
);
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
if (failUrls.has(url)) {
|
|
38
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
39
|
-
res.end(JSON.stringify({ error: "Bad Gateway" }));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
43
|
-
res.end(
|
|
44
|
-
JSON.stringify({
|
|
45
|
-
rating: 4.7,
|
|
46
|
-
reviewCount: 3303,
|
|
47
|
-
soldCount: 296,
|
|
48
|
-
productName: `商品-${url.slice(-8)}`,
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
54
|
-
res.end(JSON.stringify({ error: "not found" }));
|
|
55
|
-
});
|
|
56
|
-
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
57
|
-
return {
|
|
58
|
-
url: `http://127.0.0.1:${server.address().port}`,
|
|
59
|
-
close: () => new Promise((r) => server.close(r)),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ────────────────────────────────────────────────
|
|
64
|
-
// mock 服务端:/api/product-tasks + /api/product-fill/:uniqueId
|
|
65
|
-
// tasksPool 可变:每次 GET 弹出 min(limit, 剩余) 个任务
|
|
66
|
-
// ────────────────────────────────────────────────
|
|
67
|
-
async function startMockServer(tasksPool) {
|
|
68
|
-
const received = []; // 回传记录
|
|
69
|
-
const server = http.createServer(async (req, res) => {
|
|
70
|
-
const url = new URL(req.url, "http://x");
|
|
71
|
-
if (req.method === "GET" && url.pathname === "/api/product-tasks") {
|
|
72
|
-
const limit = Number(url.searchParams.get("limit") || 1);
|
|
73
|
-
const tasks = tasksPool.splice(0, limit);
|
|
74
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
75
|
-
res.end(JSON.stringify({ total: tasks.length, tasks }));
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const m = url.pathname.match(/^\/api\/product-fill\/([^/]+)$/);
|
|
79
|
-
if (req.method === "POST" && m) {
|
|
80
|
-
const body = JSON.parse((await readBody(req)) || "{}");
|
|
81
|
-
received.push({ uniqueId: decodeURIComponent(m[1]), ...body });
|
|
82
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
83
|
-
res.end(JSON.stringify({ ok: true }));
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
87
|
-
res.end(JSON.stringify({ error: "not found" }));
|
|
88
|
-
});
|
|
89
|
-
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
90
|
-
return {
|
|
91
|
-
url: `http://127.0.0.1:${server.address().port}`,
|
|
92
|
-
received,
|
|
93
|
-
close: () => new Promise((r) => server.close(r)),
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function readBody(req) {
|
|
98
|
-
return new Promise((resolve) => {
|
|
99
|
-
let data = "";
|
|
100
|
-
req.on("data", (c) => (data += c));
|
|
101
|
-
req.on("end", () => resolve(data));
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ────────────────────────────────────────────────
|
|
106
|
-
// 补商品客户端核心逻辑(未来命令的雏形)
|
|
107
|
-
// 返回 { batches, success, failed, skipped, received }
|
|
108
|
-
// ────────────────────────────────────────────────
|
|
109
|
-
async function fetchProductDetail(webserverUrl, seoUrl) {
|
|
110
|
-
const res = await fetch(`${webserverUrl}/api/tiktok/product`, {
|
|
111
|
-
method: "POST",
|
|
112
|
-
headers: { "Content-Type": "application/json" },
|
|
113
|
-
body: JSON.stringify({ url: seoUrl }),
|
|
114
|
-
signal: AbortSignal.timeout(10000),
|
|
115
|
-
});
|
|
116
|
-
if (res.status === 503) {
|
|
117
|
-
const err = new Error(
|
|
118
|
-
`captcha: ${(await res.json().catch(() => ({}))).error || "Blocked"}`,
|
|
119
|
-
);
|
|
120
|
-
err.retryable = true;
|
|
121
|
-
throw err;
|
|
122
|
-
}
|
|
123
|
-
if (!res.ok) {
|
|
124
|
-
const err = new Error(`HTTP ${res.status}`);
|
|
125
|
-
err.status = res.status;
|
|
126
|
-
throw err;
|
|
127
|
-
}
|
|
128
|
-
return res.json();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function fillProducts({
|
|
132
|
-
serverUrl,
|
|
133
|
-
webserverUrl,
|
|
134
|
-
limit = 2,
|
|
135
|
-
intervalMs = 50,
|
|
136
|
-
maxBatches = 4,
|
|
137
|
-
}) {
|
|
138
|
-
let batches = 0,
|
|
139
|
-
success = 0,
|
|
140
|
-
failed = 0,
|
|
141
|
-
skipped = 0;
|
|
142
|
-
const received = [];
|
|
143
|
-
for (let b = 0; b < maxBatches; b++) {
|
|
144
|
-
batches++;
|
|
145
|
-
const res = await fetch(
|
|
146
|
-
`${serverUrl}/api/product-tasks?limit=${limit}&userId=test-user`,
|
|
147
|
-
);
|
|
148
|
-
const { tasks = [] } = await res.json();
|
|
149
|
-
if (tasks.length === 0) {
|
|
150
|
-
await new Promise((r) => setTimeout(r, intervalMs)); // 空闲等待
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
for (const task of tasks) {
|
|
154
|
-
try {
|
|
155
|
-
const detail = await fetchProductDetail(webserverUrl, task.seoUrl);
|
|
156
|
-
const post = await fetch(
|
|
157
|
-
`${serverUrl}/api/product-fill/${encodeURIComponent(task.uniqueId)}`,
|
|
158
|
-
{
|
|
159
|
-
method: "POST",
|
|
160
|
-
headers: { "Content-Type": "application/json" },
|
|
161
|
-
body: JSON.stringify({ productDetail: detail }),
|
|
162
|
-
},
|
|
163
|
-
);
|
|
164
|
-
if (post.ok) {
|
|
165
|
-
success++;
|
|
166
|
-
received.push({ uniqueId: task.uniqueId, productDetail: detail });
|
|
167
|
-
} else {
|
|
168
|
-
failed++;
|
|
169
|
-
}
|
|
170
|
-
} catch (e) {
|
|
171
|
-
if (e.retryable) skipped++;
|
|
172
|
-
else failed++;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return { batches, success, failed, skipped, received };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ────────────────────────────────────────────────
|
|
180
|
-
// 测试用例
|
|
181
|
-
// ────────────────────────────────────────────────
|
|
182
|
-
let passed = 0;
|
|
183
|
-
function check(name, fn) {
|
|
184
|
-
try {
|
|
185
|
-
fn();
|
|
186
|
-
passed++;
|
|
187
|
-
console.log(` ✅ ${name}`);
|
|
188
|
-
} catch (e) {
|
|
189
|
-
console.error(` ❌ ${name}\n ${e.message}`);
|
|
190
|
-
process.exitCode = 1;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// 用例 1:正常链路——领取 2 个缺商品任务 → 补商品成功 → 回传 payload 正确
|
|
195
|
-
async function testNormalFlow() {
|
|
196
|
-
console.log("\n[1] 正常链路:领取→补商品→回传");
|
|
197
|
-
const tasksPool = [
|
|
198
|
-
{ uniqueId: "userA", seoUrl: "https://www.tiktok.com/shop/pdp/a123" },
|
|
199
|
-
{ uniqueId: "userB", seoUrl: "https://www.tiktok.com/shop/pdp/b456" },
|
|
200
|
-
];
|
|
201
|
-
const ws = await startMockWebserver();
|
|
202
|
-
const srv = await startMockServer(tasksPool);
|
|
203
|
-
const r = await fillProducts({
|
|
204
|
-
serverUrl: srv.url,
|
|
205
|
-
webserverUrl: ws.url,
|
|
206
|
-
limit: 2,
|
|
207
|
-
maxBatches: 1,
|
|
208
|
-
});
|
|
209
|
-
check("成功数 = 2", () => assert.strictEqual(r.success, 2));
|
|
210
|
-
check("失败/跳过 = 0", () => assert.strictEqual(r.failed + r.skipped, 0));
|
|
211
|
-
check("回传数 = 2", () => assert.strictEqual(r.received.length, 2));
|
|
212
|
-
check("回传 payload 含 productDetail 且字段正确", () => {
|
|
213
|
-
assert.strictEqual(r.received[0].productDetail.rating, 4.7);
|
|
214
|
-
assert.strictEqual(r.received[0].productDetail.soldCount, 296);
|
|
215
|
-
assert.ok(r.received[0].productDetail.productName);
|
|
216
|
-
});
|
|
217
|
-
check("服务端收到 uniqueId 匹配", () =>
|
|
218
|
-
assert.strictEqual(srv.received[0].uniqueId, "userA"),
|
|
219
|
-
);
|
|
220
|
-
await ws.close();
|
|
221
|
-
await srv.close();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// 用例 2:空任务——先无任务(空闲等待),后补任务仍能接上
|
|
225
|
-
async function testEmptyThenTask() {
|
|
226
|
-
console.log("\n[2] 空任务:空闲等待后仍能接到任务");
|
|
227
|
-
const tasksPool = [];
|
|
228
|
-
const ws = await startMockWebserver();
|
|
229
|
-
const srv = await startMockServer(tasksPool);
|
|
230
|
-
// 启动时任务池为空 → 第一轮空闲;随后注入任务
|
|
231
|
-
const runner = fillProducts({
|
|
232
|
-
serverUrl: srv.url,
|
|
233
|
-
webserverUrl: ws.url,
|
|
234
|
-
limit: 1,
|
|
235
|
-
intervalMs: 80,
|
|
236
|
-
maxBatches: 4,
|
|
237
|
-
});
|
|
238
|
-
setTimeout(() => {
|
|
239
|
-
tasksPool.push({
|
|
240
|
-
uniqueId: "lateUser",
|
|
241
|
-
seoUrl: "https://www.tiktok.com/shop/pdp/late789",
|
|
242
|
-
});
|
|
243
|
-
}, 150);
|
|
244
|
-
const r = await runner;
|
|
245
|
-
check("空闲等待后成功补到 1 个", () => assert.strictEqual(r.success, 1));
|
|
246
|
-
check("无失败", () => assert.strictEqual(r.failed, 0));
|
|
247
|
-
await ws.close();
|
|
248
|
-
await srv.close();
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// 用例 3:502 失败——该任务 failed,不回传成功
|
|
252
|
-
async function testHttpFail() {
|
|
253
|
-
console.log("\n[3] 补商品 502 失败:计入 failed 且不回传");
|
|
254
|
-
const failUrl = "https://www.tiktok.com/shop/pdp/fail502";
|
|
255
|
-
const tasksPool = [
|
|
256
|
-
{ uniqueId: "failUser", seoUrl: failUrl },
|
|
257
|
-
{ uniqueId: "okUser", seoUrl: "https://www.tiktok.com/shop/pdp/ok001" },
|
|
258
|
-
];
|
|
259
|
-
const ws = await startMockWebserver({ failUrls: new Set([failUrl]) });
|
|
260
|
-
const srv = await startMockServer(tasksPool);
|
|
261
|
-
const r = await fillProducts({
|
|
262
|
-
serverUrl: srv.url,
|
|
263
|
-
webserverUrl: ws.url,
|
|
264
|
-
limit: 2,
|
|
265
|
-
maxBatches: 1,
|
|
266
|
-
});
|
|
267
|
-
check("成功 1 个", () => assert.strictEqual(r.success, 1));
|
|
268
|
-
check("失败 1 个", () => assert.strictEqual(r.failed, 1));
|
|
269
|
-
check("只回传成功的(failUser 不在回传里)", () => {
|
|
270
|
-
assert.strictEqual(r.received.length, 1);
|
|
271
|
-
assert.strictEqual(r.received[0].uniqueId, "okUser");
|
|
272
|
-
});
|
|
273
|
-
await ws.close();
|
|
274
|
-
await srv.close();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// 用例 4:captcha——503 retryable,计入 skipped
|
|
278
|
-
async function testCaptcha() {
|
|
279
|
-
console.log("\n[4] 商品详情验证码(503):计入 skipped");
|
|
280
|
-
const capUrl = "https://www.tiktok.com/shop/pdp/cap999";
|
|
281
|
-
const tasksPool = [{ uniqueId: "capUser", seoUrl: capUrl }];
|
|
282
|
-
const ws = await startMockWebserver({ captchaUrls: new Set([capUrl]) });
|
|
283
|
-
const srv = await startMockServer(tasksPool);
|
|
284
|
-
const r = await fillProducts({
|
|
285
|
-
serverUrl: srv.url,
|
|
286
|
-
webserverUrl: ws.url,
|
|
287
|
-
limit: 1,
|
|
288
|
-
maxBatches: 1,
|
|
289
|
-
});
|
|
290
|
-
check("skipped = 1", () => assert.strictEqual(r.skipped, 1));
|
|
291
|
-
check("失败/成功 = 0", () => assert.strictEqual(r.failed + r.success, 0));
|
|
292
|
-
check("未回传(验证码跳过,不提交)", () =>
|
|
293
|
-
assert.strictEqual(r.received.length, 0),
|
|
294
|
-
);
|
|
295
|
-
await ws.close();
|
|
296
|
-
await srv.close();
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
console.log("===== 补商品命令核心逻辑测试 =====");
|
|
300
|
-
await testNormalFlow();
|
|
301
|
-
await testEmptyThenTask();
|
|
302
|
-
await testHttpFail();
|
|
303
|
-
await testCaptcha();
|
|
304
|
-
console.log(
|
|
305
|
-
`\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
|
|
306
|
-
);
|