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.
- package/package.json +1 -1
- package/scripts/test-daemon-failover.mjs +61 -4
- package/scripts/test-product-fill-cdp.mjs +168 -0
- package/src/clash/checker.js +41 -0
- package/src/clash/daemon.js +26 -0
- package/src/cli/comments.js +265 -388
- package/src/cli/product-fill.js +255 -128
- package/src/cli/tag.js +143 -334
- package/src/lib/args.js +15 -5
- package/src/lib/constants.js +14 -10
- package/src/lib/shop-scraper.mjs +69 -6
- package/src/watch/data-store.js +129 -6
- package/src/watch/public/app.js +290 -0
- package/src/watch/public/index.html +99 -1
- package/src/watch/public/style.css +202 -0
- package/src/watch/server.js +64 -1
- package/src/watchdog/process-manager.js +11 -6
- package/scripts/test-product-fill.mjs +0 -306
|
@@ -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
|
+
}
|
package/src/watch/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
// 错峰停止:第一个立即停,之后每个间隔
|
|
745
|
+
// 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
|
|
743
746
|
for (let i = 0; i < toRestart.length; i++) {
|
|
744
|
-
if (i > 0)
|
|
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
|
-
// 错峰逐个重启:第一个立即起,之后每个间隔
|
|
758
|
+
// 错峰逐个重启:第一个立即起,之后每个间隔 RESTART_SPAWN_STAGGER_MS
|
|
755
759
|
for (let i = 0; i < toRestart.length; i++) {
|
|
756
|
-
if (i > 0)
|
|
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
|
-
);
|