tt-help-cli-ycl 1.4.167 → 1.4.169

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.167",
3
+ "version": "1.4.169",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,4 +48,4 @@
48
48
  "devDependencies": {
49
49
  "better-sqlite3": "^12.10.0"
50
50
  }
51
- }
51
+ }
@@ -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);
@@ -5,7 +5,7 @@
5
5
  * 1. 检测 Clash Verge 进程/API 存活(跨平台 mac/win)
6
6
  * 2. 自动发现代理端口(GET /configs → mixed-port)
7
7
  * 3. 节点健康检测(curl 穿透代理请求 TikTok)
8
- * 4. 国家过滤(按节点名 emoji 匹配)
8
+ * 4. 国家过滤(全词文本 + emoji 国旗 + 单字方言三层匹配)
9
9
  * 5. 健康节点列表持久化 + 故障切换
10
10
  * 6. CLI 入口硬门控预检
11
11
  */
@@ -311,18 +311,95 @@ export function normalizeCountries(v) {
311
311
  return [];
312
312
  }
313
313
 
314
- /** 把国家过滤值归一化为匹配模式数组(多国取并集),未知国家自动忽略 */
315
- function getCountryPatterns(country) {
316
- const pats = normalizeCountries(country)
317
- .map((c) => COUNTRY_PATTERNS[c])
318
- .filter(Boolean);
319
- return pats.length ? pats : null;
314
+ // ── 层2:emoji 国旗(全局启用)──
315
+ // 国旗是 regional indicator 成对编码(🇩🇪 = U+1F1E9 U+1F1EA),无歧义,
316
+ // 比节点名文本更可靠。7899/topman 类机场节点名只有国旗或单字缩写
317
+ // (S德🇩🇪56、🇭🇰261008节点、S0港🇭🇰4),全词文本模式全部落空(实测 0/20)。
318
+ // 偏召回设计:公告伪节点误认由 delay 初测淘汰,漏检则永远进不了候选集。
319
+ const FLAG_RE = /[\u{1F1E6}-\u{1F1FF}]{2}/u;
320
+ const FLAG_OFFSET = 0x1f1e6 - 65; // regional indicator → 大写字母
321
+
322
+ /** emoji 国旗 → ISO 国家码,无旗返回 null */
323
+ export function matchCountryByEmoji(name) {
324
+ const m = String(name || "").match(FLAG_RE);
325
+ if (!m) return null;
326
+ const [a, b] = [...m[0]].map((ch) => ch.codePointAt(0) - FLAG_OFFSET);
327
+ return String.fromCharCode(a, b);
328
+ }
329
+
330
+ // ── 层3:单字缩写(按方言门控)──
331
+ // 误伤半径大,仅在检测到方言命名风格的订阅上启用(见 detectSingleCharDialect)。
332
+ // 刻意不含 新/加/印/台/澳 等高歧义单字(新节点/加速器/打印/平台/澳门)
333
+ const SINGLE_CHAR_PATTERNS = {
334
+ 美: "US",
335
+ 英: "GB",
336
+ 法: "FR",
337
+ 德: "DE",
338
+ 日: "JP",
339
+ 韩: "KR",
340
+ 港: "HK",
341
+ 俄: "RU",
342
+ };
343
+
344
+ // 单字边界:前后不得紧邻其他汉字 —— 放行 "S美🇺🇸86"/"S0港4"/"TEST日🇯🵵10",
345
+ // 排除全词("美国"/"香港",层1已覆盖)与嵌字("完美网络"不含国家义)
346
+ const SINGLE_CHAR_BOUNDARY_RE = /(^|[^一-鿿])[美德日韩港英法俄](?![一-鿿])/;
347
+
348
+ // 预编译:国家码 → 单字边界正则数组
349
+ const SINGLE_CHAR_RES = {};
350
+ for (const [ch, c] of Object.entries(SINGLE_CHAR_PATTERNS)) {
351
+ (SINGLE_CHAR_RES[c] ??= []).push(
352
+ new RegExp(`(^|[^一-鿿])${ch}(?![一-鿿])`),
353
+ );
354
+ }
355
+
356
+ // "仅靠 emoji 可识别(全词文本不命中)"的节点占比 ≥ 阈值 → emoji/单字方言机场。
357
+ // 老机场命名带全词("🇺🇸美国HY1")文本层即可命中,占比≈0;topman 类占比 >0.6
358
+ const DIALECT_MIN_RATIO = 0.3;
359
+ const DIALECT_MIN_SAMPLE = 5; // 样本太少不判定,避免小列表误翻转
360
+
361
+ /**
362
+ * 方言探测:该订阅是否属于 emoji/单字命名风格(决定单字层是否启用)。
363
+ * 两路信号取大者:① 仅靠 emoji 可识别(全词文本不命中)占比 —— topman 类;
364
+ * ② 单字边界风格占比 —— 兼顾无 emoji 纯单字缩写的机场。
365
+ * 老机场("🇺🇸美国HY1")两路占比均≈0,单字层不启用、行为与旧版一致
366
+ * @param {string[]} nodes 全量节点名
367
+ */
368
+ function detectSingleCharDialect(nodes) {
369
+ const usable = (Array.isArray(nodes) ? nodes : []).filter(isUsableProxyNode);
370
+ if (usable.length < DIALECT_MIN_SAMPLE) return false;
371
+ let emojiOnly = 0;
372
+ let singleCharStyle = 0;
373
+ for (const n of usable) {
374
+ const code = matchCountryByEmoji(n);
375
+ const pat = code && COUNTRY_PATTERNS[code];
376
+ if (pat && !pat.test(n)) emojiOnly++;
377
+ if (SINGLE_CHAR_BOUNDARY_RE.test(n)) singleCharStyle++;
378
+ }
379
+ const ratio = Math.max(emojiOnly, singleCharStyle) / usable.length;
380
+ return ratio >= DIALECT_MIN_RATIO;
381
+ }
382
+
383
+ /** 单节点是否命中指定国家的任一匹配层(emoji / 全词文本 / 单字方言) */
384
+ function matchNodeCountry(name, code, useSingleChar) {
385
+ if (matchCountryByEmoji(name) === code) return true;
386
+ const pat = COUNTRY_PATTERNS[code];
387
+ if (pat && pat.test(name)) return true;
388
+ if (useSingleChar) {
389
+ for (const re of SINGLE_CHAR_RES[code] || []) {
390
+ if (re.test(String(name))) return true;
391
+ }
392
+ }
393
+ return false;
320
394
  }
321
395
 
322
396
  export function filterNodesByCountry(nodes, country) {
323
- const pats = getCountryPatterns(country);
324
- if (!pats) return { filtered: nodes, excluded: 0 };
325
- const filtered = nodes.filter((n) => pats.some((p) => p.test(n)));
397
+ const countries = normalizeCountries(country);
398
+ if (!countries.length) return { filtered: nodes, excluded: 0 };
399
+ const useSingleChar = detectSingleCharDialect(nodes);
400
+ const filtered = nodes.filter((n) =>
401
+ countries.some((c) => matchNodeCountry(n, c, useSingleChar)),
402
+ );
326
403
  return { filtered, excluded: nodes.length - filtered.length };
327
404
  }
328
405
 
@@ -349,15 +426,20 @@ const BUILTIN_POLICY_NAMES = new Set([
349
426
  const INFO_NODE_PATTERN =
350
427
  /剩余|流量|到期|过期|官网|重置|套餐|订阅|工单|[\d.]\s*[KMGTP]B$/i;
351
428
 
352
- /**
353
- * 判定节点名是否可用作业务出口(内置策略与机场信息节点返回 false)
354
- * 在候选集写口(runDelayProbe)与读口(loadHealthyList)统一调用
429
+ // []【】()()注解段。机场常把"实时更新订阅/0倍率/移动优化"等字样放进注解,
430
+ // 主名才是节点标识。信息节点判定只看主名,避免注解关键词误杀真节点
431
+ // (2026-01 生产实锤:S韩🇰🇷55[实时更新订阅]、🇺🇸 2[0倍率][实时更新订阅] 被"订阅"误杀)
432
+ const ANNOTATION_RE = /\[[^\]]*\]|【[^】]*】|\([^)]*\)|([^)]*)/g;
433
+
434
+ /** 判定节点名是否可用作业务出口(内置策略与机场信息节点返回 false)
435
+ * 在候选集写口(runDelayProbe)与读口(loadHealthyList)统一调用
355
436
  */
356
437
  export function isUsableProxyNode(name) {
357
438
  const n = String(name || "").trim();
358
439
  if (!n) return false;
359
440
  if (BUILTIN_POLICY_NAMES.has(n.toUpperCase())) return false;
360
- if (INFO_NODE_PATTERN.test(n)) return false;
441
+ // 信息节点只看主名:注解里的"订阅/更新"等字样不算(见 ANNOTATION_RE 注释)
442
+ if (INFO_NODE_PATTERN.test(n.replace(ANNOTATION_RE, " "))) return false;
361
443
  return true;
362
444
  }
363
445
 
@@ -893,11 +893,32 @@ async function rollbackConfig(content, { index = 1, apiPort, secret } = {}) {
893
893
  } catch {}
894
894
  }
895
895
 
896
- /** 等待并校验 provider 上线(热应用/接管共用) */
896
+ /**
897
+ * 强制刷新 provider(绕过 interval 缓存),让新订阅立即生效。
898
+ * 热加载/接管不会触发 provider 重新拉取:URL 已变但同名缓存未过期(interval 1h 内)时,
899
+ * 内核沿用旧订阅节点,节点数校验会把旧缓存误判为新订阅上线
900
+ * (2026-01-11 生产实锤:i3 换订阅后报应用成功,实际最长 1h 仍是旧机场节点)。
901
+ * 尽力而为:单条失败不报错(机场 URL 暂时不可达等),由后续节点数校验兜底。
902
+ */
903
+ async function refreshProviders(subs, { apiPort, secret }) {
904
+ const headers = {};
905
+ if (secret) headers.Authorization = `Bearer ${secret}`;
906
+ await Promise.all(
907
+ subs.map((s) =>
908
+ fetch(
909
+ `http://127.0.0.1:${apiPort}/providers/proxies/${encodeURIComponent(s.name)}`,
910
+ { method: "PUT", headers, signal: AbortSignal.timeout(20_000) },
911
+ ).catch(() => {}),
912
+ ),
913
+ );
914
+ }
915
+
916
+ /** 等待并校验 provider 上线(热应用/接管共用):先强刷 provider 再校验,确保校验的是新订阅 */
897
917
  export async function verifyProviders(
898
918
  subs,
899
919
  { apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, log = () => {}, waitMs = 3000 } = {},
900
920
  ) {
921
+ await refreshProviders(subs, { apiPort, secret });
901
922
  await delay(waitMs);
902
923
  try {
903
924
  const headers = {};
@@ -657,9 +657,23 @@ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
657
657
  searchParams.push(`%${search}%`, `%${search}%`);
658
658
  }
659
659
 
660
- const totalRow = getDb()
661
- .prepare(`SELECT COUNT(*) AS n FROM jobs WHERE ${where}${searchSql}`)
662
- .get(...targetLocations, ...searchParams);
660
+ // INDEXED BY 强制走精准商家 partial 覆盖索引(候选仅数百行)。无统计信息时
661
+ // 优化器会误选 idx_jobs_redo_target 全量商家回表(127 万行实测 COUNT 2.6s/次,
662
+ // 页面 COUNT+LIST 合计 5~6s)。索引不存在的环境(老库未重启)抛
663
+ // "no such index",自动回退普通查询
664
+ let totalRow;
665
+ try {
666
+ totalRow = getDb()
667
+ .prepare(
668
+ `SELECT COUNT(*) AS n FROM jobs INDEXED BY idx_jobs_precise_merchants WHERE ${where}${searchSql}`,
669
+ )
670
+ .get(...targetLocations, ...searchParams);
671
+ } catch (e) {
672
+ if (!/no such index/i.test(e.message)) throw e;
673
+ totalRow = getDb()
674
+ .prepare(`SELECT COUNT(*) AS n FROM jobs WHERE ${where}${searchSql}`)
675
+ .get(...targetLocations, ...searchParams);
676
+ }
663
677
  const total = totalRow?.n || 0;
664
678
 
665
679
  const selectSql = `
@@ -699,21 +713,47 @@ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
699
713
  ORDER BY product_sold_count DESC, unique_id ASC
700
714
  `;
701
715
 
716
+ // 强制 partial 索引(同 COUNT,注释见上);"FROM jobs" 在 selectSql 中仅出现一次
717
+ const listSql = (indexed) =>
718
+ `${selectSql.replace(
719
+ "FROM jobs",
720
+ indexed ? "FROM jobs INDEXED BY idx_jobs_precise_merchants" : "FROM jobs",
721
+ )}`;
722
+
702
723
  if (limit !== undefined) {
703
724
  const safeLimit = Math.max(Math.min(Math.floor(limit), 1000), 1);
704
725
  const safeOffset = Math.max(Math.floor(offset || 0), 0);
705
- const rows = getDb()
706
- .prepare(`${selectSql} LIMIT ? OFFSET ?`)
707
- .all(...targetLocations, ...searchParams, safeLimit, safeOffset)
708
- .map(mapJobRow);
709
- return { total, limit: safeLimit, offset: safeOffset, users: rows };
726
+ let rows;
727
+ try {
728
+ rows = getDb()
729
+ .prepare(`${listSql(true)} LIMIT ? OFFSET ?`)
730
+ .all(...targetLocations, ...searchParams, safeLimit, safeOffset);
731
+ } catch (e) {
732
+ if (!/no such index/i.test(e.message)) throw e;
733
+ rows = getDb()
734
+ .prepare(`${listSql(false)} LIMIT ? OFFSET ?`)
735
+ .all(...targetLocations, ...searchParams, safeLimit, safeOffset);
736
+ }
737
+ return {
738
+ total,
739
+ limit: safeLimit,
740
+ offset: safeOffset,
741
+ users: rows.map(mapJobRow),
742
+ };
710
743
  }
711
744
 
712
- const rows = getDb()
713
- .prepare(selectSql)
714
- .all(...targetLocations, ...searchParams)
715
- .map(mapJobRow);
716
- return { total, users: rows };
745
+ let allRows;
746
+ try {
747
+ allRows = getDb()
748
+ .prepare(listSql(true))
749
+ .all(...targetLocations, ...searchParams);
750
+ } catch (e) {
751
+ if (!/no such index/i.test(e.message)) throw e;
752
+ allRows = getDb()
753
+ .prepare(listSql(false))
754
+ .all(...targetLocations, ...searchParams);
755
+ }
756
+ return { total, users: allRows.map(mapJobRow) };
717
757
  }
718
758
 
719
759
  /**
@@ -1260,10 +1300,18 @@ export function createStore(filePath, options = {}) {
1260
1300
  return data;
1261
1301
  }
1262
1302
 
1263
- // 空级跳过记忆(claimNextJob 用):key = "level|国家列表",value = 首次全 miss 时间。
1264
- // 某来源级(如 seed)全部国家分支 miss 后,TTL 内跳过该级,避免每次领任务空跑
1303
+ // 空级跳过记忆(claimNextJob 用):key = "level|登录|国家列表",value = 首次全
1304
+ // miss 时间。某来源级(含 pinned)全部分支 miss 后,TTL 内跳过该级,避免每次
1305
+ // 领任务空跑
1265
1306
  const levelEmptyCache = new Map();
1266
1307
 
1308
+ // 任务增删/固定等使空池记忆失效的操作调用(前缀匹配,如 "pinned|")
1309
+ function invalidateClaimEmptyCache(prefix) {
1310
+ for (const key of levelEmptyCache.keys()) {
1311
+ if (key.startsWith(prefix)) levelEmptyCache.delete(key);
1312
+ }
1313
+ }
1314
+
1267
1315
  async function claimNextJob(
1268
1316
  userId,
1269
1317
  expireMs = 5 * 60 * 1000,
@@ -1332,14 +1380,22 @@ export function createStore(filePath, options = {}) {
1332
1380
  : "(status_code IS NULL OR status_code = 0)";
1333
1381
  const notSellerWhere = loggedIn ? "" : "AND COALESCE(tt_seller, 0) != 1";
1334
1382
 
1335
- const locIn = (list, { includeEmpty = true } = {}) => {
1383
+ // 单国等值分支:等值条件命中 idx_tq_claim_fast 国家表达式列,点查毫秒级。
1384
+ // 勿与空位置 OR 合写——OR 会让国家列失效退化为 fallback 全走
1385
+ //(2026-10-09 生产实测 miss 单分支 2.2s)
1386
+ const locIn = (list) => {
1336
1387
  if (!list || list.length === 0) return null;
1337
1388
  const ph = list.map(() => "?").join(", ");
1338
- const sql = includeEmpty
1339
- ? `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`
1340
- : `UPPER(COALESCE(guessed_location, '')) IN (${ph})`;
1341
- return { sql, params: [...list] };
1389
+ return {
1390
+ sql: `UPPER(COALESCE(guessed_location, '')) IN (${ph})`,
1391
+ params: [...list],
1392
+ };
1342
1393
  };
1394
+ // 空位置等值分支:同样走 fast 索引点查(实测 9ms)
1395
+ const locEmpty = () => ({
1396
+ sql: `UPPER(COALESCE(guessed_location, '')) = ''`,
1397
+ params: [],
1398
+ });
1343
1399
  const locNotIn = (list) => {
1344
1400
  if (!list || list.length === 0) return null;
1345
1401
  const ph = list.map(() => "?").join(", ");
@@ -1370,16 +1426,30 @@ export function createStore(filePath, options = {}) {
1370
1426
  branches.push({ conditions, params });
1371
1427
  };
1372
1428
 
1373
- // prio 0: pinned + hasVideo
1374
- addBranch(
1375
- `status = 'pending' AND pinned = 1 AND video_count > 0 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1376
- pinnedLocParams,
1377
- );
1378
- // prio 1: pinned (no video req)
1379
- addBranch(
1380
- `status = 'pending' AND pinned = 1 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1381
- pinnedLocParams,
1382
- );
1429
+ // 空级跳过记忆 TTL:池子空了没必要每次领任务都空跑全量分支
1430
+ //(2026-09-29 实测 seed 空级每次烧 1.4s CPU;2026-10-09 实测 pinned 空池
1431
+ // 每条分支全走 32 万行 2.1s)。TTL 过期后恢复探测一次——补进新任务即可自动恢复。
1432
+ const LEVEL_EMPTY_TTL = 60 * 1000;
1433
+
1434
+ // prio 0/1: pinned + hasVideo / pinned。pinned=1 无可用排序索引(列表索引
1435
+ // 列序不匹配 ORDER BY),池空时每条分支必全走确认 miss 且原本不在空级缓存内,
1436
+ // 每次领取固定烧 ~4.3s → 纳入空池记忆,TTL 内整体跳过;togglePin/resetJob 主动失效
1437
+ const pinnedEmptyKey = `pinned|${loggedIn ? 1 : 0}|${orderedCountries.join(",")}`;
1438
+ const pinnedEmptySince = levelEmptyCache.get(pinnedEmptyKey);
1439
+ const skipPinned =
1440
+ pinnedEmptySince != null && now - pinnedEmptySince < LEVEL_EMPTY_TTL;
1441
+ if (!skipPinned) {
1442
+ // prio 0: pinned + hasVideo
1443
+ addBranch(
1444
+ `status = 'pending' AND pinned = 1 AND video_count > 0 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1445
+ pinnedLocParams,
1446
+ );
1447
+ // prio 1: pinned (no video req)
1448
+ addBranch(
1449
+ `status = 'pending' AND pinned = 1 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1450
+ pinnedLocParams,
1451
+ );
1452
+ }
1383
1453
 
1384
1454
  // prio 2-3: expired candidate(回收的超时任务优先)
1385
1455
  if (expiredCandidate) {
@@ -1414,14 +1484,14 @@ export function createStore(filePath, options = {}) {
1414
1484
  ];
1415
1485
 
1416
1486
  // 空级跳过记忆:某来源级(如 seed)全部国家分支 miss 后,记录时间;
1417
- // TTL 内该级整体跳过(池子空了没必要每次领任务都空跑 24国×2 条查询,
1418
- // 2026-09-29 实测 seed 空级每次烧 1.4s CPU 是 watch 饱和主因)。
1419
- // TTL 过期后恢复探测一次——refill 补进新任务即可自动恢复。
1420
- const LEVEL_EMPTY_TTL = 60 * 1000;
1487
+ // TTL 内该级整体跳过。TTL 过期后恢复探测一次——refill 补进新任务即可自动恢复。
1488
+ // key 含登录态:未登录(过滤更严,无 209002/商家)miss 不代表登录端 miss,
1489
+ // 避免未登录轮污染登录端记忆
1490
+ const levelKeyOf = (name) =>
1491
+ `${name}|${loggedIn ? 1 : 0}|${orderedCountries.join(",")}`;
1421
1492
  for (const p of priorities) {
1422
1493
  if (p.loggedInOnly && !loggedIn) continue;
1423
- const levelKey = `${p.name}|${orderedCountries.join(",")}`;
1424
- const emptySince = levelEmptyCache.get(levelKey);
1494
+ const emptySince = levelEmptyCache.get(levelKeyOf(p.name));
1425
1495
  if (emptySince && now - emptySince < LEVEL_EMPTY_TTL) {
1426
1496
  p.skip = true; // 标记跳过,下面构建分支时跳过
1427
1497
  }
@@ -1431,12 +1501,15 @@ export function createStore(filePath, options = {}) {
1431
1501
  for (const p of priorities) {
1432
1502
  if (p.loggedInOnly && !loggedIn) continue;
1433
1503
  if (p.skip) continue;
1434
- // 逐国顺序分支:配置内国家按优先级顺序;未传子集时末尾补“配置外国家”兑底。
1435
- // 空位置(loc='')只在首个国家的分支参与:首国的 noVideoReq 分支 miss 即意味着
1436
- // 空位置池已耗尽,后续分支无需再 OR ''(避免反复扫描空位置大池)
1437
- const locationBranches = orderedCountries.map((loc, idx) =>
1438
- locIn([loc], { includeEmpty: idx === 0 }),
1439
- );
1504
+ // 逐国顺序分支:配置内国家按优先级顺序;未传子集时末尾补“配置外国家”兜底。
1505
+ // 空位置(loc='')拆为独立等值分支、只挂首国:原 OR 合写毁掉 fast 索引国家列
1506
+ // 退化为 fallback 全走(2026-10-09 实测 miss 2.2s/次),拆分后 IN 3ms / ='' 9ms。
1507
+ // 同层内首国等值严格优先于空位置(原为并集按粉丝竞争,属行为微调已确认可接受)
1508
+ const locationBranches = [];
1509
+ orderedCountries.forEach((loc, idx) => {
1510
+ locationBranches.push(locIn([loc]));
1511
+ if (idx === 0) locationBranches.push(locEmpty());
1512
+ });
1440
1513
  if (normalizedLocations.length === 0)
1441
1514
  locationBranches.push(locNotIn(orderedCountries));
1442
1515
  // 商家级专用(sellerAnyCountry):国家对商家放开——末尾追加"任意国家"分支。
@@ -1444,6 +1517,9 @@ export function createStore(filePath, options = {}) {
1444
1517
  // 未传国家时同理(配置内各国 → 配置外 → 任意)。其他来源级分支不受影响。
1445
1518
  if (p.sellerAnyCountry) locationBranches.push(null); // null → 任意国家分支
1446
1519
  p._branchStart = branches.length; // 记录该级分支区间(用于判空)
1520
+ // 每个位置描述符产出 hasVideo + noVideoReq 两条分支(国家层为主序、
1521
+ // 是否要求视频为层内次序,与原版一致):首国等值 → 首国空位置 →
1522
+ // 其余配置内国家 → 配置外兜底 → 商家任意国家
1447
1523
  let subPrio = 0;
1448
1524
  for (const loc of locationBranches) {
1449
1525
  // 商家任意国家分支:不限 guessed_location(含空位置的商家)
@@ -1499,10 +1575,15 @@ export function createStore(filePath, options = {}) {
1499
1575
  // 空级写入 levelEmptyCache(TTL 内下次领取直接跳过),命中级清除记忆
1500
1576
  // bi = 命中分支下标(未命中则停在 branches.length)
1501
1577
  const hitIdx = bi;
1578
+ // pinned 空池记忆:仅当 pinned 分支本轮真实执行时更新(跳过轮保留原记忆)
1579
+ if (!skipPinned) {
1580
+ if (hitIdx >= 2) levelEmptyCache.set(pinnedEmptyKey, now);
1581
+ else levelEmptyCache.delete(pinnedEmptyKey);
1582
+ }
1502
1583
  for (const p of priorities) {
1503
1584
  if (p.loggedInOnly && !loggedIn) continue;
1504
1585
  if (p._branchStart == null) continue; // 被 skip 跳过的级无分支,保留原记忆
1505
- const levelKey = `${p.name}|${orderedCountries.join(",")}`;
1586
+ const levelKey = levelKeyOf(p.name);
1506
1587
  if (hitIdx >= p._branchEnd) levelEmptyCache.set(levelKey, now);
1507
1588
  else levelEmptyCache.delete(levelKey);
1508
1589
  }
@@ -2292,6 +2373,7 @@ export function createStore(filePath, options = {}) {
2292
2373
  .run(uniqueId);
2293
2374
  }
2294
2375
  markStatsDirty();
2376
+ invalidateClaimEmptyCache("pinned|");
2295
2377
  return { saved: true };
2296
2378
  }
2297
2379
 
@@ -2323,6 +2405,7 @@ export function createStore(filePath, options = {}) {
2323
2405
  tableName,
2324
2406
  );
2325
2407
  if (ret.error) return { saved: false, error: ret.error };
2408
+ invalidateClaimEmptyCache("pinned|");
2326
2409
  return { saved: true, pinned: nextPinned };
2327
2410
  }
2328
2411
 
@@ -84,44 +84,25 @@ const STAT_GROUPS = {
84
84
  FROM task_queue`,
85
85
  },
86
86
  {
87
- key: "jobsAgg",
87
+ // 目标商家计数(仪表盘"目标商家"卡片 + 烟花动画的数据源):
88
+ // tt_seller+verified+location 三列条件完全被 idx_jobs_seller_verified_loc
89
+ // 覆盖,纯索引扫描无回表(127 万行实测 140ms),30s 级刷新无压力。
90
+ // 原随 jobsAgg 在 slow 组(5 分钟 TTL)导致烟花延迟数分钟
91
+ key: "targetUsersCount",
88
92
  mode: "one",
89
93
  params: () => getTargetLocations(),
90
- sql: () => `SELECT
91
- SUM(jobsTotal) as jobsTotal,
92
- SUM(done) as done,
93
- SUM(error) as error,
94
- SUM(restricted) as restricted,
95
- SUM(targetUsers) as targetUsers,
96
- SUM(noVideo) as noVideo
97
- FROM (
98
- SELECT
99
- COUNT(*) as jobsTotal,
100
- SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
101
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
102
- SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
103
- SUM(CASE WHEN tt_seller = 1 AND verified = 0 AND ${targetInSql()} THEN 1 ELSE 0 END) as targetUsers,
104
- SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
105
- FROM jobs
106
- UNION ALL
107
- -- jobs_done 冷归档表:全部是 done 且只增不改。改用覆盖索引/部分索引计数,
108
- -- 避免对 147 万行全表回表(原 SUM(CASE WHEN no_video=1) 无法匹配索引)
109
- SELECT
110
- c.cnt as jobsTotal,
111
- c.cnt as done,
112
- 0 as error,
113
- 0 as restricted,
114
- 0 as targetUsers,
115
- (SELECT COUNT(*) FROM jobs_done WHERE no_video = 1) as noVideo
116
- FROM (SELECT COUNT(*) as cnt FROM jobs_done) c
117
- )`,
94
+ sql: () => `SELECT COUNT(*) AS n FROM jobs
95
+ WHERE tt_seller = 1 AND verified = 0 AND ${targetInSql()}`,
118
96
  },
119
97
  {
120
98
  // 精确商家计数:目标商家 + 7 天 EC 视频播放>1000 + 有商品 + 评论0 + 销量>10
99
+ // INDEXED BY 强制走 idx_jobs_precise_merchants partial 覆盖索引(候选仅数
100
+ // 百行,毫秒级):曾因 12.1s/次迁入 slow 组(5 分钟 TTL)导致卡片 5 分钟
101
+ // 才刷一次,索引修复后移回 fast 组恢复 30s 级。索引缺失时置空降级为 0
121
102
  key: "preciseMerchants",
122
103
  mode: "one",
123
104
  params: () => getTargetLocations(),
124
- sql: () => `SELECT COUNT(*) AS n FROM jobs
105
+ sql: () => `SELECT COUNT(*) AS n FROM jobs INDEXED BY idx_jobs_precise_merchants
125
106
  WHERE tt_seller = 1 AND verified = 0 AND ${targetInSql()}
126
107
  AND top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
127
108
  AND product_review_count = 0
@@ -132,10 +113,40 @@ const STAT_GROUPS = {
132
113
  WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000
133
114
  )`,
134
115
  },
116
+ {
117
+ // 已完成/限制/错误计数(仪表盘三个状态卡片):走部分索引覆盖计数,
118
+ // 索引只含对应状态的行(idx_jobs_list_done/error/restricted,
119
+ // WHERE status='xxx' 与查询条件完全匹配),127 万行毫秒级。
120
+ // 原随 jobsAgg 在 slow 组(5 分钟 TTL),2026-10-09 起错误卡片 5 分钟
121
+ // 才刷一次;拆出后恢复 30s 级。索引缺失查询失败时回退 slow 组 jobsAgg
122
+ key: "doneCount",
123
+ mode: "one",
124
+ sql: `SELECT (SELECT COUNT(*) FROM jobs INDEXED BY idx_jobs_list_done WHERE status = 'done')
125
+ + (SELECT COUNT(*) FROM jobs_done) AS n`,
126
+ },
127
+ {
128
+ key: "restrictedCount",
129
+ mode: "one",
130
+ sql: "SELECT COUNT(*) AS n FROM jobs INDEXED BY idx_jobs_list_restricted WHERE status = 'restricted'",
131
+ },
132
+ {
133
+ key: "errorCount",
134
+ mode: "one",
135
+ sql: "SELECT COUNT(*) AS n FROM jobs INDEXED BY idx_jobs_list_error WHERE status = 'error'",
136
+ },
135
137
  ],
136
138
  combine(results) {
137
139
  const tq = results.tqAgg || {};
138
- const jb = results.jobsAgg || {};
140
+ // jobs 域轻量计数 done/error/restricted 已拆出为 fast 组独立计数
141
+ // (部分索引覆盖计数,毫秒级,30s 级刷新);jobsAgg 仍在 slow 组提供
142
+ // jobsTotal(总用户)/noVideo,并作为拆出计数查询失败时的回退源。
143
+ // 未就绪时回退 0。
144
+ const slow = groupState.slow?.cache || {};
145
+ const jb = slow.jobsAgg || {};
146
+ const done = results.doneCount?.n != null ? results.doneCount.n : jb.done || 0;
147
+ const restricted =
148
+ results.restrictedCount?.n != null ? results.restrictedCount.n : jb.restricted || 0;
149
+ const error = results.errorCount?.n != null ? results.errorCount.n : jb.error || 0;
139
150
  const total = (tq.tqTotal || 0) + (jb.jobsTotal || 0);
140
151
  return {
141
152
  totalUsers: total,
@@ -143,10 +154,10 @@ const STAT_GROUPS = {
143
154
  jobsPending: tq.pending || 0,
144
155
  pendingUsers: tq.pending || 0,
145
156
  processingUsers: tq.processing || 0,
146
- processedUsers: jb.done || 0,
147
- restrictedUsers: jb.restricted || 0,
148
- errorUsers: jb.error || 0,
149
- targetUsers: jb.targetUsers || 0,
157
+ processedUsers: done,
158
+ restrictedUsers: restricted,
159
+ errorUsers: error,
160
+ targetUsers: results.targetUsersCount?.n || 0,
150
161
  preciseMerchants: results.preciseMerchants?.n || 0,
151
162
  sourceStats: {
152
163
  seed: tq.seed || 0,
@@ -155,9 +166,9 @@ const STAT_GROUPS = {
155
166
  guess: tq.guess || 0,
156
167
  following: tq.following || 0,
157
168
  follower: tq.follower || 0,
158
- processed: jb.done || 0,
159
- restricted: jb.restricted || 0,
160
- error: jb.error || 0,
169
+ processed: done,
170
+ restricted: restricted,
171
+ error: error,
161
172
  noVideo: jb.noVideo || 0,
162
173
  },
163
174
  };
@@ -222,12 +233,50 @@ const STAT_GROUPS = {
222
233
  slow: {
223
234
  ttl: SLOW_STATS_TTL,
224
235
  // rawJobs 走 store.getRawJobsCount()(模块内部自带 60s 缓存),不进分片 SQL
236
+ // jobsAgg 自 fast 组迁入:jobs 表百万行级后单次 ~3.3s,只适合 5 分钟级刷新
237
+ // (fast 组 combine 从本组缓存读取)。targetUsers/preciseMerchants 已拆出
238
+ // 走覆盖索引回 fast 组(详见 fast 组注释);done/error/restricted 也已拆出
239
+ // 为部分索引轻量计数(详见 fast 组注释),jobsAgg 保留全量聚合作为回退源,
240
+ // 并继续提供 jobsTotal(总用户)/noVideo(jobs 侧无匹配索引,保持 5 分钟级)
225
241
  sqls: [
226
242
  {
227
243
  key: "commentTaskCount",
228
244
  mode: "one",
229
245
  sql: "SELECT COUNT(*) as c FROM videos WHERE user_update_count IS NULL OR user_update_count <= 0",
230
246
  },
247
+ {
248
+ key: "jobsAgg",
249
+ mode: "one",
250
+ params: () => getTargetLocations(),
251
+ sql: () => `SELECT
252
+ SUM(jobsTotal) as jobsTotal,
253
+ SUM(done) as done,
254
+ SUM(error) as error,
255
+ SUM(restricted) as restricted,
256
+ SUM(targetUsers) as targetUsers,
257
+ SUM(noVideo) as noVideo
258
+ FROM (
259
+ SELECT
260
+ COUNT(*) as jobsTotal,
261
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
262
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
263
+ SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
264
+ SUM(CASE WHEN tt_seller = 1 AND verified = 0 AND ${targetInSql()} THEN 1 ELSE 0 END) as targetUsers,
265
+ SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
266
+ FROM jobs
267
+ UNION ALL
268
+ -- jobs_done 冷归档表:全部是 done 且只增不改。改用覆盖索引/部分索引计数,
269
+ -- 避免对 147 万行全表回表(原 SUM(CASE WHEN no_video=1) 无法匹配索引)
270
+ SELECT
271
+ c.cnt as jobsTotal,
272
+ c.cnt as done,
273
+ 0 as error,
274
+ 0 as restricted,
275
+ 0 as targetUsers,
276
+ (SELECT COUNT(*) FROM jobs_done WHERE no_video = 1) as noVideo
277
+ FROM (SELECT COUNT(*) as cnt FROM jobs_done) c
278
+ )`,
279
+ },
231
280
  ],
232
281
  combine(results, st) {
233
282
  // tagCount/tagHighScore 从独立 tags.db 读取(跨库)
@@ -249,6 +298,15 @@ const STAT_GROUPS = {
249
298
  return {
250
299
  rawJobs: st.getRawJobsCount ? st.getRawJobsCount() : 0,
251
300
  commentTaskCount: results.commentTaskCount?.c || 0,
301
+ // jobs 域聚合(供 fast 组 combine 读取,字段保持展开后的形态)
302
+ jobsAgg: {
303
+ jobsTotal: results.jobsAgg?.jobsTotal || 0,
304
+ done: results.jobsAgg?.done || 0,
305
+ error: results.jobsAgg?.error || 0,
306
+ restricted: results.jobsAgg?.restricted || 0,
307
+ targetUsers: results.jobsAgg?.targetUsers || 0,
308
+ noVideo: results.jobsAgg?.noVideo || 0,
309
+ },
252
310
  tagCount,
253
311
  tagHighScore,
254
312
  };
@@ -595,6 +653,12 @@ export function startWatchServer(
595
653
  // precheck 候选查询部分索引:预缩候选集且天然按 refresh_time 有序,
596
654
  // 消除 TEMP B-TREE 排序(76 万行实测 ~1s → 毫秒级)
597
655
  "CREATE INDEX IF NOT EXISTS idx_jobs_precheck ON jobs(refresh_time) WHERE tt_seller = 1 AND verified = 0 AND (pending_refresh IS NULL OR pending_refresh = 0)",
656
+ // 精准商家(目标商家页 + stats 计数)部分覆盖索引:WHERE 收录查询全部
657
+ // 便宜过滤条件,候选集从 jobs 全量商家(2026-10 已 127 万行)预缩到数百行,
658
+ // COUNT/LIST 由 127 万次回表(实测 COUNT 2.6s)降到毫秒级。
659
+ // ⚠️ WHERE 文本必须与 getPreciseMerchantsFromDb / stats preciseMerchants
660
+ // 查询逐词一致(SQLite 按术语文本匹配 partial index),改查询需同步改这里
661
+ "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 != ''",
598
662
  ];
599
663
  for (const sql of ensureIdx) {
600
664
  try {