tt-help-cli-ycl 1.4.147 → 1.4.149
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-country-fix.mjs +61 -0
- package/src/clash/checker.js +42 -2
- package/src/cli/attach.js +6 -1
- package/src/cli/product-fill.js +55 -13
- package/src/lib/api-client.js +12 -2
- package/src/watch/db-schema.js +5 -1
- package/src/watch/public/app.js +14 -15
- package/src/watch/server.js +15 -5
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - 多国配置且已生效时,幂等不重复下发
|
|
17
17
|
* B. daemon 逗号分隔多国 --country 参数解析
|
|
18
18
|
* C. checker normalizeCountries / filterNodesByCountry 回归
|
|
19
|
+
* + 内置策略/机场信息节点过滤(isUsableProxyNode,2026-09-03 剩余流量 GB 修复)
|
|
19
20
|
* D. 客户端 daemon 启动兜底:读 clash-health.json 上次生效国家 → join(",")
|
|
20
21
|
*/
|
|
21
22
|
|
|
@@ -239,6 +240,66 @@ ok(
|
|
|
239
240
|
fr = checker.filterNodesByCountry(nodes, null);
|
|
240
241
|
ok(fr.filtered.length === 4 && fr.excluded === 0, "C8 null 不过滤");
|
|
241
242
|
|
|
243
|
+
// ── C9-C14 信息节点/内置策略过滤(2026-09-03:剩余流量 GB 混入候选集修复) ──
|
|
244
|
+
fr = checker.filterNodesByCountry(["剩余流量:5319.72 GB"], ["US", "GB", "JP"]);
|
|
245
|
+
ok(
|
|
246
|
+
fr.filtered.length === 0,
|
|
247
|
+
"C9 GB 模式不再命中流量单位(剩余流量:xxx GB)",
|
|
248
|
+
JSON.stringify(fr),
|
|
249
|
+
);
|
|
250
|
+
fr = checker.filterNodesByCountry(
|
|
251
|
+
["🇬🇧英国HY1-1000M专线", "🇬🇧英国trojan1", "UK Premium"],
|
|
252
|
+
["GB"],
|
|
253
|
+
);
|
|
254
|
+
ok(
|
|
255
|
+
fr.filtered.length === 3,
|
|
256
|
+
"C10 英国节点仍正常命中(英国/UK)",
|
|
257
|
+
JSON.stringify(fr),
|
|
258
|
+
);
|
|
259
|
+
const junkNodes = [
|
|
260
|
+
"DIRECT",
|
|
261
|
+
"REJECT",
|
|
262
|
+
"REJECT-DROP",
|
|
263
|
+
"GLOBAL",
|
|
264
|
+
"剩余流量:5319.72 GB",
|
|
265
|
+
"剩余流量:1862.27 MB",
|
|
266
|
+
"官网:example.com",
|
|
267
|
+
"套餐到期:2099-12-31",
|
|
268
|
+
"1.5TB",
|
|
269
|
+
];
|
|
270
|
+
ok(
|
|
271
|
+
junkNodes.every((n) => !checker.isUsableProxyNode(n)),
|
|
272
|
+
"C11 内置策略+信息节点全部被 isUsableProxyNode 拒绝",
|
|
273
|
+
JSON.stringify(junkNodes.filter((n) => checker.isUsableProxyNode(n))),
|
|
274
|
+
);
|
|
275
|
+
const goodNodes = [
|
|
276
|
+
"🇺🇸美国HY1轻量",
|
|
277
|
+
"🇬🇧英国HY1-1000M专线",
|
|
278
|
+
"🇯🇵日本Trojan1",
|
|
279
|
+
"🇹🇼台湾trojan直连AI",
|
|
280
|
+
];
|
|
281
|
+
ok(
|
|
282
|
+
goodNodes.every((n) => checker.isUsableProxyNode(n)),
|
|
283
|
+
"C12 正常业务节点全部通过 isUsableProxyNode",
|
|
284
|
+
JSON.stringify(goodNodes.filter((n) => !checker.isUsableProxyNode(n))),
|
|
285
|
+
);
|
|
286
|
+
// 读口兜底:已持久化的脏候选文件在读取时被过滤
|
|
287
|
+
writeFileSync(
|
|
288
|
+
join(tmpdir(), "clash-healthy-nodes.json"),
|
|
289
|
+
JSON.stringify({
|
|
290
|
+
updated: new Date().toISOString(),
|
|
291
|
+
nodes: ["🇺🇸美国A", "剩余流量:5319.72 GB", "DIRECT", "🇯🇵日本A"],
|
|
292
|
+
delays: {},
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
const hl = checker.loadHealthyList();
|
|
296
|
+
ok(
|
|
297
|
+
hl.nodes.length === 2 &&
|
|
298
|
+
!hl.nodes.some((n) => checker.isUsableProxyNode(n) === false),
|
|
299
|
+
"C13 loadHealthyList 读口剔除已持久化的脏候选",
|
|
300
|
+
JSON.stringify(hl.nodes),
|
|
301
|
+
);
|
|
302
|
+
|
|
242
303
|
console.log("\n═══ D. 客户端 daemon 启动兜底(读上次生效国家)═══");
|
|
243
304
|
const { getClashHealth } = await import("../src/watchdog/reporter.js");
|
|
244
305
|
const healthPath = join(tmpdir(), "clash-health.json");
|
package/src/clash/checker.js
CHANGED
|
@@ -45,7 +45,10 @@ const CLASH_PROCESS_NAMES = ["verge-mihomo", "69cloud-core"];
|
|
|
45
45
|
// 拉丁代码用词边界防误匹配(如 \bUS\b 不匹配 PLUS)
|
|
46
46
|
const COUNTRY_PATTERNS = {
|
|
47
47
|
US: /(?:[🇺🇸]\s*)?(美国|USA|United\s*States|\bUS\b)/i,
|
|
48
|
-
GB
|
|
48
|
+
// GB 代码已移除(2026-09-03):机场信息节点"剩余流量:xxx GB"的流量单位会被
|
|
49
|
+
// \bGB\b 误判为英国节点,导致其通过国家过滤混入 delay 初测候选集
|
|
50
|
+
// (生产实锤:12 台机器候选集混入、D-1/D-7 切上去驻留)。英国节点靠 英国/UK/🇬🇧 命中。
|
|
51
|
+
GB: /(?:[🇬🇧]\s*)?(英国|UK|United\s*Kingdom)/i,
|
|
49
52
|
DE: /(?:[🇩🇪]\s*)?(德国|Germany|\bDE\b)/i,
|
|
50
53
|
FR: /(?:[🇫🇷]\s*)?(法国|France|\bFR\b)/i,
|
|
51
54
|
JP: /(?:[🇯🇵]\s*)?(日本|Japan|\bJP\b)/i,
|
|
@@ -327,6 +330,37 @@ export function getSupportedCountries() {
|
|
|
327
330
|
return Object.keys(COUNTRY_PATTERNS);
|
|
328
331
|
}
|
|
329
332
|
|
|
333
|
+
// ══════════════════════════════════════════════
|
|
334
|
+
// 4.0 内置策略 / 机场信息节点过滤
|
|
335
|
+
// ══════════════════════════════════════════════
|
|
336
|
+
|
|
337
|
+
// mihomo GLOBAL 组的 all 列表包含内置策略;这类节点不可用作业务出口
|
|
338
|
+
const BUILTIN_POLICY_NAMES = new Set([
|
|
339
|
+
"DIRECT",
|
|
340
|
+
"REJECT",
|
|
341
|
+
"REJECT-DROP",
|
|
342
|
+
"PASS",
|
|
343
|
+
"COMPATIBLE",
|
|
344
|
+
"GLOBAL",
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
// 机场订阅普遍带"剩余流量/到期/官网"等信息节点;部分实际可翻墙,会被 delay
|
|
348
|
+
// 初测选入候选集并被巡检/failover 轮换切上去驻留(2026-09-03 生产实锤)
|
|
349
|
+
const INFO_NODE_PATTERN =
|
|
350
|
+
/剩余|流量|到期|过期|官网|重置|套餐|订阅|工单|[\d.]\s*[KMGTP]B$/i;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 判定节点名是否可用作业务出口(内置策略与机场信息节点返回 false)
|
|
354
|
+
* 在候选集写口(runDelayProbe)与读口(loadHealthyList)统一调用
|
|
355
|
+
*/
|
|
356
|
+
export function isUsableProxyNode(name) {
|
|
357
|
+
const n = String(name || "").trim();
|
|
358
|
+
if (!n) return false;
|
|
359
|
+
if (BUILTIN_POLICY_NAMES.has(n.toUpperCase())) return false;
|
|
360
|
+
if (INFO_NODE_PATTERN.test(n)) return false;
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
330
364
|
// ══════════════════════════════════════════════
|
|
331
365
|
// 4.5 delay 初测(零切换,生成候选集)
|
|
332
366
|
// ══════════════════════════════════════════════
|
|
@@ -355,6 +389,8 @@ export async function runDelayProbe({
|
|
|
355
389
|
console.error(`[checker] 获取 GLOBAL 节点列表失败: ${e.message}`);
|
|
356
390
|
return { nodes: [], total: 0, results: [] };
|
|
357
391
|
}
|
|
392
|
+
// 先剔除内置策略与机场信息节点(不可用作业务出口),再走国家过滤
|
|
393
|
+
all = all.filter(isUsableProxyNode);
|
|
358
394
|
const { filtered, excluded } = filterNodesByCountry(all, filter);
|
|
359
395
|
const total = all.length;
|
|
360
396
|
|
|
@@ -402,7 +438,11 @@ export function loadHealthyList(country = null) {
|
|
|
402
438
|
try {
|
|
403
439
|
if (!existsSync(_healthyFile)) return _emptyList();
|
|
404
440
|
const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
|
|
405
|
-
|
|
441
|
+
// 读口兜底:剔除内置策略/信息节点。覆盖"修复前已写入文件的脏候选"——
|
|
442
|
+
// 写口过滤后文件会在下轮 delay 初测自愈,这里保证修复生效前也不切错。
|
|
443
|
+
const allNodes = (Array.isArray(d.nodes) ? d.nodes : []).filter(
|
|
444
|
+
isUsableProxyNode,
|
|
445
|
+
);
|
|
406
446
|
const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
|
|
407
447
|
const filter = country || CONFIG.nodeCountryFilter;
|
|
408
448
|
const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
|
package/src/cli/attach.js
CHANGED
|
@@ -143,12 +143,17 @@ export async function handleAttach(options) {
|
|
|
143
143
|
|
|
144
144
|
if (webserverUrl) {
|
|
145
145
|
attachLog(`[Attach] webserver 模式: ${webserverUrl}`);
|
|
146
|
+
// webserver 端共享 scraper(poolSize 默认 8,每 slot 串行),当 -p 并发数大于
|
|
147
|
+
// poolSize 时,请求会在 webserver 内排队,端到端耗时可达数分钟(实测 270~310s)。
|
|
148
|
+
// 30s 超时太短会让整批任务被误判失败(aborted due to timeout),故放宽到 10 分钟
|
|
149
|
+
// 作为兜底——webserver 进程若真的挂了,连接会立即断开报错,不会真的空等 10 分钟。
|
|
150
|
+
const WEBSERVER_FETCH_TIMEOUT_MS = 600000;
|
|
146
151
|
getUserInfo = async (uniqueId) => {
|
|
147
152
|
const res = await fetch(`${webserverUrl}/api/tiktok/user`, {
|
|
148
153
|
method: "POST",
|
|
149
154
|
headers: { "Content-Type": "application/json" },
|
|
150
155
|
body: JSON.stringify({ uniqueId }),
|
|
151
|
-
signal: AbortSignal.timeout(
|
|
156
|
+
signal: AbortSignal.timeout(WEBSERVER_FETCH_TIMEOUT_MS),
|
|
152
157
|
});
|
|
153
158
|
let data = null;
|
|
154
159
|
try {
|
package/src/cli/product-fill.js
CHANGED
|
@@ -342,29 +342,68 @@ export async function handleProductFill(options) {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
/** CDP 直抓:验证码在已登录浏览器中弹出时,等待人工解决后自动重试 */
|
|
345
|
-
const fetchViaCDP = async (seoUrl) => {
|
|
345
|
+
const fetchViaCDP = async (seoUrl, uniqueId) => {
|
|
346
346
|
try {
|
|
347
347
|
return await getProductInfoCDP(page, seoUrl);
|
|
348
348
|
} catch (e) {
|
|
349
349
|
if (!e.message?.includes("Blocked by TikTok")) throw e;
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
350
|
+
// 撞码即向服务端报一次(errorType: captcha),之后无论人工解决、
|
|
351
|
+
// 自动放行还是超时冷却,本任务流程不再重复上报
|
|
352
|
+
await apiPost(`${serverUrl}/api/error-report`, {
|
|
353
|
+
userId,
|
|
354
|
+
username: uniqueId,
|
|
355
|
+
errorType: "captcha",
|
|
356
|
+
errorMessage: e.message,
|
|
357
|
+
stage: "product-fill",
|
|
358
|
+
}).catch(() => {});
|
|
359
|
+
// 验证码拦截统一等待(上限 3 分钟):容器可能稍后才渲染(HTML 已含
|
|
360
|
+
// 标记、DOM 未挂载),也可能全程不出现(风控软拦/结构变化)。
|
|
361
|
+
// 轮询期间容器出现 → 交人工解决;容器未出现但页面恢复出商品数据 →
|
|
362
|
+
// 直接重试;到点仍拦截 → 抛 captcha 错误走 30s 冷却
|
|
363
|
+
log(
|
|
364
|
+
` ⏳ 验证码拦截,等待处理(上限 ${CDP_CAPTCHA_WAIT_MS / 1000}s,可在浏览器中人工解决)...`,
|
|
365
|
+
);
|
|
366
|
+
const deadline = Date.now() + CDP_CAPTCHA_WAIT_MS;
|
|
367
|
+
let cap = await detectCaptcha(page);
|
|
368
|
+
while (!cap && Date.now() < deadline) {
|
|
369
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
370
|
+
cap = await detectCaptcha(page);
|
|
371
|
+
if (!cap) {
|
|
372
|
+
// 容器始终没出现:探测页面是否已自动放行
|
|
373
|
+
const html = await page.content().catch(() => "");
|
|
374
|
+
if (
|
|
375
|
+
html.includes('"product_model"') ||
|
|
376
|
+
html.includes('"review_ratings"')
|
|
377
|
+
) {
|
|
378
|
+
log(" ✓ 拦截已自动解除,重新抓取...");
|
|
379
|
+
return getProductInfoCDP(page, seoUrl);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (cap) {
|
|
384
|
+
log(" ⏳ 请在已登录的浏览器窗口中手动解决验证码...");
|
|
354
385
|
const r = await waitAndGetCaptcha(page, {
|
|
355
|
-
waitMs:
|
|
386
|
+
waitMs: Math.max(0, deadline - Date.now()),
|
|
356
387
|
pollInterval: 5000,
|
|
357
388
|
log,
|
|
358
389
|
});
|
|
359
390
|
if (r.resolved) {
|
|
360
391
|
log(" ✓ 验证码已解决,重新抓取...");
|
|
361
|
-
|
|
392
|
+
try {
|
|
393
|
+
return await getProductInfoCDP(page, seoUrl);
|
|
394
|
+
} catch (retryErr) {
|
|
395
|
+
// 重试又撞码:仍标记 captcha 走冷却,避免误计 noteFail 哨兵
|
|
396
|
+
if (retryErr.message?.includes("Blocked by TikTok")) {
|
|
397
|
+
retryErr.captcha = true;
|
|
398
|
+
}
|
|
399
|
+
throw retryErr;
|
|
400
|
+
}
|
|
362
401
|
}
|
|
363
402
|
}
|
|
364
403
|
const err = new Error(
|
|
365
|
-
cap
|
|
404
|
+
cap
|
|
366
405
|
? "验证码等待人工解决超时"
|
|
367
|
-
: "
|
|
406
|
+
: "验证码拦截(3 分钟内未出现容器且未自动解除)",
|
|
368
407
|
);
|
|
369
408
|
err.captcha = true;
|
|
370
409
|
throw err;
|
|
@@ -372,10 +411,10 @@ export async function handleProductFill(options) {
|
|
|
372
411
|
};
|
|
373
412
|
|
|
374
413
|
/** 抓取商品详情,带浏览器断连恢复与空数据防御 */
|
|
375
|
-
const getTaskDetail = async (seoUrl, country) => {
|
|
414
|
+
const getTaskDetail = async (seoUrl, country, uniqueId) => {
|
|
376
415
|
let detail;
|
|
377
416
|
try {
|
|
378
|
-
detail = await fetchViaCDP(seoUrl);
|
|
417
|
+
detail = await fetchViaCDP(seoUrl, uniqueId);
|
|
379
418
|
} catch (e) {
|
|
380
419
|
if (isBrowserDisconnected(browser, e)) {
|
|
381
420
|
// 确凿断连(CDP 断开 / 明确关闭消息)才重启浏览器
|
|
@@ -399,7 +438,7 @@ export async function handleProductFill(options) {
|
|
|
399
438
|
err.retryable = true;
|
|
400
439
|
throw err;
|
|
401
440
|
}
|
|
402
|
-
detail = await fetchViaCDP(seoUrl);
|
|
441
|
+
detail = await fetchViaCDP(seoUrl, uniqueId);
|
|
403
442
|
} else if (isNetworkError(e)) {
|
|
404
443
|
// 网络层错误:代理断连/Clash 切节点,浏览器没死,不杀浏览器,
|
|
405
444
|
// 记为可重试错误跳过本轮,下轮代理探测自然跟上
|
|
@@ -492,7 +531,7 @@ export async function handleProductFill(options) {
|
|
|
492
531
|
}
|
|
493
532
|
log(` → 补商品 @${uniqueId} ...`);
|
|
494
533
|
try {
|
|
495
|
-
const detail = await getTaskDetail(seoUrl, profile.country);
|
|
534
|
+
const detail = await getTaskDetail(seoUrl, profile.country, uniqueId);
|
|
496
535
|
const post = await apiPost(
|
|
497
536
|
`${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
|
|
498
537
|
{ productDetail: detail },
|
|
@@ -518,6 +557,9 @@ export async function handleProductFill(options) {
|
|
|
518
557
|
log(
|
|
519
558
|
` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
|
|
520
559
|
);
|
|
560
|
+
// 验证码是全局风控:立即终止本批次剩余任务,避免批次内
|
|
561
|
+
// 继续 goto 顶着风控连刷;30s 冷却在 while 顶部领取前生效
|
|
562
|
+
break;
|
|
521
563
|
} else if (e.delisted) {
|
|
522
564
|
// 壳页/下架:seller_id 为空且无销售数据 → 立即哨兵放弃
|
|
523
565
|
batchSkipped++;
|
package/src/lib/api-client.js
CHANGED
|
@@ -74,9 +74,19 @@ export function createApiClient(opts = {}) {
|
|
|
74
74
|
clientId,
|
|
75
75
|
meta,
|
|
76
76
|
);
|
|
77
|
-
// error-report 自动带上本机 hostname
|
|
77
|
+
// error-report 自动带上本机 hostname,供看门狗按机器关联异常。
|
|
78
|
+
// ⚠️ header 值必须是 ASCII(ByteString):中文主机名(如"霞霞笔记本")会让
|
|
79
|
+
// undici fetch 在校验 header 时抛 "Cannot convert argument to a ByteString
|
|
80
|
+
// ... value of XXXX which is greater than 255",请求根本没发出去且被
|
|
81
|
+
// withRetry 无限重试刷屏。因此:
|
|
82
|
+
// - header 放净化后的 ASCII 值(仅保证请求能发出)
|
|
83
|
+
// - 原始 hostname 塞进 body(JSON 为 UTF-8,无 ByteString 限制),
|
|
84
|
+
// 服务端 body.hostname 优先,看门狗仍能按原始主机名关联本机异常
|
|
78
85
|
if (/\/api\/error-report(\?|$)/.test(String(url))) {
|
|
79
|
-
headers["X-Client-Hostname"] = os
|
|
86
|
+
headers["X-Client-Hostname"] = os
|
|
87
|
+
.hostname()
|
|
88
|
+
.replace(/[^\x20-\x7e]/g, "_");
|
|
89
|
+
body = { ...body, hostname: os.hostname() };
|
|
80
90
|
}
|
|
81
91
|
const res = await fetch(url, {
|
|
82
92
|
method: "POST",
|
package/src/watch/db-schema.js
CHANGED
|
@@ -103,6 +103,9 @@ export function initDb(filePath) {
|
|
|
103
103
|
db = new Database(dbPath);
|
|
104
104
|
db.pragma("journal_mode = WAL");
|
|
105
105
|
db.pragma("busy_timeout = 5000");
|
|
106
|
+
// 调大 SQLite 页缓存:主库 7.3GB,task_queue 等热表的回表若走磁盘 I/O 会非常慢
|
|
107
|
+
// (实测同规模数据 16MB 缓存 1.5s → 256MB 1.1s)。512MB 足以让 task_queue 全量驻留内存。
|
|
108
|
+
db.pragma("cache_size = -524288");
|
|
106
109
|
|
|
107
110
|
// ===== users 表 =====
|
|
108
111
|
db.exec(`
|
|
@@ -423,10 +426,11 @@ export function initDb(filePath) {
|
|
|
423
426
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_done ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
|
|
424
427
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_error ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'error'`,
|
|
425
428
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_restricted ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'restricted'`,
|
|
426
|
-
// jobs_done 冷归档表索引(done 列表 / 国家统计 /
|
|
429
|
+
// jobs_done 冷归档表索引(done 列表 / 国家统计 / 重检 / no_video 计数)
|
|
427
430
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
|
|
428
431
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)`,
|
|
429
432
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_status_updated ON jobs_done(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
|
|
433
|
+
`CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1`,
|
|
430
434
|
];
|
|
431
435
|
for (const sql of indexes) {
|
|
432
436
|
db.exec(sql);
|
package/src/watch/public/app.js
CHANGED
|
@@ -5286,21 +5286,20 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis, clientId) {
|
|
|
5286
5286
|
styled(
|
|
5287
5287
|
"span",
|
|
5288
5288
|
"display:flex;gap:2px",
|
|
5289
|
-
(
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
: "") +
|
|
5289
|
+
tag(
|
|
5290
|
+
"button",
|
|
5291
|
+
{
|
|
5292
|
+
onclick: "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
|
|
5293
|
+
style:
|
|
5294
|
+
"padding:1px 5px;font-size:11px;background:" +
|
|
5295
|
+
(c.enabled ? "#f59e0b" : "#4ade80") +
|
|
5296
|
+
";color:#000;border:none;border-radius:2px;cursor:pointer",
|
|
5297
|
+
title:
|
|
5298
|
+
(c.enabled ? "停止" : "启动") +
|
|
5299
|
+
(isOnline ? "" : "(设备离线,上线后生效)"),
|
|
5300
|
+
},
|
|
5301
|
+
c.enabled ? "⏹" : "▶",
|
|
5302
|
+
) +
|
|
5304
5303
|
tag(
|
|
5305
5304
|
"button",
|
|
5306
5305
|
{
|
package/src/watch/server.js
CHANGED
|
@@ -104,14 +104,16 @@ const STAT_GROUPS = {
|
|
|
104
104
|
SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
|
|
105
105
|
FROM jobs
|
|
106
106
|
UNION ALL
|
|
107
|
+
-- jobs_done 冷归档表:全部是 done 且只增不改。改用覆盖索引/部分索引计数,
|
|
108
|
+
-- 避免对 147 万行全表回表(原 SUM(CASE WHEN no_video=1) 无法匹配索引)
|
|
107
109
|
SELECT
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
c.cnt as jobsTotal,
|
|
111
|
+
c.cnt as done,
|
|
110
112
|
0 as error,
|
|
111
113
|
0 as restricted,
|
|
112
114
|
0 as targetUsers,
|
|
113
|
-
|
|
114
|
-
FROM jobs_done
|
|
115
|
+
(SELECT COUNT(*) FROM jobs_done WHERE no_video = 1) as noVideo
|
|
116
|
+
FROM (SELECT COUNT(*) as cnt FROM jobs_done) c
|
|
115
117
|
)`,
|
|
116
118
|
},
|
|
117
119
|
{
|
|
@@ -389,8 +391,15 @@ function computeStatsIncremental(st) {
|
|
|
389
391
|
};
|
|
390
392
|
}
|
|
391
393
|
|
|
392
|
-
// 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast
|
|
394
|
+
// 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast 组后台刷新。
|
|
395
|
+
// 节流:53 个采集进程高频领/交任务,若每次都失效会导致 fast 组几乎每 10s 全量重算,
|
|
396
|
+
// 把失效限制为至少间隔 FAST_DIRTY_MIN_INTERVAL,重算节奏由 fast TTL(30s) 主导。
|
|
397
|
+
let lastFastDirtyTime = 0;
|
|
398
|
+
const FAST_DIRTY_MIN_INTERVAL = 30 * 1000;
|
|
393
399
|
function markFastStatsDirty() {
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
if (now - lastFastDirtyTime < FAST_DIRTY_MIN_INTERVAL) return;
|
|
402
|
+
lastFastDirtyTime = now;
|
|
394
403
|
groupState.fast.cacheTime = 0;
|
|
395
404
|
}
|
|
396
405
|
|
|
@@ -579,6 +588,7 @@ export function startWatchServer(
|
|
|
579
588
|
"CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status)",
|
|
580
589
|
"CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'",
|
|
581
590
|
"CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)",
|
|
591
|
+
"CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1",
|
|
582
592
|
"CREATE INDEX IF NOT EXISTS idx_videos_user_update ON videos(user_update_count)",
|
|
583
593
|
// product-fill 领任务查询的部分索引:只索引候选行,避免全表扫描 164 万行
|
|
584
594
|
"CREATE INDEX IF NOT EXISTS idx_jobs_product_fill ON jobs(claimed_at, updated_at) WHERE top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND (product_name IS NULL OR product_name = '') AND product_rating IS NULL AND product_sold_count IS NULL",
|