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.
@@ -112,6 +112,12 @@ body {
112
112
  color: #f472b6;
113
113
  }
114
114
 
115
+ /* 精准商家卡片文字改为红色 */
116
+ #statPreciseCard .label,
117
+ #statPreciseCard .value {
118
+ color: #ef4444;
119
+ }
120
+
115
121
  .stat-card .value-sub {
116
122
  font-size: 12px;
117
123
  font-weight: 400;
@@ -878,6 +884,14 @@ td.user-id:hover {
878
884
  display: block;
879
885
  }
880
886
 
887
+ #precisePage {
888
+ display: none;
889
+ }
890
+
891
+ #precisePage.active {
892
+ display: block;
893
+ }
894
+
881
895
  #backToTop {
882
896
  position: fixed;
883
897
  bottom: 32px;
@@ -1892,3 +1906,191 @@ td.user-id:hover {
1892
1906
  color: #888;
1893
1907
  font-weight: 600;
1894
1908
  }
1909
+
1910
+ /* ===== 精准商家:视频卡片视图 ===== */
1911
+ .precise-list-header {
1912
+ display: flex;
1913
+ align-items: center;
1914
+ justify-content: space-between;
1915
+ gap: 10px;
1916
+ margin-bottom: 8px;
1917
+ }
1918
+ .precise-list-header h3 {
1919
+ margin: 0;
1920
+ }
1921
+ .view-toggle {
1922
+ display: inline-flex;
1923
+ align-items: center;
1924
+ gap: 2px;
1925
+ border-bottom: 2px solid #2f2f3f;
1926
+ }
1927
+ .view-toggle button {
1928
+ padding: 7px 18px;
1929
+ border: none;
1930
+ border-bottom: 2px solid transparent;
1931
+ border-radius: 6px 6px 0 0;
1932
+ background: transparent;
1933
+ color: #9aa;
1934
+ font-size: 13px;
1935
+ cursor: pointer;
1936
+ transition: all 0.2s;
1937
+ }
1938
+ .view-toggle button:hover {
1939
+ color: #ccc;
1940
+ }
1941
+ .view-toggle button.active {
1942
+ color: #fff;
1943
+ border-bottom-color: #7c3aed;
1944
+ background: rgba(124, 58, 237, 0.14);
1945
+ font-weight: 600;
1946
+ }
1947
+
1948
+ .precise-video-wrap {
1949
+ display: flex;
1950
+ flex-direction: column;
1951
+ gap: 14px;
1952
+ }
1953
+ .precise-merchant-card {
1954
+ background: #1a1a24;
1955
+ border: 1px solid #2a2a3a;
1956
+ border-radius: 10px;
1957
+ padding: 12px 14px;
1958
+ }
1959
+ .precise-merchant-head {
1960
+ display: flex;
1961
+ flex-wrap: wrap;
1962
+ align-items: baseline;
1963
+ gap: 10px;
1964
+ margin-bottom: 10px;
1965
+ }
1966
+ .precise-merchant-head .pm-user {
1967
+ font-weight: 700;
1968
+ color: #60a5fa;
1969
+ text-decoration: none;
1970
+ font-size: 15px;
1971
+ }
1972
+ .precise-merchant-head .pm-user:hover {
1973
+ text-decoration: underline;
1974
+ }
1975
+ .precise-merchant-head .pm-nick {
1976
+ color: #ccc;
1977
+ font-size: 13px;
1978
+ }
1979
+ .precise-merchant-head .pm-country {
1980
+ font-size: 11px;
1981
+ background: #2a2a3a;
1982
+ color: #a78bfa;
1983
+ padding: 1px 7px;
1984
+ border-radius: 10px;
1985
+ }
1986
+ .precise-merchant-head .pm-stats {
1987
+ color: #888;
1988
+ font-size: 12px;
1989
+ margin-left: auto;
1990
+ }
1991
+
1992
+ .precise-video-grid {
1993
+ display: grid;
1994
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1995
+ gap: 10px;
1996
+ }
1997
+ .precise-video-item {
1998
+ background: #20202e;
1999
+ border: 1px solid #2c2c3c;
2000
+ border-radius: 8px;
2001
+ overflow: hidden;
2002
+ position: relative;
2003
+ display: flex;
2004
+ flex-direction: column;
2005
+ }
2006
+ .precise-video-tile {
2007
+ display: block;
2008
+ position: relative;
2009
+ aspect-ratio: 9 / 14;
2010
+ background: linear-gradient(160deg, #2a2a3a 0%, #1c1c28 100%);
2011
+ color: #e0e0e0;
2012
+ text-decoration: none;
2013
+ overflow: hidden;
2014
+ }
2015
+ .precise-video-tile:hover {
2016
+ border-color: #7c3aed;
2017
+ }
2018
+ .precise-video-tile .pv-play {
2019
+ position: absolute;
2020
+ left: 50%;
2021
+ top: 50%;
2022
+ transform: translate(-50%, -50%);
2023
+ width: 42px;
2024
+ height: 42px;
2025
+ border-radius: 50%;
2026
+ background: rgba(0, 0, 0, 0.55);
2027
+ display: flex;
2028
+ align-items: center;
2029
+ justify-content: center;
2030
+ font-size: 20px;
2031
+ color: #fff;
2032
+ }
2033
+ .precise-video-tile .pv-count {
2034
+ position: absolute;
2035
+ left: 6px;
2036
+ top: 6px;
2037
+ font-size: 12px;
2038
+ color: #fff;
2039
+ text-shadow: 0 1px 2px #000;
2040
+ }
2041
+ /* 播放量最大的视频:播放量文字特殊高亮 */
2042
+ .precise-video-tile .pv-count-max {
2043
+ color: #1a1a24;
2044
+ font-weight: 800;
2045
+ background: #fbbf24;
2046
+ padding: 1px 5px;
2047
+ border-radius: 4px;
2048
+ text-shadow: none;
2049
+ box-shadow: 0 0 6px rgba(251, 191, 36, 0.8);
2050
+ }
2051
+ .precise-video-tile .pv-time {
2052
+ position: absolute;
2053
+ left: 6px;
2054
+ top: 24px;
2055
+ font-size: 11px;
2056
+ color: #cfcfcf;
2057
+ text-shadow: 0 1px 2px #000;
2058
+ }
2059
+ .precise-video-tile .pv-ad {
2060
+ position: absolute;
2061
+ right: 6px;
2062
+ top: 6px;
2063
+ font-size: 10px;
2064
+ background: rgba(254, 44, 85, 0.9);
2065
+ color: #fff;
2066
+ padding: 1px 5px;
2067
+ border-radius: 4px;
2068
+ }
2069
+ /* 商品名悬浮在图片上,最多 2 行、固定高度以容纳 2 行文字 */
2070
+ .precise-video-item .pv-product {
2071
+ position: absolute;
2072
+ left: 0;
2073
+ right: 0;
2074
+ bottom: 0;
2075
+ padding: 30px 8px 8px;
2076
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.92), rgba(0, 0, 0, 0));
2077
+ color: #fbbf24;
2078
+ font-size: 12px;
2079
+ line-height: 1.35;
2080
+ text-decoration: none;
2081
+ text-align: left;
2082
+ display: -webkit-box;
2083
+ -webkit-box-orient: vertical;
2084
+ -webkit-line-clamp: 2;
2085
+ line-clamp: 2;
2086
+ overflow: hidden;
2087
+ box-sizing: border-box;
2088
+ height: 71px;
2089
+ }
2090
+ .precise-video-item .pv-product:hover {
2091
+ color: #fff;
2092
+ text-decoration: underline;
2093
+ }
2094
+ .precise-video-item .pv-product .pv-product-icon {
2095
+ margin-right: 4px;
2096
+ }
@@ -114,6 +114,22 @@ const STAT_GROUPS = {
114
114
  FROM jobs_done
115
115
  )`,
116
116
  },
117
+ {
118
+ // 精确商家计数:目标商家 + 7 天 EC 视频播放>1000 + 有商品 + 评论0 + 销量>10
119
+ key: "preciseMerchants",
120
+ mode: "one",
121
+ params: () => getTargetLocations(),
122
+ sql: () => `SELECT COUNT(*) AS n FROM jobs
123
+ WHERE tt_seller = 1 AND verified = 0 AND ${targetInSql()}
124
+ AND top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
125
+ AND product_review_count = 0
126
+ AND product_sold_count > 10
127
+ AND recent_ec_videos IS NOT NULL AND recent_ec_videos != ''
128
+ AND EXISTS (
129
+ SELECT 1 FROM json_each(recent_ec_videos)
130
+ WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000
131
+ )`,
132
+ },
117
133
  ],
118
134
  combine(results) {
119
135
  const tq = results.tqAgg || {};
@@ -129,6 +145,7 @@ const STAT_GROUPS = {
129
145
  restrictedUsers: jb.restricted || 0,
130
146
  errorUsers: jb.error || 0,
131
147
  targetUsers: jb.targetUsers || 0,
148
+ preciseMerchants: results.preciseMerchants?.n || 0,
132
149
  sourceStats: {
133
150
  seed: tq.seed || 0,
134
151
  video: tq.video || 0,
@@ -256,6 +273,7 @@ const EMPTY_GROUP = {
256
273
  restrictedUsers: 0,
257
274
  errorUsers: 0,
258
275
  targetUsers: 0,
276
+ preciseMerchants: 0,
259
277
  sourceStats: {
260
278
  seed: 0,
261
279
  video: 0,
@@ -1271,13 +1289,16 @@ export function startWatchServer(
1271
1289
  if (req.method === "GET" && routePath === "/api/product-tasks") {
1272
1290
  const limit = params.limit;
1273
1291
  const clientId = params.userId || "?";
1292
+ const country = params.country || null;
1274
1293
  const tasks = store.getPendingProductTasks(
1275
1294
  limit,
1276
1295
  params.userId || null,
1296
+ undefined, // claimTtlMs 用默认 5 分钟
1297
+ country,
1277
1298
  );
1278
1299
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1279
1300
  console.error(
1280
- `[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId})`,
1301
+ `[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId}${country ? `, country=${country}` : ""})`,
1281
1302
  );
1282
1303
  sendJSON(res, 200, { total: tasks.length, tasks });
1283
1304
  return;
@@ -1551,6 +1572,48 @@ export function startWatchServer(
1551
1572
  return;
1552
1573
  }
1553
1574
 
1575
+ // GET /api/precise-merchants — 精确商家列表(支持分页+搜索)
1576
+ if (req.method === "GET" && routePath === "/api/precise-merchants") {
1577
+ const result = store.getPreciseMerchants(getTargetLocations(), {
1578
+ search: params.search || undefined,
1579
+ limit: params.limit ? parseInt(params.limit, 10) : undefined,
1580
+ offset: params.offset ? parseInt(params.offset, 10) : undefined,
1581
+ });
1582
+ if (result && result.users) {
1583
+ result.users = result.users.map((u) => ({
1584
+ uniqueId: u.uniqueId,
1585
+ nickname: u.nickname || "",
1586
+ locationCreated: u.locationCreated || null,
1587
+ followerCount: u.followerCount || 0,
1588
+ videoCount: u.videoCount || 0,
1589
+ latestVideoTime: u.latestVideoTime || null,
1590
+ topVideoPlayCount: u.topVideoPlayCount || null,
1591
+ topVideoHref: u.topVideoHref || null,
1592
+ topVideoIsAd: u.topVideoIsAd ?? null,
1593
+ topVideoProductId: u.topVideoProductId || null,
1594
+ topVideoProductTitle: u.topVideoProductTitle || null,
1595
+ topVideoSeoUrl: u.topVideoSeoUrl || null,
1596
+ productRating: u.productRating ?? null,
1597
+ productReviewCount: u.productReviewCount ?? null,
1598
+ productSoldCount: u.productSoldCount ?? null,
1599
+ productName: u.productName || null,
1600
+ ecOver1k: u.ecOver1k || 0,
1601
+ maxEcPlay: u.maxEcPlay || null,
1602
+ recentEcVideos: (() => {
1603
+ try {
1604
+ return Array.isArray(u.recentEcVideos)
1605
+ ? u.recentEcVideos
1606
+ : JSON.parse(u.recentEcVideos || "[]");
1607
+ } catch {
1608
+ return [];
1609
+ }
1610
+ })(),
1611
+ }));
1612
+ }
1613
+ sendJSON(res, 200, result);
1614
+ return;
1615
+ }
1616
+
1554
1617
  if (
1555
1618
  req.method === "GET" &&
1556
1619
  routePath === "/api/target-users-by-country"
@@ -22,8 +22,11 @@ import { pushSelfLog } from "./self-log-buffer.js";
22
22
  const MAX_RESTART = 3;
23
23
  const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
24
24
  const IS_WIN = process.platform === "win32";
25
- const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
26
- const STOP_STAGGER_MS = 10000; // 强制重启时每个命令停止间隔 10 秒(杀 Edge/进程树会瞬时占资源,错峰释放)
25
+ const SPAWN_STAGGER_MS = 30000; // syncCommands 批量启动时每个进程间隔 30 秒,避免抢网络
26
+ // 代理切换后强制重启(restartAll)专用错峰间隔:切换出口后所有命令集中重建连接,
27
+ // 间隔给长一点,避免瞬时抢网络/触发风控(2026-08-29 按需加长)
28
+ const RESTART_STOP_STAGGER_MS = 20000; // 停止错峰 20s(杀 Edge/进程树瞬时占资源,错峰释放)
29
+ const RESTART_SPAWN_STAGGER_MS = 60000; // 启动错峰 60s
27
30
  const STALE_LOG_TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟无日志输出视为假死
28
31
 
29
32
  // === 控制台日志格式化(仅显示层增强,不影响任何功能链路) ===
@@ -739,9 +742,10 @@ export class ProcessManager extends EventEmitter {
739
742
  console.error(
740
743
  `[watchdog] ${reason},强制重启全部命令 (${toRestart.length}个)...`,
741
744
  );
742
- // 错峰停止:第一个立即停,之后每个间隔 STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
745
+ // 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
743
746
  for (let i = 0; i < toRestart.length; i++) {
744
- if (i > 0) await new Promise((r) => setTimeout(r, STOP_STAGGER_MS));
747
+ if (i > 0)
748
+ await new Promise((r) => setTimeout(r, RESTART_STOP_STAGGER_MS));
745
749
  if (this.shutdown) {
746
750
  console.error("[watchdog] 看门狗已停止,中止停止命令");
747
751
  break;
@@ -751,9 +755,10 @@ export class ProcessManager extends EventEmitter {
751
755
  `[watchdog] 命令 ${toRestart[i].id} 已停止 (${i + 1}/${toRestart.length})`,
752
756
  );
753
757
  }
754
- // 错峰逐个重启:第一个立即起,之后每个间隔 SPAWN_STAGGER_MS
758
+ // 错峰逐个重启:第一个立即起,之后每个间隔 RESTART_SPAWN_STAGGER_MS
755
759
  for (let i = 0; i < toRestart.length; i++) {
756
- if (i > 0) await new Promise((r) => setTimeout(r, SPAWN_STAGGER_MS));
760
+ if (i > 0)
761
+ await new Promise((r) => setTimeout(r, RESTART_SPAWN_STAGGER_MS));
757
762
  if (this.shutdown) {
758
763
  console.error("[watchdog] 看门狗已停止,中止重启命令");
759
764
  break;
@@ -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
- );