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
package/src/cli/tag.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { reportClientErrorAndFailover } from "../lib/client-error-report.js";
|
|
10
10
|
import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
|
|
11
11
|
import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
|
|
12
|
-
import { getOrCreatePage
|
|
12
|
+
import { getOrCreatePage } from "../lib/browser/page.js";
|
|
13
13
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
14
14
|
import { isLocationInList } from "../lib/target-locations.js";
|
|
15
15
|
import { getTargetLocations } from "../lib/app-config.js";
|
|
@@ -34,11 +34,17 @@ function resolveTargetCountries(countries) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* 判断浏览器是否真的已断开(用户手动关窗口 / CDP 断开)。
|
|
38
38
|
* 用于让采集循环(score-all / refresh)在浏览器被关闭后自动退出,避免无限重试。
|
|
39
|
+
*
|
|
40
|
+
* 注意:page.js 的 isBrowserClosedError 会把 net::ERR_CONNECTION_* 等网络层错误
|
|
41
|
+
* 也判为“浏览器关闭”。实际上这类错误是代理掂断/连接重置,浏览器进程还活着,
|
|
42
|
+
* 属于网络错误而不是窗口关闭。此处只认两种确凿信号:
|
|
43
|
+
* 1. browser.isConnected() === false(CDP 连接已断)
|
|
44
|
+
* 2. 明确的关闭消息(Target closed / Browser has been closed)
|
|
45
|
+
* net::ERR_* 一律放行,交给错误计数处理,避免误退出循环。
|
|
39
46
|
*/
|
|
40
47
|
function isBrowserDisconnected(browser, e) {
|
|
41
|
-
if (e && isBrowserClosedError(e)) return true;
|
|
42
48
|
try {
|
|
43
49
|
if (
|
|
44
50
|
browser &&
|
|
@@ -48,7 +54,16 @@ function isBrowserDisconnected(browser, e) {
|
|
|
48
54
|
return true;
|
|
49
55
|
}
|
|
50
56
|
} catch {}
|
|
51
|
-
return false;
|
|
57
|
+
if (!e) return false;
|
|
58
|
+
const msg = e.message || "";
|
|
59
|
+
// 网络层错误(net::ERR_CONNECTION_CLOSED/RESET/REFUSED...)不代表浏览器关闭
|
|
60
|
+
if (msg.includes("net::ERR_")) return false;
|
|
61
|
+
// 明确的 CDP 关闭信号
|
|
62
|
+
return (
|
|
63
|
+
msg.includes("Target closed") ||
|
|
64
|
+
msg.includes("Browser has been closed") ||
|
|
65
|
+
msg.includes("Target page, context or browser has been closed")
|
|
66
|
+
);
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
// 构建带客户端追踪 header 的 fetch 封装
|
|
@@ -851,10 +866,10 @@ export async function handleScoreAll(parsed) {
|
|
|
851
866
|
// ── 错误计数(纯观察,不做任何自愈) ──
|
|
852
867
|
// 阈值语义:连续超过 N 次触发动作(即第 N+1 次)
|
|
853
868
|
// 403 连续 >2 次 → 上报服务端 + 强制 fallback(daemon 换节点),清零后继续跑
|
|
854
|
-
// 网络错误 连续 >10 次 →
|
|
869
|
+
// 网络错误 连续 >10 次 → 触发 daemon fallback 换节点(本地 IPC,不依赖服务端可达)+ 退出
|
|
855
870
|
// 验证码 连续 >3 次(人工一直未解决)→ 打印退出
|
|
856
871
|
// 其他错误 连续 >10 次 → 打印退出
|
|
857
|
-
// 清零规则:成功处理一个 tag → 全部清零;fallback 触发 →
|
|
872
|
+
// 清零规则:成功处理一个 tag → 全部清零;fallback 触发 → 对应计数清零;
|
|
858
873
|
// 人工点掉验证码 → 验证码计数清零
|
|
859
874
|
const errorCounts = { http403: 0, network: 0, captcha: 0, other: 0 };
|
|
860
875
|
const ERROR_LIMITS = { http403: 2, network: 10, captcha: 3, other: 10 };
|
|
@@ -1207,16 +1222,35 @@ export async function handleScoreAll(parsed) {
|
|
|
1207
1222
|
(e.message &&
|
|
1208
1223
|
(e.message.includes("ECONNREFUSED") ||
|
|
1209
1224
|
e.message.includes("fetch failed") ||
|
|
1210
|
-
e.message.includes("network")
|
|
1225
|
+
e.message.includes("network") ||
|
|
1226
|
+
e.message.includes("net::ERR_")));
|
|
1211
1227
|
if (isNetworkError) {
|
|
1212
1228
|
const over = bumpErrorCount("network");
|
|
1213
1229
|
log(
|
|
1214
|
-
` ⚠️
|
|
1230
|
+
` ⚠️ 网络错误 (${e.message}),15 秒后重试(连续网络错误 ${errorCounts.network} 次)...`,
|
|
1215
1231
|
);
|
|
1216
1232
|
if (over) {
|
|
1217
1233
|
log(
|
|
1218
|
-
` ❌ 连续网络错误超过 ${ERROR_LIMITS.network} 次 →
|
|
1234
|
+
` ❌ 连续网络错误超过 ${ERROR_LIMITS.network} 次 → 触发 daemon fallback 换节点后退出`,
|
|
1219
1235
|
);
|
|
1236
|
+
// 网络错误多为代理节点问题:fallback 走本地 IPC 触发 daemon 换节点
|
|
1237
|
+
// (不依赖服务端可达),上报失败会被内部忽略;随后退出由看门狗拉起
|
|
1238
|
+
await reportClientErrorAndFailover({
|
|
1239
|
+
post: (url, body) =>
|
|
1240
|
+
fetch(url, {
|
|
1241
|
+
method: "POST",
|
|
1242
|
+
headers: buildClientHeaders(clientId, clientMeta, {
|
|
1243
|
+
"Content-Type": "application/json",
|
|
1244
|
+
}),
|
|
1245
|
+
body: JSON.stringify(body),
|
|
1246
|
+
}),
|
|
1247
|
+
baseUrl,
|
|
1248
|
+
userId,
|
|
1249
|
+
errorType: "network_failure",
|
|
1250
|
+
message: `连续 ${ERROR_LIMITS.network + 1} 次网络错误 (port=${cdpPort})`,
|
|
1251
|
+
stage: "tag-scoring",
|
|
1252
|
+
log,
|
|
1253
|
+
});
|
|
1220
1254
|
await cleanup();
|
|
1221
1255
|
return;
|
|
1222
1256
|
}
|
package/src/lib/args.js
CHANGED
|
@@ -894,21 +894,36 @@ 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
|
+
let taskDelay = 5; // --task-delay:每个任务处理完后的随机延迟上限,秒
|
|
899
|
+
/** 端口/国家配置:--profiles US/9223,JP/1222 → [{country:"US",port:9223},...] */
|
|
900
|
+
let profiles = [];
|
|
899
901
|
|
|
900
902
|
for (let i = 0; i < args.length; i++) {
|
|
901
903
|
const arg = args[i];
|
|
902
904
|
if (arg === "-s" || arg === "--server") {
|
|
903
905
|
serverUrl = args[++i];
|
|
904
|
-
} else if (arg === "
|
|
905
|
-
|
|
906
|
+
} else if (arg === "--profiles") {
|
|
907
|
+
for (const seg of String(args[++i] || "").split(",")) {
|
|
908
|
+
const [country, portStr] = seg.trim().split("/");
|
|
909
|
+
const port = parseInt(portStr, 10);
|
|
910
|
+
const cc = String(country || "")
|
|
911
|
+
.trim()
|
|
912
|
+
.toUpperCase();
|
|
913
|
+
if (port && /^[A-Z]{2}$/.test(cc)) {
|
|
914
|
+
profiles.push({ country: cc, port });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
906
917
|
} else if (arg === "--proxy") {
|
|
907
918
|
proxyUrl = args[++i] || null;
|
|
908
919
|
} else if (arg === "-i" || arg === "--interval") {
|
|
909
920
|
interval = parseInt(args[++i], 10) || 10;
|
|
910
921
|
} else if (arg === "-l" || arg === "--limit") {
|
|
911
922
|
limit = parseInt(args[++i], 10) || 5;
|
|
923
|
+
} else if (arg === "--task-delay") {
|
|
924
|
+
// 允许 0(关闭任务间延迟),非法值回退默认 5
|
|
925
|
+
const v = parseInt(args[++i], 10);
|
|
926
|
+
taskDelay = Number.isFinite(v) && v >= 0 ? v : 5;
|
|
912
927
|
}
|
|
913
928
|
}
|
|
914
929
|
|
|
@@ -917,8 +932,9 @@ function parseProductFillArgs(args) {
|
|
|
917
932
|
productFillLimit: limit,
|
|
918
933
|
productFillInterval: interval,
|
|
919
934
|
serverUrl,
|
|
920
|
-
productFillWebserver: webserverUrl,
|
|
921
935
|
productFillProxy: proxyUrl,
|
|
936
|
+
productFillTaskDelay: taskDelay,
|
|
937
|
+
productFillProfiles: profiles,
|
|
922
938
|
urls: [],
|
|
923
939
|
outputFormat: "json",
|
|
924
940
|
exploreCount: 0,
|
|
@@ -1564,7 +1580,7 @@ export function parseArgs() {
|
|
|
1564
1580
|
productFillLimit: 5,
|
|
1565
1581
|
productFillInterval: 10,
|
|
1566
1582
|
serverUrl: defaultServer,
|
|
1567
|
-
|
|
1583
|
+
productFillProfiles: [],
|
|
1568
1584
|
showHelp: true,
|
|
1569
1585
|
showVersion: false,
|
|
1570
1586
|
urls: [],
|
package/src/lib/constants.js
CHANGED
|
@@ -222,14 +222,18 @@ 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> 浏览器启动代理及出口探测(未指定时用配置文件 proxy;均未配置则浏览器走系统默认代理)",
|
|
229
231
|
" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
|
|
230
232
|
" -l, --limit <N> 每批领取任务数上限(默认: 5)",
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
+
" --task-delay <N> 每个任务处理完后的随机延迟上限,秒(默认: 5,实际 N/2~N 秒随机)",
|
|
234
|
+
" 下架壳页/连续失败3次/无效URL → 回写 rating=-1 哨兵永久移出候选",
|
|
235
|
+
" 示例: tt-help product-fill --profiles US/9223",
|
|
236
|
+
" tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
|
|
233
237
|
"",
|
|
234
238
|
" tt-help recheck [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
|
|
235
239
|
" 重检 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;
|