tt-help-cli-ycl 1.4.150 → 1.4.152

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.150",
3
+ "version": "1.4.152",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,134 @@
1
+ // 临时验证:被判重试期间弹出验证码 → fetchUserVideosAPI 立即抛 "验证码:",不再空转重试
2
+ // 运行:RM_BLOCKED_WAIT_MS=1 node scripts/test-api-interceptor-captcha.mjs
3
+ import { fetchUserVideosAPI } from "../src/lib/api-interceptor.js";
4
+
5
+ let pass = 0;
6
+ let fail = 0;
7
+
8
+ function check(name, ok, detail = "") {
9
+ if (ok) {
10
+ pass++;
11
+ console.log(`✓ ${name}${detail ? `: ${detail}` : ""}`);
12
+ } else {
13
+ fail++;
14
+ console.error(`✗ ${name}${detail ? `: ${detail}` : ""}`);
15
+ }
16
+ }
17
+
18
+ // 429 + 页面有验证码容器
19
+ function make429CaptchaPage(username) {
20
+ let gotoCount = 0;
21
+ const page = {
22
+ _handlers: {},
23
+ on(event, fn) {
24
+ (page._handlers[event] ||= []).push(fn);
25
+ return page;
26
+ },
27
+ off(event) {
28
+ delete page._handlers[event];
29
+ return page;
30
+ },
31
+ url: () => `https://www.tiktok.com/@${username}`,
32
+ goto: async () => {
33
+ gotoCount++;
34
+ },
35
+ // checkCdpHealth 用 () => document.readyState(fn.length===0)→ "complete"
36
+ // detectCaptcha 用 () => {...}(fn.length===0,通过 captchaVisible 开关控制返回值)
37
+ _captchaVisible: true,
38
+ evaluate: async (fn) => {
39
+ if (
40
+ page._captchaVisible &&
41
+ fn.toString().includes("captcha-verify-container")
42
+ ) {
43
+ return {
44
+ exists: true,
45
+ visible: true,
46
+ rect: { x: 0, y: 0, w: 100, h: 100 },
47
+ };
48
+ }
49
+ // detectPageError(fn.length===1)→ null
50
+ if (fn.length === 1) return null;
51
+ return "complete";
52
+ },
53
+ reload: async () => {},
54
+ waitForFunction: async () => null,
55
+ waitForResponse: async (pred) => {
56
+ const resp = {
57
+ url: () => "https://www.tiktok.com/api/post/item_list/?...",
58
+ status: () => 429,
59
+ headers: () => ({ "content-length": "19" }),
60
+ json: async () => {
61
+ throw new Error("Unexpected token 'r'...");
62
+ },
63
+ body: async () => Buffer.from("ratelimit triggered"),
64
+ };
65
+ if (!pred(resp)) throw new Error("response 不匹配 item_list");
66
+ return resp;
67
+ },
68
+ get gotoCount() {
69
+ return gotoCount;
70
+ },
71
+ };
72
+ return page;
73
+ }
74
+
75
+ // 用例1:429 + 验证码可见 → 立即抛 "验证码:",只 goto 1 次(无重试)
76
+ {
77
+ const page = make429CaptchaPage("fumolbbf08o");
78
+ try {
79
+ await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
80
+ check("429+验证码 立即转验证码", false, "未抛错");
81
+ } catch (e) {
82
+ check(
83
+ "429+验证码 立即转验证码",
84
+ e.message === "验证码: fumolbbf08o",
85
+ `抛错 "${e.message}"`,
86
+ );
87
+ }
88
+ check(
89
+ "429+验证码 不再重试(仅 1 次 goto)",
90
+ page.gotoCount === 1,
91
+ `goto 次数 = ${page.gotoCount}`,
92
+ );
93
+ }
94
+
95
+ // 用例2:429 + 验证码不可见 → 仍走正常被封重试,最终抛 "被封:"
96
+ {
97
+ const page = make429CaptchaPage("fumolbbf08o");
98
+ page._captchaVisible = false;
99
+ try {
100
+ await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
101
+ check("429无验证码 仍按被封重试", false, "未抛错");
102
+ } catch (e) {
103
+ check(
104
+ "429无验证码 仍按被封重试",
105
+ e.message === "被封: fumolbbf08o",
106
+ `抛错 "${e.message}",goto ${page.gotoCount} 次`,
107
+ );
108
+ }
109
+ }
110
+
111
+ // 用例3:429 + detectCaptcha 本身抛异常(页面上下文销毁)→ 忽略,按被封重试
112
+ {
113
+ const page = make429CaptchaPage("fumolbbf08o");
114
+ page.evaluate = async (fn) => {
115
+ if (fn.toString().includes("captcha-verify-container")) {
116
+ throw new Error("Execution context was destroyed");
117
+ }
118
+ if (fn.length === 1) return null;
119
+ return "complete";
120
+ };
121
+ try {
122
+ await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
123
+ check("检测异常时忽略按被封重试", false, "未抛错");
124
+ } catch (e) {
125
+ check(
126
+ "检测异常时忽略按被封重试",
127
+ e.message === "被封: fumolbbf08o",
128
+ `抛错 "${e.message}"`,
129
+ );
130
+ }
131
+ }
132
+
133
+ console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
134
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,156 @@
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
+ );
@@ -0,0 +1,270 @@
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);
@@ -32,6 +32,10 @@ function check(name, fn) {
32
32
  const VALID_HTML =
33
33
  '<html><body><script>{"product_model":{"name":"Test Product","product_id":"123456","seller_id":"789"},"sold_count":"296","review_ratings":{"overall_score":4.7,"review_count":"3303","rating_result":[5,4,3,2,1]}}</script></body></html>';
34
34
 
35
+ // 含多 SKU 的 PDP HTML(结构与线上实测一致:sku_id 19位 / property_pairs / sku_quantity)
36
+ const VALID_HTML_WITH_SKUS =
37
+ '<html><body><script>{"product_model":{"name":"Test Product","product_id":"123456","seller_id":"789"},"sold_count":"962","review_ratings":{"overall_score":4.8,"review_count":"174"},"skus":[{"sku_id":"1731140428076257811","sku_name":"810003369352","sku_status":1,"property_pairs":[{"sku_property_id":"7003574686483515141","sku_property_value_id":"6996631003314030337","sku_property_name":"Shade","sku_property_value_name":"Wanderlust"}],"sku_quantity":{"warehouse_id":"7676336681096611597","available_quantity":106},"sku_stock_status":1},{"sku_id":"1729888889849549331","sku_name":"810003367204","sku_status":1,"property_pairs":[{"sku_property_name":"Shade","sku_property_value_name":"Ballerina"}],"sku_quantity":{"warehouse_id":"7676336681096611597","available_quantity":2017},"sku_stock_status":1}]}</script></body></html>';
38
+
35
39
  console.log("\n[1] isEmptyProductDetail 全空判定");
36
40
  check("全 null → true", () => {
37
41
  assert.strictEqual(
@@ -98,6 +102,46 @@ check("解析结果非全空", () =>
98
102
  assert.strictEqual(isEmptyProductDetail(detail), false),
99
103
  );
100
104
 
105
+ console.log("\n[3b] parseProductHtml 多 SKU 提取");
106
+ const skuDetail = parseProductHtml(VALID_HTML_WITH_SKUS);
107
+ check("原有字段不受 skus 影响(rating)", () =>
108
+ assert.strictEqual(skuDetail.rating, 4.8),
109
+ );
110
+ check("原有字段不受 skus 影响(soldCount)", () =>
111
+ assert.strictEqual(skuDetail.soldCount, 962),
112
+ );
113
+ check(
114
+ "skus 数量 = 2",
115
+ () =>
116
+ assert.strictEqual(Array.isArray(skuDetail.skus), true) &&
117
+ assert.strictEqual(skuDetail.skus.length, 2),
118
+ );
119
+ check("skuId 19位字符串保精度", () =>
120
+ assert.strictEqual(skuDetail.skus[0].skuId, "1731140428076257811"),
121
+ );
122
+ check("specs 展开 property_pairs", () =>
123
+ assert.strictEqual(skuDetail.skus[0].specs, "Shade: Wanderlust"),
124
+ );
125
+ check(
126
+ "库存 available_quantity 提取",
127
+ () =>
128
+ assert.strictEqual(skuDetail.skus[0].stock, 106) &&
129
+ assert.strictEqual(skuDetail.skus[1].stock, 2017),
130
+ );
131
+ check("warehouseId 字符串化", () =>
132
+ assert.strictEqual(skuDetail.skus[0].warehouseId, "7676336681096611597"),
133
+ );
134
+ check("stockStatus 提取", () =>
135
+ assert.strictEqual(skuDetail.skus[0].stockStatus, 1),
136
+ );
137
+ check("无 skus 段 → skus = null", () =>
138
+ assert.strictEqual(parseProductHtml(VALID_HTML).skus, null),
139
+ );
140
+ check("skus 为 null 时不影响 isEmptyProductDetail", () => {
141
+ const d = parseProductHtml(VALID_HTML);
142
+ return assert.strictEqual(isEmptyProductDetail(d), false);
143
+ });
144
+
101
145
  console.log("\n[4] parseProductHtml 验证码页抛 captcha 错误");
102
146
  check("captcha-verify-container → 抛 captcha", () => {
103
147
  assert.throws(
@@ -153,6 +153,49 @@ check("sellerA 不再出现在候选", () => {
153
153
  assert(!tasksAfter.some((t) => t.uniqueId === "sellerA"));
154
154
  });
155
155
 
156
+ console.log("\n[3b] skus 入库(product_skus JSON 列)");
157
+ const skuRet = store.updateProductInfo("sellerB", {
158
+ rating: 4.8,
159
+ reviewCount: 174,
160
+ soldCount: 962,
161
+ productName: "Sku Product",
162
+ skus: [
163
+ {
164
+ skuId: "1731140428076257811",
165
+ specs: "Shade: Wanderlust",
166
+ stock: 106,
167
+ stockStatus: 1,
168
+ warehouseId: "7676336681096611597",
169
+ },
170
+ {
171
+ skuId: "1729888889849549331",
172
+ specs: "Shade: Ballerina",
173
+ stock: 2017,
174
+ stockStatus: 1,
175
+ warehouseId: "7676336681096611597",
176
+ },
177
+ ],
178
+ });
179
+ check("带 skus 回写返回 ok", () => assert(skuRet.ok === true));
180
+ check("product_skus 列已写入 JSON(读回可解析)", () => {
181
+ const rowB = db
182
+ .prepare("SELECT product_skus FROM jobs WHERE unique_id = ?")
183
+ .get("sellerB");
184
+ const parsed = JSON.parse(rowB.product_skus);
185
+ assert(parsed.length === 2);
186
+ assert(parsed[0].skuId === "1731140428076257811");
187
+ assert(parsed[1].stock === 2017);
188
+ });
189
+ check("无 skus 回写(老客户端)不报错", () => {
190
+ const ret = store.updateProductInfo("sellerB", {
191
+ rating: 4.0,
192
+ soldCount: 10,
193
+ productName: "Old",
194
+ reviewCount: 5,
195
+ });
196
+ assert(ret.ok === true);
197
+ });
198
+
156
199
  console.log("\n[4] 异常输入");
157
200
  check("缺 productDetail → error", () => {
158
201
  const r = store.updateProductInfo("sellerB", null);