tt-help-cli-ycl 1.4.145 → 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-product-fill-cdp.mjs +168 -0
- package/src/cli/comments.js +265 -388
- package/src/cli/product-fill.js +255 -128
- package/src/cli/tag.js +43 -9
- package/src/lib/args.js +15 -5
- package/src/lib/constants.js +8 -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
package/src/lib/args.js
CHANGED
|
@@ -894,15 +894,25 @@ function parseProductFillArgs(args) {
|
|
|
894
894
|
let limit = 5;
|
|
895
895
|
let interval = 10;
|
|
896
896
|
let serverUrl = defaultServer;
|
|
897
|
-
let webserverUrl = null;
|
|
898
897
|
let proxyUrl = null;
|
|
898
|
+
/** 端口/国家配置:--profiles US/9223,JP/1222 → [{country:"US",port:9223},...] */
|
|
899
|
+
let profiles = [];
|
|
899
900
|
|
|
900
901
|
for (let i = 0; i < args.length; i++) {
|
|
901
902
|
const arg = args[i];
|
|
902
903
|
if (arg === "-s" || arg === "--server") {
|
|
903
904
|
serverUrl = args[++i];
|
|
904
|
-
} else if (arg === "
|
|
905
|
-
|
|
905
|
+
} else if (arg === "--profiles") {
|
|
906
|
+
for (const seg of String(args[++i] || "").split(",")) {
|
|
907
|
+
const [country, portStr] = seg.trim().split("/");
|
|
908
|
+
const port = parseInt(portStr, 10);
|
|
909
|
+
const cc = String(country || "")
|
|
910
|
+
.trim()
|
|
911
|
+
.toUpperCase();
|
|
912
|
+
if (port && /^[A-Z]{2}$/.test(cc)) {
|
|
913
|
+
profiles.push({ country: cc, port });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
906
916
|
} else if (arg === "--proxy") {
|
|
907
917
|
proxyUrl = args[++i] || null;
|
|
908
918
|
} else if (arg === "-i" || arg === "--interval") {
|
|
@@ -917,8 +927,8 @@ function parseProductFillArgs(args) {
|
|
|
917
927
|
productFillLimit: limit,
|
|
918
928
|
productFillInterval: interval,
|
|
919
929
|
serverUrl,
|
|
920
|
-
productFillWebserver: webserverUrl,
|
|
921
930
|
productFillProxy: proxyUrl,
|
|
931
|
+
productFillProfiles: profiles,
|
|
922
932
|
urls: [],
|
|
923
933
|
outputFormat: "json",
|
|
924
934
|
exploreCount: 0,
|
|
@@ -1564,7 +1574,7 @@ export function parseArgs() {
|
|
|
1564
1574
|
productFillLimit: 5,
|
|
1565
1575
|
productFillInterval: 10,
|
|
1566
1576
|
serverUrl: defaultServer,
|
|
1567
|
-
|
|
1577
|
+
productFillProfiles: [],
|
|
1568
1578
|
showHelp: true,
|
|
1569
1579
|
showVersion: false,
|
|
1570
1580
|
urls: [],
|
package/src/lib/constants.js
CHANGED
|
@@ -222,14 +222,17 @@ const HELP_TEXT = [
|
|
|
222
222
|
" 示例: tt-help attach -p 5 -i 10",
|
|
223
223
|
" tt-help attach -c PL,DE,FR -p 5",
|
|
224
224
|
"",
|
|
225
|
-
" tt-help product-fill [
|
|
226
|
-
"
|
|
225
|
+
" tt-help product-fill --profiles <XX/port>[,<XX/port>...] [-s 服务端地址] [-i 间隔秒数] [-l 每批上限] [--proxy URL]",
|
|
226
|
+
" 循环领取缺商品详情任务,抓取评分/销量/评论并回写 jobs 表",
|
|
227
|
+
" --profiles 国家/端口对绑定(端口 profile 区域需与商品国家一致,如 US/9223,JP/1222);",
|
|
228
|
+
" 每轮探测代理出口,在配置中匹配端口领取对应国家任务,出口不在配置范围则跳过",
|
|
227
229
|
" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
|
|
228
|
-
"
|
|
230
|
+
" --proxy <URL> 浏览器启动代理及出口探测(默认: http://127.0.0.1:7897)",
|
|
229
231
|
" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
|
|
230
232
|
" -l, --limit <N> 每批领取任务数上限(默认: 5)",
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
+
" 下架壳页/连续失败3次/无效URL → 回写 rating=-1 哨兵永久移出候选",
|
|
234
|
+
" 示例: tt-help product-fill --profiles US/9223",
|
|
235
|
+
" tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
|
|
233
236
|
"",
|
|
234
237
|
" tt-help recheck [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
|
|
235
238
|
" 重检 jobs 表中 done 非商家用户(tt_seller!=1),7 天未更新才检测",
|
package/src/lib/shop-scraper.mjs
CHANGED
|
@@ -190,6 +190,8 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
190
190
|
const agent = proxy ? new ProxyAgent(proxy) : null;
|
|
191
191
|
|
|
192
192
|
let lastError;
|
|
193
|
+
let captchaRetries = 0; // 验证码弹浏览器重试次数(防无限重试)
|
|
194
|
+
const MAX_CAPTCHA_RETRIES = 2;
|
|
193
195
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
194
196
|
try {
|
|
195
197
|
const headers = { ...PDP_HEADERS };
|
|
@@ -216,8 +218,15 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
216
218
|
err.message?.includes("Blocked by TikTok") &&
|
|
217
219
|
options.captchaFallback
|
|
218
220
|
) {
|
|
221
|
+
if (captchaRetries >= MAX_CAPTCHA_RETRIES) {
|
|
222
|
+
console.error(
|
|
223
|
+
`[shop-scraper] Captcha persists after ${captchaRetries} browser attempts, giving up`,
|
|
224
|
+
);
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
captchaRetries++;
|
|
219
228
|
console.error(
|
|
220
|
-
`[shop-scraper] Captcha on ${proxy}, clearing cached cookies...`,
|
|
229
|
+
`[shop-scraper] Captcha on ${proxy}, clearing cached cookies (browser attempt ${captchaRetries}/${MAX_CAPTCHA_RETRIES})...`,
|
|
221
230
|
);
|
|
222
231
|
clearCachedCookies(proxy);
|
|
223
232
|
console.error("[shop-scraper] Opening browser for manual captcha...");
|
|
@@ -255,6 +264,12 @@ export function parseProductHtml(html) {
|
|
|
255
264
|
throw new Error("Blocked by TikTok security check (captcha)");
|
|
256
265
|
}
|
|
257
266
|
|
|
267
|
+
// 页面不含任何商品数据标识 → 空壳页(风控降级/跳转/空白),
|
|
268
|
+
// 抛可重试错误,避免把全 null 数据当成功回写
|
|
269
|
+
if (!html.includes('"product_model"') && !html.includes('"review_ratings"')) {
|
|
270
|
+
throw new Error("Empty product page (no product data markers)");
|
|
271
|
+
}
|
|
272
|
+
|
|
258
273
|
const result = {
|
|
259
274
|
rating: null,
|
|
260
275
|
reviewCount: null,
|
|
@@ -293,6 +308,19 @@ export function parseProductHtml(html) {
|
|
|
293
308
|
result.ratingBreakdown = ratings.rating_result ?? null;
|
|
294
309
|
}
|
|
295
310
|
|
|
311
|
+
// 壳页识别:有 product_id 骨架但 seller_id 为空且无任何销售/名称数据
|
|
312
|
+
// → 商品已下架/店铺异常(200 空壳页),抛 delisted 供调用方标记放弃
|
|
313
|
+
const hasSaleData =
|
|
314
|
+
result.soldCount !== null ||
|
|
315
|
+
result.rating !== null ||
|
|
316
|
+
result.reviewCount !== null ||
|
|
317
|
+
result.productName !== null;
|
|
318
|
+
if (result.productId && !result.sellerId && !hasSaleData) {
|
|
319
|
+
const err = new Error("Product delisted (seller_id empty, no sale data)");
|
|
320
|
+
err.delisted = true;
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
|
|
296
324
|
return result;
|
|
297
325
|
}
|
|
298
326
|
|
|
@@ -303,11 +331,29 @@ export function parseProductHtml(html) {
|
|
|
303
331
|
* @param {string} url - TikTok Shop PDP URL
|
|
304
332
|
* @returns {Promise<{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}>}
|
|
305
333
|
*/
|
|
306
|
-
export async function getProductInfoCDP(page, url) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
334
|
+
export async function getProductInfoCDP(page, url, options = {}) {
|
|
335
|
+
const { gotoTimeout = 60000, renderWaitMs = 15000 } = options;
|
|
336
|
+
await page.goto(url, {
|
|
337
|
+
waitUntil: "domcontentloaded",
|
|
338
|
+
timeout: gotoTimeout,
|
|
339
|
+
});
|
|
340
|
+
// 轮询等待 CSR 渲染出商品数据标识(或出现验证码标记),
|
|
341
|
+
// 不用 networkidle:TikTok 页面持续请求,networkidle 极易超时
|
|
342
|
+
const deadline = Date.now() + renderWaitMs;
|
|
343
|
+
let html = "";
|
|
344
|
+
while (Date.now() < deadline) {
|
|
345
|
+
html = await page.content();
|
|
346
|
+
if (
|
|
347
|
+
html.includes('"product_model"') ||
|
|
348
|
+
html.includes('"review_ratings"') ||
|
|
349
|
+
html.includes("captcha-verify-container") ||
|
|
350
|
+
html.includes("<title>Security Check</title>")
|
|
351
|
+
) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
355
|
+
}
|
|
356
|
+
if (!html) html = await page.content();
|
|
311
357
|
return parseProductHtml(html);
|
|
312
358
|
}
|
|
313
359
|
|
|
@@ -320,3 +366,20 @@ export function formatCount(n) {
|
|
|
320
366
|
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
|
321
367
|
return String(n);
|
|
322
368
|
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 判断商品详情是否全空(页面被拦截/结构变化时解析结果全为 null)。
|
|
372
|
+
* 只判实际回写的四个字段:rating/soldCount/reviewCount/productName。
|
|
373
|
+
* productId/sellerId 不回写,有值也不能视为"抓到了数据",
|
|
374
|
+
* 否则壳页(仅含 product_id 骨架)会被误报成功、任务永久留在候选池。
|
|
375
|
+
* @param {object} detail - parseProductHtml 的返回值
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
export function isEmptyProductDetail(detail) {
|
|
379
|
+
return (
|
|
380
|
+
(detail?.rating ?? null) === null &&
|
|
381
|
+
(detail?.reviewCount ?? null) === null &&
|
|
382
|
+
(detail?.soldCount ?? null) === null &&
|
|
383
|
+
!detail?.productName
|
|
384
|
+
);
|
|
385
|
+
}
|
package/src/watch/data-store.js
CHANGED
|
@@ -2772,20 +2772,27 @@ export function createStore(filePath, options = {}) {
|
|
|
2772
2772
|
* @param {number|string} limit
|
|
2773
2773
|
* @param {string|null} claimBy 领取者标识(userId),仅作标记/日志
|
|
2774
2774
|
* @param {number} claimTtlMs 锁定超时(默认 5 分钟)
|
|
2775
|
+
* @param {string|null} country 国家过滤(大写两位,如 US/JP);非空时只返回
|
|
2776
|
+
* location_created 匹配的任务,location_created 为 NULL 的任务不分发;
|
|
2777
|
+
* 传 null/空串表示不限(兼容旧客户端)
|
|
2775
2778
|
* @returns {{uniqueId:string, nickname:string|null, seoUrl:string, productId:string|null, productTitle:string|null}[]}
|
|
2776
2779
|
*/
|
|
2777
2780
|
function getPendingProductTasks(
|
|
2778
2781
|
limit,
|
|
2779
2782
|
claimBy = null,
|
|
2780
2783
|
claimTtlMs = 5 * 60 * 1000,
|
|
2784
|
+
country = null,
|
|
2781
2785
|
) {
|
|
2782
2786
|
if (getDb()) {
|
|
2783
2787
|
const l = Math.max(1, parseInt(limit) || 5);
|
|
2784
2788
|
const now = Date.now();
|
|
2785
2789
|
const claimExpireBefore = now - claimTtlMs;
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2790
|
+
// 国家过滤:只认大写两位字母,避免异常值影响;无国家段的任务
|
|
2791
|
+
// 不分发给任何按国家领取的客户端(后续人工回填处理)
|
|
2792
|
+
const countryFilter = country
|
|
2793
|
+
? String(country).trim().toUpperCase().slice(0, 2)
|
|
2794
|
+
: "";
|
|
2795
|
+
let sql = `
|
|
2789
2796
|
SELECT unique_id, nickname, top_video_seo_url, top_video_product_id,
|
|
2790
2797
|
top_video_product_title
|
|
2791
2798
|
FROM jobs
|
|
@@ -2793,12 +2800,21 @@ export function createStore(filePath, options = {}) {
|
|
|
2793
2800
|
AND (product_name IS NULL OR product_name = '')
|
|
2794
2801
|
AND product_rating IS NULL
|
|
2795
2802
|
AND product_sold_count IS NULL
|
|
2803
|
+
`;
|
|
2804
|
+
const params = [];
|
|
2805
|
+
if (countryFilter.length === 2) {
|
|
2806
|
+
sql += ` AND location_created = ?`;
|
|
2807
|
+
params.push(countryFilter);
|
|
2808
|
+
}
|
|
2809
|
+
sql += `
|
|
2796
2810
|
AND (claimed_at IS NULL OR claimed_at < ?)
|
|
2797
2811
|
ORDER BY COALESCE(claimed_at, 0) ASC, updated_at DESC
|
|
2798
2812
|
LIMIT ?
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2813
|
+
`;
|
|
2814
|
+
params.push(claimExpireBefore, l);
|
|
2815
|
+
const rows = getDb()
|
|
2816
|
+
.prepare(sql)
|
|
2817
|
+
.all(...params);
|
|
2802
2818
|
if (rows.length > 0) {
|
|
2803
2819
|
// 领取即锁定:写入 claimed_at/claimed_by,避免多客户端重复抓取同一商品
|
|
2804
2820
|
const ids = rows.map((r) => r.unique_id);
|
|
@@ -2825,6 +2841,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2825
2841
|
* 回写商品详情到 jobs 表(product_* 列)
|
|
2826
2842
|
* @param {string} uniqueId
|
|
2827
2843
|
* @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
|
|
2844
|
+
* rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
|
|
2828
2845
|
*/
|
|
2829
2846
|
function updateProductInfo(uniqueId, productDetail) {
|
|
2830
2847
|
if (getDb()) {
|
|
@@ -2843,6 +2860,24 @@ export function createStore(filePath, options = {}) {
|
|
|
2843
2860
|
"jobs",
|
|
2844
2861
|
);
|
|
2845
2862
|
if (ret.error) return ret;
|
|
2863
|
+
// 校验:写入后 product_* 仍全空 → 回写无效(壳页假成功防御),
|
|
2864
|
+
// 防止任务在候选池里无限轮转
|
|
2865
|
+
const row = getDb()
|
|
2866
|
+
.prepare(
|
|
2867
|
+
`SELECT product_name, product_rating, product_sold_count,
|
|
2868
|
+
product_review_count
|
|
2869
|
+
FROM jobs WHERE unique_id = ?`,
|
|
2870
|
+
)
|
|
2871
|
+
.get(uniqueId);
|
|
2872
|
+
if (
|
|
2873
|
+
row &&
|
|
2874
|
+
(row.product_name === null || row.product_name === "") &&
|
|
2875
|
+
row.product_rating === null &&
|
|
2876
|
+
row.product_sold_count === null &&
|
|
2877
|
+
row.product_review_count === null
|
|
2878
|
+
) {
|
|
2879
|
+
return { ok: false, error: "product 字段仍全空,回写无效" };
|
|
2880
|
+
}
|
|
2846
2881
|
return { ok: true };
|
|
2847
2882
|
}
|
|
2848
2883
|
return { ok: false, error: "db not initialized" };
|
package/src/watch/public/app.js
CHANGED
|
@@ -1394,6 +1394,16 @@ function navigateToPrecise() {
|
|
|
1394
1394
|
window.location.hash = "#precise";
|
|
1395
1395
|
}
|
|
1396
1396
|
|
|
1397
|
+
// product-fill 任务查询条件表单:折叠 / 展开
|
|
1398
|
+
function togglePreciseCriteria() {
|
|
1399
|
+
const body = document.getElementById("preciseCriteriaBody");
|
|
1400
|
+
const arrow = document.getElementById("preciseCriteriaArrow");
|
|
1401
|
+
if (!body) return;
|
|
1402
|
+
const collapsed = body.style.display === "none";
|
|
1403
|
+
body.style.display = collapsed ? "" : "none";
|
|
1404
|
+
if (arrow) arrow.textContent = collapsed ? "▼" : "▶";
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1397
1407
|
function navigateToTags() {
|
|
1398
1408
|
window.location.hash = "#tags";
|
|
1399
1409
|
}
|
|
@@ -3801,12 +3811,14 @@ fetchUsers();
|
|
|
3801
3811
|
fetchClientErrors();
|
|
3802
3812
|
initTableSorting();
|
|
3803
3813
|
initTargetTableSorting();
|
|
3814
|
+
fetchWatchdogBadge();
|
|
3804
3815
|
|
|
3805
3816
|
// 错峰轮询(服务端已缓存,瞬时响应无压力)
|
|
3806
3817
|
setInterval(fetchStats, 10000);
|
|
3807
3818
|
setTimeout(() => setInterval(fetchUsers, 10000), 2500);
|
|
3808
3819
|
setTimeout(() => setInterval(fetchClientErrors, 10000), 5000);
|
|
3809
3820
|
setTimeout(() => setInterval(refreshUserUpdateIfActive, 10000), 7500);
|
|
3821
|
+
setTimeout(() => setInterval(fetchWatchdogBadge, 30000), 9000);
|
|
3810
3822
|
|
|
3811
3823
|
// ========== 看门狗页面 ==========
|
|
3812
3824
|
|
|
@@ -3830,6 +3842,7 @@ async function fetchWatchdogData() {
|
|
|
3830
3842
|
try {
|
|
3831
3843
|
const res = await fetch("/api/watchdog/clients");
|
|
3832
3844
|
_wdData = await res.json();
|
|
3845
|
+
updateWatchdogBadge(_wdData.clients || [], _wdData.clientCommands || {});
|
|
3833
3846
|
renderWatchdogDashboard(
|
|
3834
3847
|
_wdData.clients || [],
|
|
3835
3848
|
_wdData.clientCommands || {},
|
|
@@ -3845,6 +3858,51 @@ async function fetchWatchdogData() {
|
|
|
3845
3858
|
}
|
|
3846
3859
|
}
|
|
3847
3860
|
|
|
3861
|
+
// ── 导航栏"看门狗"离线 badge ──
|
|
3862
|
+
// 离线数口径与看门狗页面卡片一致:
|
|
3863
|
+
// = 已注册但离线的客户端数 + 配置了命令但从未注册(renderOfflineCard)的 userId 数
|
|
3864
|
+
function updateWatchdogBadge(clients, clientCommands) {
|
|
3865
|
+
const badge = document.getElementById("wdOfflineBadge");
|
|
3866
|
+
if (!badge) return;
|
|
3867
|
+
const offlineClients = clients.filter(function (c) {
|
|
3868
|
+
return !c.online;
|
|
3869
|
+
}).length;
|
|
3870
|
+
const registeredUids = {};
|
|
3871
|
+
clients.forEach(function (c) {
|
|
3872
|
+
if (c.config && c.config.userId) {
|
|
3873
|
+
registeredUids[c.config.userId] = true;
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
let neverRegistered = 0;
|
|
3877
|
+
for (var uid in clientCommands) {
|
|
3878
|
+
if (
|
|
3879
|
+
clientCommands[uid] &&
|
|
3880
|
+
clientCommands[uid].length > 0 &&
|
|
3881
|
+
!registeredUids[uid]
|
|
3882
|
+
) {
|
|
3883
|
+
neverRegistered++;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
const total = offlineClients + neverRegistered;
|
|
3887
|
+
if (total > 0) {
|
|
3888
|
+
badge.textContent = total;
|
|
3889
|
+
badge.style.display = "";
|
|
3890
|
+
} else {
|
|
3891
|
+
badge.style.display = "none";
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
// 独立轻量轮询:导航栏 badge 在任何页面都实时显示离线数
|
|
3896
|
+
async function fetchWatchdogBadge() {
|
|
3897
|
+
try {
|
|
3898
|
+
const res = await fetch("/api/watchdog/clients");
|
|
3899
|
+
const data = await res.json();
|
|
3900
|
+
updateWatchdogBadge(data.clients || [], data.clientCommands || {});
|
|
3901
|
+
} catch (e) {
|
|
3902
|
+
// 静默失败,保留旧值
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3848
3906
|
// ── 渲染辅助 ──
|
|
3849
3907
|
|
|
3850
3908
|
function tag(tagName, attrs, content) {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
<span>🎆</span>
|
|
22
22
|
</label>
|
|
23
23
|
<a class="script-link" onclick="navigateToSettings()" style="cursor:pointer">⚙️ 配置</a>
|
|
24
|
-
<a class="script-link" onclick="navigateToWatchdog()" style="cursor:pointer"
|
|
24
|
+
<a class="script-link" onclick="navigateToWatchdog()" style="cursor:pointer;position:relative"
|
|
25
|
+
title="看门狗离线客户端数">🐕 看门狗<span id="wdOfflineBadge"
|
|
26
|
+
style="display:none;position:absolute;top:-9px;right:-8px;background:#ef4444;color:#fff;border-radius:10px;font-size:10px;font-weight:700;line-height:1;padding:3px 6px;box-shadow:0 0 6px rgba(239,68,68,0.6);white-space:nowrap">0</span></a>
|
|
25
27
|
<span class="status" id="lastUpdate">--</span>
|
|
26
28
|
</div>
|
|
27
29
|
</div>
|
|
@@ -393,6 +395,49 @@
|
|
|
393
395
|
<div class="label">← 返回主页面</div>
|
|
394
396
|
</div>
|
|
395
397
|
</div>
|
|
398
|
+
<!-- 精准商家检索条件(静态表单展示,与 src/watch/data-store.js getPreciseMerchantsFromDb 保持一致) -->
|
|
399
|
+
<div style="margin-bottom:16px;background:#1a1a24;border:1px solid #333;border-radius:8px;overflow:hidden">
|
|
400
|
+
<div
|
|
401
|
+
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;cursor:pointer;user-select:none"
|
|
402
|
+
onclick="togglePreciseCriteria()" title="点击展开/收起">
|
|
403
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
404
|
+
<span style="color:#f59e0b;font-size:14px">🎯</span>
|
|
405
|
+
<span style="font-size:13px;color:#e0e0e0;font-weight:600">精准商家检索条件</span>
|
|
406
|
+
<span style="font-size:11px;color:#666">(阈值参数 · jobs 表)</span>
|
|
407
|
+
</div>
|
|
408
|
+
<span id="preciseCriteriaArrow" style="color:#888;font-size:12px">▼</span>
|
|
409
|
+
</div>
|
|
410
|
+
<div id="preciseCriteriaBody" style="padding:2px 14px 14px">
|
|
411
|
+
<div style="font-size:11px;color:#888;margin-bottom:10px">以下阈值条件全部满足(AND),其余条件为固有规则:</div>
|
|
412
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px">
|
|
413
|
+
<div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
|
|
414
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
|
415
|
+
<span style="font-size:12px;color:#60a5fa;font-weight:600">商品评论数</span>
|
|
416
|
+
<span style="font-size:10px;color:#666;font-family:monospace">product_review_count</span>
|
|
417
|
+
</div>
|
|
418
|
+
<div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">= 0</div>
|
|
419
|
+
<div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">商品评论数为 0</div>
|
|
420
|
+
</div>
|
|
421
|
+
<div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
|
|
422
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
|
423
|
+
<span style="font-size:12px;color:#60a5fa;font-weight:600">商品销量</span>
|
|
424
|
+
<span style="font-size:10px;color:#666;font-family:monospace">product_sold_count</span>
|
|
425
|
+
</div>
|
|
426
|
+
<div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">> 10</div>
|
|
427
|
+
<div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">商品销量大于 10</div>
|
|
428
|
+
</div>
|
|
429
|
+
<div style="background:#14141d;border:1px solid #2a2a3a;border-radius:8px;padding:10px 12px">
|
|
430
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
|
431
|
+
<span style="font-size:12px;color:#60a5fa;font-weight:600">EC 视频播放量</span>
|
|
432
|
+
<span style="font-size:10px;color:#666;font-family:monospace">playCount</span>
|
|
433
|
+
</div>
|
|
434
|
+
<div style="margin-top:6px;font-size:16px;color:#4ade80;font-weight:700">> 1000</div>
|
|
435
|
+
<div style="margin-top:4px;font-size:11px;color:#888;line-height:1.5">最近 7 天发布的 EC 视频(isECVideo)中,存在播放量超过
|
|
436
|
+
1000 的视频</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
396
441
|
<div class="table-wrap">
|
|
397
442
|
<div class="precise-list-header">
|
|
398
443
|
<h3>精准商家列表</h3>
|
package/src/watch/server.js
CHANGED
|
@@ -1289,13 +1289,16 @@ export function startWatchServer(
|
|
|
1289
1289
|
if (req.method === "GET" && routePath === "/api/product-tasks") {
|
|
1290
1290
|
const limit = params.limit;
|
|
1291
1291
|
const clientId = params.userId || "?";
|
|
1292
|
+
const country = params.country || null;
|
|
1292
1293
|
const tasks = store.getPendingProductTasks(
|
|
1293
1294
|
limit,
|
|
1294
1295
|
params.userId || null,
|
|
1296
|
+
undefined, // claimTtlMs 用默认 5 分钟
|
|
1297
|
+
country,
|
|
1295
1298
|
);
|
|
1296
1299
|
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
1297
1300
|
console.error(
|
|
1298
|
-
`[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId})`,
|
|
1301
|
+
`[JOB ${ts}] PRODUCT-TASKS: ${tasks.length} tasks (client=${clientId}${country ? `, country=${country}` : ""})`,
|
|
1299
1302
|
);
|
|
1300
1303
|
sendJSON(res, 200, { total: tasks.length, tasks });
|
|
1301
1304
|
return;
|