tt-help-cli-ycl 1.4.166 → 1.4.168
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/deploy-node.sh +83 -0
- package/scripts/diag-claim-branches.js +72 -0
- package/scripts/test-claim-fix.mjs +216 -0
- package/scripts/test-precise-index.mjs +114 -0
- package/scripts/test-stats-smoke.mjs +43 -0
- package/src/clash/checker.js +96 -14
- package/src/cli/attach.js +67 -19
- package/src/lib/browser/cdp.js +182 -27
- package/src/watch/data-store.js +159 -37
- package/src/watch/server.js +102 -38
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 将本地 tiktok-cli 打包部署到 Windows 采集节点(试运行,不经 npm registry)
|
|
3
|
+
# 用法:
|
|
4
|
+
# ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验
|
|
5
|
+
# ./scripts/deploy-node.sh tiktok-210 --bump # 先把 package.json 版本号 +1 再打包
|
|
6
|
+
# ./scripts/deploy-node.sh tiktok-210 --bump --restart # 安装后重启走 CDP 的命令进程
|
|
7
|
+
# #(watchdog 检测到退出会用新代码自动拉起)
|
|
8
|
+
# 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启命令
|
|
9
|
+
# 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210,经跳板机),BatchMode 免密可用
|
|
10
|
+
# 说明:
|
|
11
|
+
# - 无需停进程再安装:采集客户端不加载 better-sqlite3(只有 videostats/watch 服务端用),无文件锁
|
|
12
|
+
# - watchdog 自升级:npm 覆盖安装后守护进程经 upgrader.js 自动重启,受管命令随后重建
|
|
13
|
+
# - 节点上保留 tgz 安装包,回滚时 npm install -g 旧版 tgz 即可
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--bump] [--restart]}"
|
|
17
|
+
shift || true
|
|
18
|
+
DO_BUMP=0
|
|
19
|
+
DO_RESTART=0
|
|
20
|
+
for a in "$@"; do
|
|
21
|
+
case "$a" in
|
|
22
|
+
--bump) DO_BUMP=1 ;;
|
|
23
|
+
--restart) DO_RESTART=1 ;;
|
|
24
|
+
*) echo "未知参数: $a"; exit 1 ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
cd "$(dirname "$0")/.."
|
|
29
|
+
|
|
30
|
+
if [ "$DO_BUMP" = 1 ]; then
|
|
31
|
+
node -e "
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
34
|
+
const m = p.version.split('.');
|
|
35
|
+
m[2] = Number(m[2]) + 1;
|
|
36
|
+
p.version = m.join('.');
|
|
37
|
+
fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
|
|
38
|
+
console.log('版本号 ->', p.version);
|
|
39
|
+
"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
NAME=$(node -p "require('./package.json').name")
|
|
43
|
+
VER=$(node -p "require('./package.json').version")
|
|
44
|
+
TGZ="${NAME}-${VER}.tgz"
|
|
45
|
+
|
|
46
|
+
echo "==> npm pack"
|
|
47
|
+
rm -f "$TGZ"
|
|
48
|
+
TGZ=$(npm pack 2>/dev/null | tail -1)
|
|
49
|
+
echo "包: $TGZ ($(du -h "$TGZ" | cut -f1))"
|
|
50
|
+
|
|
51
|
+
echo "==> scp → $ALIAS"
|
|
52
|
+
scp -o ConnectTimeout=25 -o BatchMode=yes "$TGZ" "$ALIAS:$TGZ" 2>&1 |
|
|
53
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null
|
|
54
|
+
echo "已传输到 %USERPROFILE%\\$TGZ"
|
|
55
|
+
|
|
56
|
+
echo "==> 远程 npm install -g"
|
|
57
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
|
|
58
|
+
"npm install -g %USERPROFILE%\\$TGZ 2>&1" |
|
|
59
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
|
|
60
|
+
tail -3
|
|
61
|
+
|
|
62
|
+
echo "==> 校验远程版本"
|
|
63
|
+
REMOTE_VER=$(
|
|
64
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
|
|
65
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
|
|
66
|
+
tail -1 | tr -d '\r'
|
|
67
|
+
)
|
|
68
|
+
if [ "$REMOTE_VER" = "$VER" ]; then
|
|
69
|
+
echo "✓ 远程版本 $REMOTE_VER"
|
|
70
|
+
else
|
|
71
|
+
echo "✗ 版本不一致: 期望 $VER, 实际 '${REMOTE_VER:-空}'"
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if [ "$DO_RESTART" = 1 ]; then
|
|
76
|
+
echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
|
|
77
|
+
ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
|
|
78
|
+
grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
|
|
79
|
+
head -10 || true
|
|
80
|
+
echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
echo "==> 完成"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// 只读诊断:201 本机实测 claimNextJob 各分支 SQL 耗时(readonly,不动生产数据)
|
|
2
|
+
// 用完即删
|
|
3
|
+
import path from "path";
|
|
4
|
+
import Database from "file://D:/tiktok/tiktok-cli/node_modules/better-sqlite3/lib/index.js";
|
|
5
|
+
const db = new Database(path.join("D:", "tiktok", "data", "result.db"), {
|
|
6
|
+
readonly: true,
|
|
7
|
+
timeout: 600000,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function t(name, sql, args = []) {
|
|
11
|
+
const stmt = db.prepare(sql);
|
|
12
|
+
const t0 = Date.now();
|
|
13
|
+
const r = stmt.get(...args);
|
|
14
|
+
console.log(
|
|
15
|
+
`${String(Date.now() - t0).padStart(6)}ms ${r ? "HIT " : "MISS"} ${name}`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ORDER = "ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
|
|
20
|
+
const SC = "(status_code IS NULL OR status_code = 0 OR status_code = 209002)";
|
|
21
|
+
|
|
22
|
+
console.log("=== A. pinned 分支(池当前为空)===");
|
|
23
|
+
t(
|
|
24
|
+
"pinned+video",
|
|
25
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=1 AND video_count>0 AND ${SC} ${ORDER}`,
|
|
26
|
+
);
|
|
27
|
+
t(
|
|
28
|
+
"pinned",
|
|
29
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=1 AND ${SC} ${ORDER}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
console.log("=== B. seed 级首国分支(MX):OR 空位置 原版形态 ===");
|
|
33
|
+
t(
|
|
34
|
+
"seed MX OR-empty (原版)",
|
|
35
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND instr(COALESCE(sources,''), '"seed"') > 0 AND video_count > 0 AND (UPPER(COALESCE(guessed_location,'')) IN ('MX') OR COALESCE(guessed_location,'') = '') AND ${SC} ${ORDER}`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
console.log("=== C. 拆分形态:单值 IN 点查 ===");
|
|
39
|
+
t(
|
|
40
|
+
"seed MX only",
|
|
41
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND instr(COALESCE(sources,''), '"seed"') > 0 AND video_count > 0 AND UPPER(COALESCE(guessed_location,'')) IN ('MX') AND ${SC} ${ORDER}`,
|
|
42
|
+
);
|
|
43
|
+
t(
|
|
44
|
+
"seed empty-loc only",
|
|
45
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND instr(COALESCE(sources,''), '"seed"') > 0 AND video_count > 0 AND UPPER(COALESCE(guessed_location,'')) = '' AND ${SC} ${ORDER}`,
|
|
46
|
+
);
|
|
47
|
+
t(
|
|
48
|
+
"seed ID only (最大池 10.8万)",
|
|
49
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND instr(COALESCE(sources,''), '"seed"') > 0 AND video_count > 0 AND UPPER(COALESCE(guessed_location,'')) IN ('ID') AND ${SC} ${ORDER}`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
console.log("=== D. seller/follow/other 级抽测(MX 单值)===");
|
|
53
|
+
t(
|
|
54
|
+
"seller MX hasVideo",
|
|
55
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND video_count>0 AND UPPER(COALESCE(guessed_location,'')) IN ('MX') AND ${SC} ${ORDER}`,
|
|
56
|
+
);
|
|
57
|
+
t(
|
|
58
|
+
"follow MX hasVideo",
|
|
59
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND (instr(COALESCE(sources,''), '"following"') > 0 OR instr(COALESCE(sources,''), '"follower"') > 0 OR instr(COALESCE(sources,''), '"comment"') > 0) AND video_count>0 AND UPPER(COALESCE(guessed_location,'')) IN ('MX') AND ${SC} ${ORDER}`,
|
|
60
|
+
);
|
|
61
|
+
t(
|
|
62
|
+
"other MX hasVideo",
|
|
63
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND video_count>0 AND UPPER(COALESCE(guessed_location,'')) IN ('MX') AND ${SC} ${ORDER}`,
|
|
64
|
+
);
|
|
65
|
+
console.log("=== E. NOT IN 24国兜底分支 ===");
|
|
66
|
+
t(
|
|
67
|
+
"other NOT-IN fallback",
|
|
68
|
+
`SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND video_count>0 AND UPPER(COALESCE(guessed_location,'')) NOT IN ('MX','JP','BR','SG','ID','VN','PH','PL','NL','BE','US','TH','MY','AT','CZ','HU','GR','PT','DE','FR','IT','IE','ES','GB') AND ${SC} ${ORDER}`,
|
|
69
|
+
);
|
|
70
|
+
db.close();
|
|
71
|
+
console.log("done");
|
|
72
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// claimNextJob 修复验证:功能语义 + 性能(合成 32 万行模拟生产行数)
|
|
2
|
+
// 用完即删
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { createStore } from "../src/watch/data-store.js";
|
|
5
|
+
import { getDb } from "../src/watch/db-schema.js";
|
|
6
|
+
|
|
7
|
+
const TMP = "/tmp/tiktok-claim-test";
|
|
8
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
9
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
10
|
+
|
|
11
|
+
function freshStore(name) {
|
|
12
|
+
const p = `${TMP}/${name}.db`;
|
|
13
|
+
const store = createStore(p, {});
|
|
14
|
+
return { store, db: getDb(), path: p };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function insertRows(db, rows) {
|
|
18
|
+
const stmt = db.prepare(`
|
|
19
|
+
INSERT OR REPLACE INTO task_queue
|
|
20
|
+
(unique_id, status, guessed_location, video_count, follower_count,
|
|
21
|
+
tt_seller, verified, status_code, pinned, sources, created_at, claimed_at, claimed_by)
|
|
22
|
+
VALUES
|
|
23
|
+
(@unique_id, @status, @guessed_location, @video_count, @follower_count,
|
|
24
|
+
@tt_seller, @verified, @status_code, @pinned, @sources, @created_at, @claimed_at, @claimed_by)
|
|
25
|
+
`);
|
|
26
|
+
const tx = db.transaction((list) => {
|
|
27
|
+
for (const r of list) stmt.run(r);
|
|
28
|
+
});
|
|
29
|
+
tx(rows);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const baseRow = {
|
|
33
|
+
status: "pending",
|
|
34
|
+
guessed_location: "",
|
|
35
|
+
video_count: 5,
|
|
36
|
+
follower_count: 1000,
|
|
37
|
+
tt_seller: 0,
|
|
38
|
+
verified: 0,
|
|
39
|
+
status_code: null,
|
|
40
|
+
pinned: 0,
|
|
41
|
+
sources: '["explore"]',
|
|
42
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
43
|
+
claimed_at: null,
|
|
44
|
+
claimed_by: null,
|
|
45
|
+
};
|
|
46
|
+
const row = (over) => ({ ...baseRow, unique_id: `u_${Math.random().toString(36).slice(2, 9)}`, ...over });
|
|
47
|
+
|
|
48
|
+
let pass = 0, fail = 0;
|
|
49
|
+
function check(name, cond, detail = "") {
|
|
50
|
+
if (cond) { pass++; console.log(` ✅ ${name}${detail ? " " + detail : ""}`); }
|
|
51
|
+
else { fail++; console.log(` ❌ ${name}${detail ? " " + detail : ""}`); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ───────── B. 功能语义(小数据集,各用独立库) ─────────
|
|
55
|
+
console.log("\n== B1. 首国等值严格优先于空位置(即使空位置粉丝更高)==");
|
|
56
|
+
{
|
|
57
|
+
const { store, db } = freshStore("b1");
|
|
58
|
+
insertRows(db, [
|
|
59
|
+
row({ unique_id: "empty-high", guessed_location: "", follower_count: 9999 }),
|
|
60
|
+
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 100 }),
|
|
61
|
+
]);
|
|
62
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
63
|
+
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("== B2. 首国无候选时空位置可领 ==");
|
|
67
|
+
{
|
|
68
|
+
const { store, db } = freshStore("b2");
|
|
69
|
+
insertRows(db, [row({ unique_id: "only-empty", guessed_location: "" })]);
|
|
70
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
71
|
+
check("领到 only-empty", j?.uniqueId === "only-empty", `got=${j?.uniqueId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log("== B3. hasVideo 层整体优先于 noVideoReq 层(含空位置)==");
|
|
75
|
+
{
|
|
76
|
+
const { store, db } = freshStore("b3");
|
|
77
|
+
insertRows(db, [
|
|
78
|
+
row({ unique_id: "mx-novideo", guessed_location: "mx", video_count: 0, follower_count: 5000 }),
|
|
79
|
+
row({ unique_id: "br-video", guessed_location: "br", video_count: 3, follower_count: 100 }),
|
|
80
|
+
]);
|
|
81
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
82
|
+
check("mx-noVideo 优先于 br-video(与原版层级一致)", j?.uniqueId === "mx-novideo", `got=${j?.uniqueId}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log("== B4. 国家顺序优先:MX 行优先于 BR 行 ==");
|
|
86
|
+
{
|
|
87
|
+
const { store, db } = freshStore("b4");
|
|
88
|
+
insertRows(db, [
|
|
89
|
+
row({ unique_id: "br-high", guessed_location: "br", follower_count: 9999 }),
|
|
90
|
+
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 10 }),
|
|
91
|
+
]);
|
|
92
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
93
|
+
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log("== B5. pinned:空池记忆后 pin 立即可领(缓存失效生效)==");
|
|
97
|
+
{
|
|
98
|
+
const { store, db } = freshStore("b5");
|
|
99
|
+
insertRows(db, [
|
|
100
|
+
row({ unique_id: "plain", guessed_location: "mx", follower_count: 100 }),
|
|
101
|
+
row({ unique_id: "pinned-later", guessed_location: "br", follower_count: 50 }),
|
|
102
|
+
]);
|
|
103
|
+
// 第一次领取:pinned 分支执行且 miss → 写入空池记忆
|
|
104
|
+
const j1 = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
105
|
+
check("第一次领到 plain", j1?.uniqueId === "plain", `got=${j1?.uniqueId}`);
|
|
106
|
+
// pin 一个低优先级任务
|
|
107
|
+
const pinRet = store.togglePin("pinned-later");
|
|
108
|
+
check("togglePin 成功", pinRet?.saved === true);
|
|
109
|
+
// 立即换客户端领取:应直接领到 pinned 任务(不受空池记忆影响)
|
|
110
|
+
const j2 = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
111
|
+
check("pin 后立即领到 pinned-later", j2?.uniqueId === "pinned-later", `got=${j2?.uniqueId}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log("== B6. 未登录客户端不领商家 ==");
|
|
115
|
+
{
|
|
116
|
+
const { store, db } = freshStore("b6");
|
|
117
|
+
insertRows(db, [row({ unique_id: "seller-only", guessed_location: "mx", tt_seller: 1 })]);
|
|
118
|
+
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
119
|
+
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
120
|
+
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
121
|
+
check("登录可领", jIn?.uniqueId === "seller-only", `got=${jIn?.uniqueId}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log("== B7. status_code=209002 仅登录客户端可领 ==");
|
|
125
|
+
{
|
|
126
|
+
const { store, db } = freshStore("b7");
|
|
127
|
+
insertRows(db, [row({ unique_id: "code-209002", guessed_location: "mx", status_code: 209002 })]);
|
|
128
|
+
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
129
|
+
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
130
|
+
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
131
|
+
check("登录可领", jIn?.uniqueId === "code-209002", `got=${jIn?.uniqueId}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log("== B8. 超时 processing 回收优先 ==");
|
|
135
|
+
{
|
|
136
|
+
const { store, db } = freshStore("b8");
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
insertRows(db, [
|
|
139
|
+
row({ unique_id: "stale", guessed_location: "mx", status: "processing", claimed_at: now - 10 * 60 * 1000, claimed_by: "dead-client" }),
|
|
140
|
+
row({ unique_id: "fresh", guessed_location: "mx", follower_count: 99999 }),
|
|
141
|
+
]);
|
|
142
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
143
|
+
check("回收的 stale 优先于高粉丝 fresh", j?.uniqueId === "stale", `got=${j?.uniqueId}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log("== B9. 商家任意国家兜底(sellerAnyCountry)==");
|
|
147
|
+
{
|
|
148
|
+
const { store, db } = freshStore("b9");
|
|
149
|
+
insertRows(db, [
|
|
150
|
+
row({ unique_id: "zz-seller", guessed_location: "zz", tt_seller: 1, verified: 0, follower_count: 10 }),
|
|
151
|
+
row({ unique_id: "mx-normal", guessed_location: "mx", follower_count: 9999 }),
|
|
152
|
+
]);
|
|
153
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
154
|
+
check("商家级高于 other 级:zz-seller 先领", j?.uniqueId === "zz-seller", `got=${j?.uniqueId}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log("== B10. seed 级优先于 follow/other ==");
|
|
158
|
+
{
|
|
159
|
+
const { store, db } = freshStore("b10");
|
|
160
|
+
insertRows(db, [
|
|
161
|
+
row({ unique_id: "mx-seed", guessed_location: "mx", sources: '["explore"]', follower_count: 10 }),
|
|
162
|
+
row({ unique_id: "br-seed", guessed_location: "br", sources: '["seed"]', follower_count: 20 }),
|
|
163
|
+
]);
|
|
164
|
+
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
165
|
+
check("seed 级 br-seed 胜过 other 级 mx(国家级让位于来源级)", j?.uniqueId === "br-seed", `got=${j?.uniqueId}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ───────── A. 性能(32 万行)─────────
|
|
169
|
+
console.log("\n== A. 性能:320,600 行 pending,候选国家池全空,模拟生产全 miss 场景 ==");
|
|
170
|
+
{
|
|
171
|
+
const { store, db } = freshStore("perf");
|
|
172
|
+
// 32 万行:位置全 ZZ(配置外),含 3000 个高粉丝商家(seller any-country 分支秒命中用)
|
|
173
|
+
db.exec(`
|
|
174
|
+
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 320600)
|
|
175
|
+
INSERT INTO task_queue
|
|
176
|
+
(unique_id, status, guessed_location, video_count, follower_count, tt_seller, verified, status_code, pinned, sources, created_at)
|
|
177
|
+
SELECT
|
|
178
|
+
'bulk_' || i,
|
|
179
|
+
'pending',
|
|
180
|
+
'zz',
|
|
181
|
+
CASE WHEN i % 3 = 0 THEN 5 ELSE 0 END,
|
|
182
|
+
(i * 7919) % 1000000,
|
|
183
|
+
CASE WHEN i <= 3000 THEN 1 ELSE 0 END,
|
|
184
|
+
0,
|
|
185
|
+
NULL,
|
|
186
|
+
0,
|
|
187
|
+
'["explore"]',
|
|
188
|
+
'2026-01-01T00:00:00Z'
|
|
189
|
+
FROM seq
|
|
190
|
+
`);
|
|
191
|
+
// 保证 3 个最高粉丝都是商家 → seller any-country 命中不扫表
|
|
192
|
+
db.exec(`UPDATE task_queue SET follower_count = 100000000 + CAST(unique_id AS INTEGER) % 1000 WHERE tt_seller = 1 AND CAST(REPLACE(unique_id,'bulk_','') AS INTEGER) <= 3`);
|
|
193
|
+
|
|
194
|
+
const t1 = Date.now();
|
|
195
|
+
const j1 = await store.claimNextJob("perf-c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
196
|
+
const t1ms = Date.now() - t1;
|
|
197
|
+
check("首领成功(命中商家兜底)", j1?.uniqueId?.startsWith("bulk_"), `got=${j1?.uniqueId} ${t1ms}ms`);
|
|
198
|
+
|
|
199
|
+
const t2 = Date.now();
|
|
200
|
+
const j2 = await store.claimNextJob("perf-c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
201
|
+
const t2ms = Date.now() - t2;
|
|
202
|
+
check("次领成功", j2?.uniqueId?.startsWith("bulk_"), `got=${j2?.uniqueId} ${t2ms}ms`);
|
|
203
|
+
check("次领显著快于首领(pinned/seed 空池缓存生效)", t2ms < t1ms, `t1=${t1ms}ms t2=${t2ms}ms`);
|
|
204
|
+
check("次领 < 150ms", t2ms < 150, `${t2ms}ms`);
|
|
205
|
+
|
|
206
|
+
// EXPLAIN:确认单国等值分支走 fast 索引
|
|
207
|
+
const plan = db
|
|
208
|
+
.prepare(`EXPLAIN QUERY PLAN SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND video_count > 0 AND UPPER(COALESCE(guessed_location, '')) IN ('MX') AND (status_code IS NULL OR status_code = 0 OR status_code = 209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`)
|
|
209
|
+
.all();
|
|
210
|
+
const detail = plan.map((r) => r.detail).join(" | ");
|
|
211
|
+
check("单国分支走 idx_tq_claim_fast", detail.includes("idx_tq_claim_fast"), detail);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`\n===== 结果: ${pass} passed, ${fail} failed =====`);
|
|
215
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
216
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 验证 idx_jobs_precise_merchants 部分覆盖索引:EXPLAIN 命中 + A/B 耗时
|
|
2
|
+
// 用完即删
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { createStore } from "../src/watch/data-store.js";
|
|
5
|
+
import { getDb } from "../src/watch/db-schema.js";
|
|
6
|
+
|
|
7
|
+
const TMP = "/tmp/tiktok-precise-idx-test";
|
|
8
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
9
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
10
|
+
|
|
11
|
+
const store = createStore(`${TMP}/t.db`, {});
|
|
12
|
+
const db = getDb();
|
|
13
|
+
|
|
14
|
+
const IDX_SQL =
|
|
15
|
+
"CREATE INDEX IF NOT EXISTS idx_jobs_precise_merchants ON jobs(location_created, product_sold_count DESC, unique_id) WHERE tt_seller = 1 AND verified = 0 AND top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND product_review_count = 0 AND product_sold_count > 10 AND recent_ec_videos IS NOT NULL AND recent_ec_videos != ''";
|
|
16
|
+
|
|
17
|
+
// 与 data-store.js getPreciseMerchantsFromDb 逐词一致的查询(loc 参数化)
|
|
18
|
+
const COUNT_SQL = (ph) =>
|
|
19
|
+
`SELECT COUNT(*) AS n FROM jobs WHERE tt_seller = 1 AND verified = 0 AND location_created IN (${ph}) AND top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND product_review_count = 0 AND product_sold_count > 10 AND recent_ec_videos IS NOT NULL AND recent_ec_videos != '' AND EXISTS (SELECT 1 FROM json_each(recent_ec_videos) WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000)`;
|
|
20
|
+
|
|
21
|
+
const LOCS = ["MX", "BR", "DE", "US", "TH"];
|
|
22
|
+
const ph = LOCS.map(() => "?").join(", ");
|
|
23
|
+
|
|
24
|
+
// ── 造数:30 万商家(大部分不满足便宜条件)+ 500 行满足 + 1 万非商家 ──
|
|
25
|
+
console.log("造数 31 万行...");
|
|
26
|
+
db.exec(`
|
|
27
|
+
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 300000)
|
|
28
|
+
INSERT INTO jobs
|
|
29
|
+
(unique_id, status, tt_seller, verified, location_created,
|
|
30
|
+
top_video_seo_url, product_review_count, product_sold_count,
|
|
31
|
+
recent_ec_videos, follower_count)
|
|
32
|
+
SELECT
|
|
33
|
+
's_' || i, 'done', 1, 0,
|
|
34
|
+
CASE (i % 5) WHEN 0 THEN 'MX' WHEN 1 THEN 'BR' WHEN 2 THEN 'DE' WHEN 3 THEN 'US' ELSE 'TH' END,
|
|
35
|
+
NULL, 5, 3, NULL, 1000
|
|
36
|
+
FROM seq
|
|
37
|
+
`);
|
|
38
|
+
db.exec(`
|
|
39
|
+
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 500)
|
|
40
|
+
INSERT INTO jobs
|
|
41
|
+
(unique_id, status, tt_seller, verified, location_created,
|
|
42
|
+
top_video_seo_url, product_review_count, product_sold_count,
|
|
43
|
+
recent_ec_videos, follower_count)
|
|
44
|
+
SELECT
|
|
45
|
+
'hit_' || i, 'done', 1, 0,
|
|
46
|
+
CASE (i % 5) WHEN 0 THEN 'MX' WHEN 1 THEN 'BR' WHEN 2 THEN 'DE' WHEN 3 THEN 'US' ELSE 'TH' END,
|
|
47
|
+
'/shop/xxx-' || i, 0, 20 + (i % 100),
|
|
48
|
+
'[{"playCount":' || (2000 + i) || '}]', 5000
|
|
49
|
+
FROM seq
|
|
50
|
+
`);
|
|
51
|
+
db.exec(`
|
|
52
|
+
INSERT INTO jobs
|
|
53
|
+
(unique_id, status, tt_seller, verified, location_created,
|
|
54
|
+
top_video_seo_url, product_review_count, product_sold_count, recent_ec_videos, follower_count)
|
|
55
|
+
SELECT 'ns_' || i, 'done', 0, 0, 'MX', '/x', 0, 20, '[{"playCount":3000}]', 100
|
|
56
|
+
FROM (WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 10000) SELECT i FROM seq)
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
const args = [...LOCS];
|
|
60
|
+
|
|
61
|
+
// ── A/B:无新索引 vs 有新索引 ──
|
|
62
|
+
function bench(label) {
|
|
63
|
+
const t0 = Date.now();
|
|
64
|
+
const n = db.prepare(COUNT_SQL(ph)).get(...args).n;
|
|
65
|
+
const ms = Date.now() - t0;
|
|
66
|
+
console.log(` ${label}: COUNT=${n} ${ms}ms`);
|
|
67
|
+
return { n, ms };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log("\n== A/B 耗时(30.6 万行)==");
|
|
71
|
+
const before = bench("无新索引");
|
|
72
|
+
db.exec(IDX_SQL);
|
|
73
|
+
const after = bench("有新索引");
|
|
74
|
+
|
|
75
|
+
console.log("\n== EXPLAIN:COUNT 与 LIST 查询计划 ==");
|
|
76
|
+
const planCount = db.prepare(`EXPLAIN QUERY PLAN ${COUNT_SQL(ph)}`).all(...args);
|
|
77
|
+
console.log("COUNT :", planCount.map((r) => r.detail).join(" | "));
|
|
78
|
+
const LIST_SQL = `SELECT unique_id, product_sold_count FROM jobs WHERE tt_seller = 1 AND verified = 0 AND location_created IN (${ph}) AND top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND product_review_count = 0 AND product_sold_count > 10 AND recent_ec_videos IS NOT NULL AND recent_ec_videos != '' AND EXISTS (SELECT 1 FROM json_each(recent_ec_videos) WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) ORDER BY product_sold_count DESC, unique_id ASC LIMIT 100 OFFSET 0`;
|
|
79
|
+
const planList = db.prepare(`EXPLAIN QUERY PLAN ${LIST_SQL}`).all(...args);
|
|
80
|
+
console.log("LIST :", planList.map((r) => r.detail).join(" | "));
|
|
81
|
+
|
|
82
|
+
const COUNT_IDX = `SELECT COUNT(*) AS n FROM jobs INDEXED BY idx_jobs_precise_merchants WHERE tt_seller = 1 AND verified = 0 AND location_created IN (${ph}) AND top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND product_review_count = 0 AND product_sold_count > 10 AND recent_ec_videos IS NOT NULL AND recent_ec_videos != '' AND EXISTS (SELECT 1 FROM json_each(recent_ec_videos) WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000)`;
|
|
83
|
+
const planIdx = db.prepare(`EXPLAIN QUERY PLAN ${COUNT_IDX}`).all(...args);
|
|
84
|
+
const nIdx = db.prepare(COUNT_IDX).get(...args).n;
|
|
85
|
+
|
|
86
|
+
let pass = 0, fail = 0;
|
|
87
|
+
const check = (name, cond, detail = "") => {
|
|
88
|
+
if (cond) { pass++; console.log(` ✅ ${name}${detail ? " " + detail : ""}`); }
|
|
89
|
+
else { fail++; console.log(` ❌ ${name}${detail ? " " + detail : ""}`); }
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
console.log("\n== 断言 ==");
|
|
93
|
+
check("COUNT 结果一致(500)", after.n === 500 && before.n === 500, `n=${after.n}`);
|
|
94
|
+
check("INDEXED BY 结果一致", nIdx === 500, `n=${nIdx}`);
|
|
95
|
+
check("优化器自发命中 partial index(小库可自发选中)",
|
|
96
|
+
planCount.some((r) => r.detail.includes("idx_jobs_precise_merchants")),
|
|
97
|
+
planCount.map((r) => r.detail).join(" | ").slice(0, 120));
|
|
98
|
+
check("INDEXED BY 强制命中 partial index", planIdx.some((r) => r.detail.includes("idx_jobs_precise_merchants")));
|
|
99
|
+
|
|
100
|
+
// 回退:drop 索引后 INDEXED BY 应报 no such index(业务层 catch 后走普通查询)
|
|
101
|
+
db.exec("DROP INDEX idx_jobs_precise_merchants");
|
|
102
|
+
let fellBack = false;
|
|
103
|
+
try {
|
|
104
|
+
db.prepare(COUNT_IDX).get(...args);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
fellBack = /no such index/i.test(e.message);
|
|
107
|
+
}
|
|
108
|
+
check("索引缺失时抛 no such index(业务层回退依赖此错误)", fellBack);
|
|
109
|
+
const nPlainAfterDrop = db.prepare(COUNT_SQL(ph)).get(...args).n;
|
|
110
|
+
check("普通查询不受影响", nPlainAfterDrop === 500, `n=${nPlainAfterDrop}`);
|
|
111
|
+
|
|
112
|
+
console.log(`\n===== 结果: ${pass} passed, ${fail} failed =====`);
|
|
113
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
114
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// /api/stats 冒烟:fast 组直接含 targetUsers/preciseMerchants(烟花 30s 级)
|
|
2
|
+
// 用完即删
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { createStore } from "../src/watch/data-store.js";
|
|
5
|
+
import { getDb } from "../src/watch/db-schema.js";
|
|
6
|
+
import { startWatchServer } from "../src/watch/server.js";
|
|
7
|
+
|
|
8
|
+
const TMP = "/tmp/tk-stats-smoke";
|
|
9
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
10
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
11
|
+
const DB = `${TMP}/t.db`;
|
|
12
|
+
const PORT = 3199;
|
|
13
|
+
|
|
14
|
+
const store = createStore(DB, {});
|
|
15
|
+
const db = getDb();
|
|
16
|
+
db.exec("ALTER TABLE jobs ADD COLUMN product_skus TEXT");
|
|
17
|
+
db.exec(`INSERT INTO jobs (unique_id, status, tt_seller, verified, location_created,
|
|
18
|
+
top_video_seo_url, product_review_count, product_sold_count, recent_ec_videos, follower_count)
|
|
19
|
+
VALUES ('m1','done',1,0,'DE','/x',0,20,'[{"playCount":3000}]',100)`);
|
|
20
|
+
db.exec(`INSERT INTO jobs (unique_id, status, tt_seller, verified, location_created, follower_count)
|
|
21
|
+
VALUES ('plain','done',1,0,'BR',50)`);
|
|
22
|
+
|
|
23
|
+
await startWatchServer(DB, PORT, store, { llmDiscover: false }, null);
|
|
24
|
+
|
|
25
|
+
let ok = false;
|
|
26
|
+
for (let i = 0; i < 20 && !ok; i++) {
|
|
27
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/api/stats`);
|
|
30
|
+
const s = await res.json();
|
|
31
|
+
if (s && typeof s.targetUsers !== "undefined") {
|
|
32
|
+
ok = true;
|
|
33
|
+
console.log("targetUsers =", s.targetUsers, s.targetUsers === 1 ? "✅" : "❌ 应为 1");
|
|
34
|
+
console.log("preciseMerchants=", s.preciseMerchants, s.preciseMerchants === 1 ? "✅" : "❌ 应为 1");
|
|
35
|
+
console.log("processedUsers =", s.processedUsers, "(slow 组 jobsAgg 未就绪时可为 0,5 分钟内后台补全)");
|
|
36
|
+
const pass = s.targetUsers === 1 && s.preciseMerchants === 1;
|
|
37
|
+
console.log(pass ? "SMOKE PASS" : "SMOKE FAIL");
|
|
38
|
+
process.exit(pass ? 0 : 1);
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
console.log("SMOKE FAIL: /api/stats 未响应");
|
|
43
|
+
process.exit(1);
|