tt-help-cli-ycl 1.4.169 → 1.4.172

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-subscription-allocator.mjs +220 -0
  3. package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
  4. package/scripts/test-subscription-auto-alloc.mjs +259 -0
  5. package/scripts/test-subscription-center-e2e.mjs +134 -0
  6. package/scripts/test-subscription-center-smoke.mjs +154 -0
  7. package/scripts/test-subscription-center-ui.mjs +111 -0
  8. package/scripts/vnc-login.sh +68 -0
  9. package/src/cli/info.js +6 -1
  10. package/src/lib/browser/cdp.js +7 -1
  11. package/src/watch/public/app.js +713 -39
  12. package/src/watch/public/index.html +74 -3
  13. package/src/watch/subscription-allocator.js +255 -0
  14. package/src/watch/subscription-center.js +590 -0
  15. package/src/watch/watchdog-server.js +210 -0
  16. package/src/watchdog/agent.js +104 -10
  17. package/scripts/test-api-interceptor-429.mjs +0 -122
  18. package/scripts/test-api-interceptor-captcha.mjs +0 -176
  19. package/scripts/test-claim-fix.mjs +0 -216
  20. package/scripts/test-clash-discover.mjs +0 -126
  21. package/scripts/test-clash-hysteresis.mjs +0 -131
  22. package/scripts/test-clash-multichannel.mjs +0 -120
  23. package/scripts/test-client-error-monitor.mjs +0 -261
  24. package/scripts/test-country-fix.mjs +0 -341
  25. package/scripts/test-daemon-failover.mjs +0 -301
  26. package/scripts/test-daemon-switch-history.mjs +0 -141
  27. package/scripts/test-daemon-whitelist.mjs +0 -308
  28. package/scripts/test-explore-captcha-wait.mjs +0 -170
  29. package/scripts/test-photo-post.mjs +0 -156
  30. package/scripts/test-precheck-perf.mjs +0 -270
  31. package/scripts/test-precise-index.mjs +0 -114
  32. package/scripts/test-probe-live.mjs +0 -83
  33. package/scripts/test-probe.mjs +0 -315
  34. package/scripts/test-product-fill-cdp.mjs +0 -336
  35. package/scripts/test-product-fill-db.mjs +0 -216
  36. package/scripts/test-product-fill-http.mjs +0 -117
  37. package/scripts/test-proxy-switch-restart.mjs +0 -198
  38. package/scripts/test-refill-order.mjs +0 -218
  39. package/scripts/test-sample-all-fail.mjs +0 -142
  40. package/scripts/test-seller-any-country.mjs +0 -167
  41. package/scripts/test-stats-smoke.mjs +0 -43
  42. package/scripts/test-upgrade-spawn.mjs +0 -179
  43. package/scripts/test-webserver-error-classify.mjs +0 -409
  44. package/scripts/test-wmi-free-upgrade.mjs +0 -98
  45. package/src/cli/test-real-attach.js +0 -0
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -1,156 +0,0 @@
1
- /**
2
- * 图文帖(photo post)标记与国家采样过滤测试
3
- *
4
- * 覆盖:
5
- * 1. buildVideoObject:imagePost 标记 / duration=0 兜底 / href 路径
6
- * 2. 采样过滤逻辑:图文不占采样位,向后补位真视频
7
- * 3. EC 筛选不受影响:图文 EC 帖照常参与
8
- *
9
- * 运行: node scripts/test-photo-post.mjs
10
- */
11
- import assert from "node:assert";
12
-
13
- let passed = 0;
14
- function check(name, fn) {
15
- try {
16
- fn();
17
- passed++;
18
- console.log(` ✅ ${name}`);
19
- } catch (e) {
20
- console.error(` ❌ ${name}\n ${e.message}`);
21
- process.exitCode = 1;
22
- }
23
- }
24
-
25
- // buildVideoObject 未导出,从模块内部行为验证:
26
- // 用真实 itemList item 结构构造,直接验证导出的 fetchUserVideosAPI 太重(需浏览器),
27
- // 所以这里用最小复制版判定逻辑 + 读源码断言导出行为一致性。
28
- // 更直接的办法:将判定逻辑抽出来测。当前先用源码 grep + 等价实现双验证。
29
-
30
- console.log("===== 图文帖(photo post)处理测试 =====");
31
-
32
- console.log("\n[1] 判定逻辑等价实现验证(imagePost / duration 兜底)");
33
- // 与 buildVideoObject 完全一致的判定
34
- const isPhotoOf = (item) =>
35
- item.imagePost !== undefined || item.video?.duration === 0;
36
-
37
- const VIDEO_ITEM = {
38
- id: "7684619445913865504",
39
- video: { duration: 66, PlayAddrStruct: {}, codecType: "h264" },
40
- stats: { playCount: 77400 },
41
- };
42
- const PHOTO_ITEM = {
43
- id: "7686098281570569504",
44
- imagePost: { cover: {}, images: [], shareCover: {}, title: "t" },
45
- video: { duration: 0 },
46
- stats: { playCount: 23000 },
47
- };
48
- const PHOTO_NO_FIELD_ITEM = {
49
- // 极端兜底:imagePost 偶发缺失,仅 duration=0
50
- id: "111",
51
- video: { duration: 0 },
52
- };
53
-
54
- check("正常视频 → 不是图文", () =>
55
- assert.strictEqual(isPhotoOf(VIDEO_ITEM), false),
56
- );
57
- check("有 imagePost → 是图文", () =>
58
- assert.strictEqual(isPhotoOf(PHOTO_ITEM), true),
59
- );
60
- check("imagePost 缺失但 duration=0 → 兜底判为图文", () =>
61
- assert.strictEqual(isPhotoOf(PHOTO_NO_FIELD_ITEM), true),
62
- );
63
- check("无 video 字段(老结构)→ 不是图文", () =>
64
- assert.strictEqual(isPhotoOf({ id: "x" }), false),
65
- );
66
-
67
- console.log("\n[2] 采样过滤逻辑(explore-core 的 filter+slice 等价行为)");
68
- // 模拟混合列表:前 5 个里 2 个图文,过滤后应取到第 6、7 个真视频
69
- const mixed = [
70
- { id: "v1", isPhoto: 0 },
71
- { id: "p1", isPhoto: 1 },
72
- { id: "v2", isPhoto: 0 },
73
- { id: "p2", isPhoto: 1 },
74
- { id: "v3", isPhoto: 0 },
75
- { id: "v4", isPhoto: 0 }, // 应补位进来
76
- { id: "v5", isPhoto: 0 }, // 应补位进来
77
- { id: "v6", isPhoto: 0 }, // 采样 5 个,到此截止
78
- ];
79
- const sampled = mixed.filter((v) => !v.isPhoto).slice(0, 5);
80
- check("图文不占采样位,向后补位真视频", () =>
81
- assert.deepStrictEqual(
82
- sampled.map((v) => v.id),
83
- ["v1", "v2", "v3", "v4", "v5"],
84
- ),
85
- );
86
-
87
- // 纯图文用户:采样 0 条 → 不发起国家查询 → 自然丢弃
88
- check("纯图文用户 → 采样 0 条", () =>
89
- assert.strictEqual(
90
- [{ id: "p1", isPhoto: 1 }].filter((v) => !v.isPhoto).length,
91
- 0,
92
- ),
93
- );
94
-
95
- console.log("\n[3] EC 筛选不受影响(图文 EC 帖照常保留)");
96
- // explore-core 的 EC 筛选条件不含 isPhoto:
97
- // v.isECVideo === 1 && 7天内 —— 图文 EC 帖(isECVideo=1 的 photo)应通过
98
- const ecItems = [
99
- {
100
- id: "pv1",
101
- isECVideo: 1,
102
- isPhoto: 1,
103
- createTime: 1758000000,
104
- playCount: 900,
105
- },
106
- {
107
- id: "vv1",
108
- isECVideo: 1,
109
- isPhoto: 0,
110
- createTime: 1758000000,
111
- playCount: 500,
112
- },
113
- ];
114
- const nowSec = 1758100000;
115
- const recent = ecItems.filter(
116
- (v) =>
117
- v.isECVideo === 1 && v.createTime && nowSec - v.createTime <= 7 * 86400,
118
- );
119
- check("图文 EC 帖仍在 recentEcVideos 候选中", () =>
120
- assert.ok(recent.some((v) => v.id === "pv1")),
121
- );
122
-
123
- console.log("\n[4] 源码一致性断言(防止后续改动漂移)");
124
- const { readFileSync } = await import("node:fs");
125
- const interceptorSrc = readFileSync(
126
- new URL("../src/lib/api-interceptor.js", import.meta.url),
127
- "utf-8",
128
- );
129
- const exploreSrc = readFileSync(
130
- new URL("../src/scraper/explore-core.js", import.meta.url),
131
- "utf-8",
132
- );
133
- check("buildVideoObject 判定:imagePost + duration 双保险", () =>
134
- assert.ok(
135
- interceptorSrc.includes(
136
- "item.imagePost !== undefined || item.video?.duration === 0",
137
- ),
138
- ),
139
- );
140
- check("buildVideoObject 输出 isPhoto 字段", () =>
141
- assert.ok(interceptorSrc.includes("isPhoto: isPhoto ? 1 : 0")),
142
- );
143
- check("href 按类型生成 /photo/ 或 /video/", () =>
144
- assert.ok(
145
- interceptorSrc.includes('${isPhoto ? "photo" : "video"}/${item.id}'),
146
- ),
147
- );
148
- check("explore-core 采样前过滤图文", () =>
149
- assert.ok(
150
- exploreSrc.includes("videoArray\n .filter((v) => !v.isPhoto)"),
151
- ),
152
- );
153
-
154
- console.log(
155
- `\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
156
- );
@@ -1,270 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 回归测试:getPrecheckCandidates 查询改写(2026-09-18)
4
- *
5
- * 背景:原 SQL 的 WHERE/ORDER BY 用 COALESCE(refresh_time, ?) 表达式,
6
- * 挡住索引第 4 列 + 触发 TEMP B-TREE 全排序(76 万行 ~1s)。
7
- * 改写为 (refresh_time IS NULL OR refresh_time < ?) + ORDER BY refresh_time。
8
- *
9
- * 验证点:
10
- * 1. 语义等价:新旧 SQL 返回相同的候选集与顺序(NULL 排最前 = 视作 2016 默认值)
11
- * 2. 索引生效:建 idx_jobs_precheck 后查询计划不再出现 TEMP B-TREE FOR ORDER BY
12
- * 3. 走真实代码路径:createStore(db).getPrecheckCandidates() 返回结构正确
13
- * (uniqueId/nickname/videoCount/refreshTime + 领取后 refresh_time 被置为 now)
14
- */
15
-
16
- import Database from "better-sqlite3";
17
- import path from "path";
18
- import fs from "fs";
19
- import { fileURLToPath } from "url";
20
-
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
- const dbPath = path.join(__dirname, "../test-precheck-perf.db");
23
-
24
- // ── 准备库 ──
25
- if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
26
- const db = new Database(dbPath);
27
-
28
- db.exec(`
29
- CREATE TABLE jobs (
30
- unique_id TEXT PRIMARY KEY,
31
- nickname TEXT,
32
- status TEXT DEFAULT 'pending',
33
- video_count INTEGER,
34
- tt_seller INTEGER DEFAULT 0,
35
- verified INTEGER DEFAULT 0,
36
- location_created TEXT,
37
- pending_refresh INTEGER,
38
- refresh_time INTEGER,
39
- pinned INTEGER DEFAULT 0,
40
- guessed_location TEXT,
41
- sources TEXT,
42
- follower_count INTEGER DEFAULT 0,
43
- claimed_at INTEGER,
44
- claimed_by TEXT,
45
- processed_at INTEGER,
46
- user_update_count INTEGER,
47
- no_video INTEGER,
48
- sec_uid TEXT,
49
- top_video_seo_url TEXT,
50
- product_name TEXT,
51
- product_rating REAL,
52
- product_sold_count INTEGER,
53
- created_at INTEGER,
54
- updated_at INTEGER
55
- );
56
- `);
57
-
58
- const NOW = Date.now();
59
- const DAY = 86400000;
60
- const OLD = 1452000000000; // 2016-01-05,模拟历史旧值(大于代码 defaultTime)
61
- // 代码内 defaultTime = new Date("2016-01-01T00:00:00Z"),NULL refresh_time 折算成它
62
- const DEFAULT_TIME = new Date("2016-01-01T00:00:00Z").getTime();
63
-
64
- // 覆盖各种边界:NULL refresh_time、极旧、刚刷新、目标国/非目标国、
65
- // pending_refresh 0/1/NULL、非商家、已验证
66
- const rows = [
67
- ["u_null_de", "DE", null, 0, 1, 0], // NULL refresh_time → 候选(最优先)
68
- ["u_old_pl", "PL", OLD + 1000, 0, 1, 0], // 2016 年代值 → 候选
69
- ["u_2d_fr", "FR", NOW - 2 * DAY, 0, 1, 0], // 2 天前 → 候选(maxAge=1d 时)
70
- ["u_5h_es", "ES", NOW - 5 * 3600 * 1000, 0, 1, 0], // 5h 前 → 非候选(maxAge=1d)
71
- ["u_pr1_it", "IT", NOW - 2 * DAY, 1, 1, 0], // pending_refresh=1 → 非候选
72
- ["u_prnull_be", "BE", NOW - 2 * DAY, null, 1, 0], // pending_refresh NULL → 候选
73
- ["u_nonseller", "DE", NOW - 2 * DAY, 0, 0, 0], // 非商家 → 非候选
74
- ["u_verified", "DE", NOW - 2 * DAY, 0, 1, 1], // 已验证 → 非候选
75
- ["u_us", "US", NOW - 3 * DAY, 0, 1, 0], // US 目标国 → 候选
76
- ["u_cn", "CN", NOW - 3 * DAY, 0, 1, 0], // 非目标国 → 非候选
77
- ];
78
- const insert = db.prepare(`
79
- INSERT INTO jobs (unique_id, nickname, status, video_count, tt_seller, verified,
80
- location_created, pending_refresh, refresh_time, created_at, updated_at)
81
- VALUES (?, ?, 'done', 10, ?, ?, ?, ?, ?, ?, ?)
82
- `);
83
- for (const [uid, loc, rt, pr, seller, verified] of rows) {
84
- insert.run(uid, uid, seller, verified, loc, pr, rt, NOW, NOW);
85
- }
86
-
87
- const TARGETS = [
88
- "PL",
89
- "NL",
90
- "BE",
91
- "DE",
92
- "FR",
93
- "IT",
94
- "IE",
95
- "ES",
96
- "CZ",
97
- "GR",
98
- "HU",
99
- "PT",
100
- "AT",
101
- "TH",
102
- "US",
103
- "MY",
104
- "VN",
105
- ];
106
- const placeholders = TARGETS.map(() => "?").join(",");
107
-
108
- // ── 1) 语义等价:新旧 SQL 逐行对比(含顺序) ──
109
- function runOld(maxAgeSec) {
110
- const threshold = NOW - maxAgeSec * 1000;
111
- return db
112
- .prepare(
113
- `SELECT unique_id, COALESCE(refresh_time, ?) AS rt FROM jobs
114
- WHERE tt_seller = 1 AND verified = 0
115
- AND (pending_refresh IS NULL OR pending_refresh = 0)
116
- AND location_created IN (${placeholders})
117
- AND COALESCE(refresh_time, ?) < ?
118
- ORDER BY COALESCE(refresh_time, ?) ASC`,
119
- )
120
- .all(OLD, ...TARGETS, OLD, threshold, OLD)
121
- .map((r) => `${r.unique_id}@${r.rt}`);
122
- }
123
- function runNew(maxAgeSec) {
124
- const threshold = NOW - maxAgeSec * 1000;
125
- return db
126
- .prepare(
127
- `SELECT unique_id, COALESCE(refresh_time, ?) AS rt FROM jobs
128
- WHERE tt_seller = 1 AND verified = 0
129
- AND (pending_refresh IS NULL OR pending_refresh = 0)
130
- AND location_created IN (${placeholders})
131
- AND (refresh_time IS NULL OR refresh_time < ?)
132
- ORDER BY refresh_time ASC`,
133
- )
134
- .all(OLD, ...TARGETS, threshold)
135
- .map((r) => `${r.unique_id}@${r.rt}`);
136
- }
137
-
138
- let failed = 0;
139
- const check = (name, cond, detail) => {
140
- if (cond) {
141
- console.error(`✓ ${name}`);
142
- } else {
143
- failed++;
144
- console.error(`✗ ${name} ${detail ?? ""}`);
145
- }
146
- };
147
-
148
- for (const maxAge of [86400, 43200]) {
149
- const a = runOld(maxAge);
150
- const b = runNew(maxAge);
151
- check(
152
- `新旧 SQL 结果一致 (maxAge=${maxAge}s)`,
153
- JSON.stringify(a) === JSON.stringify(b),
154
- `\n old=${JSON.stringify(a)}\n new=${JSON.stringify(b)}`,
155
- );
156
- }
157
- // 顺带验证期望集合(maxAge=1d:NULL 和旧值排最前,非候选被排除)
158
- const expect1d = [
159
- "u_null_de@1452000000000",
160
- "u_old_pl@1452000001000",
161
- "u_2d_fr",
162
- "u_prnull_be",
163
- "u_us",
164
- ]
165
- .map((s) => (s.includes("@") ? s : null))
166
- .filter(Boolean);
167
- const got1d = runNew(86400);
168
- check(
169
- "新 SQL 命中预期候选集",
170
- got1d.slice(0, 2).join() === expect1d.slice(0, 2).join() &&
171
- got1d.length === 5 &&
172
- got1d.includes("u_prnull_be@" + (NOW - 2 * DAY)),
173
- JSON.stringify(got1d),
174
- );
175
-
176
- // ── 2) 索引生效:建部分索引后无 TEMP B-TREE ──
177
- db.exec(
178
- `CREATE INDEX idx_jobs_precheck ON jobs(refresh_time)
179
- WHERE tt_seller = 1 AND verified = 0 AND (pending_refresh IS NULL OR pending_refresh = 0)`,
180
- );
181
- const plan = db
182
- .prepare(
183
- `EXPLAIN QUERY PLAN SELECT unique_id FROM jobs
184
- WHERE tt_seller = 1 AND verified = 0
185
- AND (pending_refresh IS NULL OR pending_refresh = 0)
186
- AND location_created IN (${placeholders})
187
- AND (refresh_time IS NULL OR refresh_time < ?)
188
- ORDER BY refresh_time ASC LIMIT 20`,
189
- )
190
- .all(...TARGETS, NOW - 86400000)
191
- .map((r) => r.detail)
192
- .join(" | ");
193
- check(
194
- "查询计划走 idx_jobs_precheck 且无 TEMP B-TREE 排序",
195
- plan.includes("idx_jobs_precheck") && !plan.includes("TEMP B-TREE"),
196
- plan,
197
- );
198
-
199
- // ── 3) 走真实代码路径 createStore().getPrecheckCandidates ──
200
- process.env.TT_HELP_DB_PATH = dbPath; // 防御性标记,createStore 用显式路径
201
- const { createStore } = await import("../src/watch/data-store.js");
202
- process.chdir(path.join(__dirname, "..")); // app-config 读 cwd 相对配置
203
- const store = createStore(dbPath);
204
- const candidates = store.getPrecheckCandidates({
205
- limit: 3,
206
- intervalSeconds: 86400,
207
- locations: null,
208
- });
209
-
210
- check("返回数量 ≤ limit", candidates.length === 3, `got ${candidates.length}`);
211
- check(
212
- "字段结构正确(uniqueId/nickname/videoCount/refreshTime)",
213
- candidates.every(
214
- (c) =>
215
- typeof c.uniqueId === "string" &&
216
- typeof c.videoCount === "number" &&
217
- (c.refreshTime === OLD || typeof c.refreshTime === "number"),
218
- ),
219
- JSON.stringify(candidates[0]),
220
- );
221
- check(
222
- "顺序 = refreshTime 升序(NULL 折算默认值排最前)",
223
- candidates[0].uniqueId === "u_null_de" &&
224
- candidates[0].refreshTime === DEFAULT_TIME &&
225
- candidates[1].uniqueId === "u_old_pl" &&
226
- candidates[2].uniqueId === "u_us", // 3天前 < 2天前,升序在前
227
- JSON.stringify(candidates.map((c) => c.uniqueId)),
228
- );
229
- // 领取即锁定:选中的行 refresh_time 应已更新为 now(防重复领取)
230
- const after = db
231
- .prepare(
232
- `SELECT unique_id, refresh_time FROM jobs WHERE unique_id IN (${candidates.map(() => "?").join(",")})`,
233
- )
234
- .all(...candidates.map((c) => c.uniqueId));
235
- check(
236
- "领取后 refresh_time 已置为当前时间(防重复领取语义保留)",
237
- after.every((r) => r.refresh_time !== null && r.refresh_time >= NOW),
238
- JSON.stringify(after),
239
- );
240
- // 锁定后再次领取不应重复返回这 3 个
241
- const second = store.getPrecheckCandidates({
242
- limit: 50,
243
- intervalSeconds: 86400,
244
- locations: null,
245
- });
246
- check(
247
- "已领取用户不会重复发放",
248
- !second.some((c) => candidates.some((p) => p.uniqueId === c.uniqueId)),
249
- JSON.stringify(second.map((c) => c.uniqueId)),
250
- );
251
- // markPrecheckResult 双分支(needRefresh 0/1)仍正常
252
- store.markPrecheckResult("u_us", { needRefresh: true, videoCount: 12 });
253
- store.markPrecheckResult("u_2d_fr", { needRefresh: false, videoCount: 8 });
254
- const marked = db
255
- .prepare(
256
- `SELECT unique_id, pending_refresh, video_count FROM jobs WHERE unique_id IN ('u_us','u_2d_fr')`,
257
- )
258
- .all();
259
- check(
260
- "markPrecheckResult 正常写回",
261
- marked.find((r) => r.unique_id === "u_us").pending_refresh === 1 &&
262
- marked.find((r) => r.unique_id === "u_us").video_count === 12 &&
263
- marked.find((r) => r.unique_id === "u_2d_fr").pending_refresh === 0,
264
- JSON.stringify(marked),
265
- );
266
-
267
- db.close();
268
- fs.unlinkSync(dbPath);
269
- console.error(failed === 0 ? "\n全部通过 ✅" : `\n${failed} 项失败 ❌`);
270
- process.exit(failed === 0 ? 0 : 1);
@@ -1,114 +0,0 @@
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);
@@ -1,83 +0,0 @@
1
- /**
2
- * 探测模块集成测试(真实浏览器 + 真实 TikTok)
3
- *
4
- * 用法:
5
- * node scripts/test-probe-live.mjs [ports] [points]
6
- * 例: node scripts/test-probe-live.mjs 4222
7
- * node scripts/test-probe-live.mjs 4222 video,comment
8
- *
9
- * 说明:
10
- * - 连接本地已启动的 CDP 端口(不 kill 业务浏览器)
11
- * - 会对 TikTok 真实发起访问(探测本身是访问行为)
12
- * - 断言输出结构 + 至少能完成调度;业务可用性结果只打印不判成败
13
- */
14
- import assert from "node:assert/strict";
15
- import { runProbe, POINT_NAMES } from "../src/lib/probe/index.js";
16
-
17
- const ports = (process.argv[2] || "4222")
18
- .split(",")
19
- .map((s) => Number(s.trim()))
20
- .filter((n) => Number.isInteger(n) && n > 0);
21
- const points = process.argv[3]
22
- ? process.argv[3].split(",").map((s) => s.trim()).filter(Boolean)
23
- : POINT_NAMES;
24
-
25
- console.error(`[live-test] ports=${ports.join(",")} points=${points.join(",")}`);
26
- console.error(`[live-test] 说明:将对 TikTok 真实访问,属正常探测行为\n`);
27
-
28
- const t0 = Date.now();
29
- const result = await runProbe({ ports, points, log: console.error });
30
- const totalMs = Date.now() - t0;
31
-
32
- console.error(`\n[结果] 总耗时 ${totalMs}ms`);
33
- console.log(JSON.stringify(result, null, 2));
34
-
35
- // ── 结构断言(不判业务成败,只判调度/输出正确) ──
36
- let failed = 0;
37
- try {
38
- assert.ok(Array.isArray(result.ports), "ports 应为数组");
39
- assert.equal(result.ports.length, ports.length, "端口数应一致");
40
- assert.ok(result.ports.every((p) => Number.isInteger(p.port)), "端口号应为整数");
41
- assert.ok(
42
- result.ports.every((p) => typeof p.loggedIn === "boolean"),
43
- "loggedIn 应为布尔",
44
- );
45
- // 每端口包含请求的功能点键
46
- for (const p of result.ports) {
47
- for (const name of points) {
48
- assert.ok(
49
- name in p.points,
50
- `端口 ${p.port} 应包含功能点 ${name}`,
51
- );
52
- const r = p.points[name];
53
- assert.equal(typeof r.ok, "boolean", `${name}.ok 应为布尔`);
54
- assert.equal(typeof r.count, "number", `${name}.count 应为数字`);
55
- assert.equal(typeof r.elapsedMs, "number", `${name}.elapsedMs 应为数字`);
56
- assert.ok(
57
- typeof r.error === "string" || r.error === null,
58
- `${name}.error 应为 string|null`,
59
- );
60
- }
61
- }
62
- console.error(`\n[断言] 结构校验全部通过 ✓`);
63
- } catch (e) {
64
- failed = 1;
65
- console.error(`\n[断言] 失败: ${e.message}`);
66
- }
67
-
68
- // 汇总业务结果
69
- console.error("\n[业务汇总]");
70
- for (const p of result.ports) {
71
- for (const name of points) {
72
- const r = p.points[name];
73
- if (!r) continue;
74
- const tag = r.skipped
75
- ? `跳过(${r.skipReason})`
76
- : r.ok
77
- ? `✓ ${r.count}`
78
- : `✗ ${r.error}`;
79
- console.error(` port ${p.port} ${name.padEnd(8)} ${tag} [${r.elapsedMs}ms]`);
80
- }
81
- }
82
-
83
- process.exit(failed);