tt-help-cli-ycl 1.4.149 → 1.4.151

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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 商家任务国家放开领取测试(claimNextJob 分支顺序)
3
+ *
4
+ * 验证(临时 SQLite 库):
5
+ * 1. 传了国家子集:先领对应国家的 ttseller,领完再领任意国家 ttseller
6
+ * 2. 未传国家:领商家(任意国家均可)
7
+ * 3. 其他来源级顺序不变:seed 仍排商家前,非商家任务仍按国家分层
8
+ * 4. 未登录客户端不领商家任务(保持原逻辑)
9
+ *
10
+ * 运行: TMPDIR=$(mktemp -d) node scripts/test-seller-any-country.mjs
11
+ */
12
+ import { createStore } from "../src/watch/data-store.js";
13
+ import { getDb } from "../src/watch/db-schema.js";
14
+ import { join } from "node:path";
15
+ import { mkdtempSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+
18
+ const tmp = mkdtempSync(join(tmpdir(), "seller-any-"));
19
+ const store = createStore(join(tmp, "test.db"));
20
+ const db = getDb();
21
+ if (!db) {
22
+ console.error("❌ db 未初始化");
23
+ process.exit(1);
24
+ }
25
+
26
+ let passed = 0;
27
+ function check(name, fn) {
28
+ try {
29
+ fn();
30
+ passed++;
31
+ console.log(` ✅ ${name}`);
32
+ } catch (e) {
33
+ console.error(` ❌ ${name}\n ${e.message}`);
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+ function assert(cond, msg = "断言失败") {
38
+ if (!cond) throw new Error(msg);
39
+ }
40
+
41
+ const now = Date.now();
42
+ // 直接插 task_queue(refill 后形态)
43
+ const insert = (id, loc, seller, sources, opts = {}) =>
44
+ db
45
+ .prepare(
46
+ `INSERT INTO task_queue (unique_id, status, pinned, guessed_location, tt_seller, verified, sources, video_count, follower_count, created_at, updated_at)
47
+ VALUES (?, 'pending', 0, ?, ?, 0, ?, ?, ?, ?, ?)`,
48
+ )
49
+ .run(
50
+ id,
51
+ loc,
52
+ seller,
53
+ JSON.stringify(sources),
54
+ opts.videoCount ?? 3,
55
+ opts.followers ?? 100,
56
+ now,
57
+ now,
58
+ );
59
+
60
+ console.log("===== 商家任务国家放开领取测试 =====");
61
+
62
+ // ── 场景 1:传 US,队列里有 US 商家 + DE 商家 ──
63
+ console.log("\n[1] 传国家子集 US:US 商家优先于 DE 商家");
64
+ insert("seller_de", "DE", 1, ["following"]);
65
+ insert("seller_us", "US", 1, ["following"]);
66
+ {
67
+ const job = await store.claimNextJob("t1", 5 * 60 * 1000, ["US"], true);
68
+ check("先领到 US 商家", () =>
69
+ assert(job?.uniqueId === "seller_us", `实际: ${job?.uniqueId}`),
70
+ );
71
+ }
72
+
73
+ // ── 场景 2:US 商家已不在(被领走),传 US → 兜任意国家 DE 商家 ──
74
+ console.log("\n[2] 无 US 商家可领时:兜到任意国家商家(DE)");
75
+ {
76
+ // seller_us 已被 t1 领走(processing),队列只剩 DE 商家 pending
77
+ const job = await store.claimNextJob("t2", 5 * 60 * 1000, ["US"], true);
78
+ check("兜到 DE 商家", () =>
79
+ assert(job?.uniqueId === "seller_de", `实际: ${job?.uniqueId}`),
80
+ );
81
+ }
82
+
83
+ // ── 场景 3:传 US 但队列只有 FR 商家 → 领 FR ──
84
+ console.log("\n[3] 传 US 但无 US 商家 → 领 FR 商家(任意国家兜底)");
85
+ db.prepare(`DELETE FROM task_queue`).run();
86
+ insert("seller_fr", "FR", 1, ["following"]);
87
+ {
88
+ const job = await store.claimNextJob("t3", 5 * 60 * 1000, ["US"], true);
89
+ check("领到 FR 商家", () =>
90
+ assert(job?.uniqueId === "seller_fr", `实际: ${job?.uniqueId}`),
91
+ );
92
+ }
93
+
94
+ // ── 场景 4:空位置商家(guessed_location='')→ 任意国家分支领到 ──
95
+ console.log("\n[4] 空位置商家可被任意国家分支领到");
96
+ db.prepare(`DELETE FROM task_queue`).run();
97
+ insert("seller_noloc", "", 1, ["following"]);
98
+ {
99
+ const job = await store.claimNextJob("t4", 5 * 60 * 1000, ["US"], true);
100
+ check("领到空位置商家", () =>
101
+ assert(job?.uniqueId === "seller_noloc", `实际: ${job?.uniqueId}`),
102
+ );
103
+ }
104
+
105
+ // ── 场景 5:未传国家,队列只有配置外国商家 → 直接领到 ──
106
+ console.log("\n[5] 未传国家:配置外国商家(FR)也能领到");
107
+ db.prepare(`DELETE FROM task_queue`).run();
108
+ insert("seller_fr2", "FR", 1, ["following"]);
109
+ {
110
+ const job = await store.claimNextJob("t5", 5 * 60 * 1000, null, true);
111
+ check("领到 FR 商家(未传国家不再被挡)", () =>
112
+ assert(job?.uniqueId === "seller_fr2", `实际: ${job?.uniqueId}`),
113
+ );
114
+ }
115
+
116
+ // ── 场景 6:seed 仍优先于商家(顺序不变)──
117
+ console.log("\n[6] seed 优先级仍高于商家");
118
+ db.prepare(`DELETE FROM task_queue`).run();
119
+ insert("seed_pl", "PL", 0, ["seed"]);
120
+ insert("seller_xx", "XX", 1, ["following"]);
121
+ {
122
+ const job = await store.claimNextJob("t6", 5 * 60 * 1000, null, true);
123
+ check("先领 seed", () =>
124
+ assert(job?.uniqueId === "seed_pl", `实际: ${job?.uniqueId}`),
125
+ );
126
+ }
127
+
128
+ // ── 场景 7:非商家任务仍按国家分层(PL 配置内优先于 ZZ 配置外)──
129
+ console.log("\n[7] 商家清空后:非商家按国家顺序领取");
130
+ db.prepare(`DELETE FROM task_queue`).run();
131
+ insert("other_pl", "PL", 0, ["search"]);
132
+ insert("other_zz", "ZZ", 0, ["search"]);
133
+ {
134
+ const job = await store.claimNextJob("t7", 5 * 60 * 1000, null, true);
135
+ check("PL 非商家先于 ZZ(配置内优先)", () =>
136
+ assert(job?.uniqueId === "other_pl", `实际: ${job?.uniqueId}`),
137
+ );
138
+ }
139
+
140
+ // ── 场景 8:未登录客户端不领商家(原逻辑不变)──
141
+ console.log("\n[8] 未登录不领商家任务");
142
+ db.prepare(`DELETE FROM task_queue`).run();
143
+ insert("seller_only", "US", 1, ["following"]);
144
+ insert("other_task", "PL", 0, ["search"]);
145
+ {
146
+ const job = await store.claimNextJob("t8", 5 * 60 * 1000, null, false);
147
+ check("未登录跳过商家领普通任务", () =>
148
+ assert(job?.uniqueId === "other_task", `实际: ${job?.uniqueId}`),
149
+ );
150
+ }
151
+
152
+ // ── 场景 9:非商家任务不享受任意国家兜底(配置内先于配置外)──
153
+ console.log("\n[9] 非商家配置外国家不越级");
154
+ db.prepare(`DELETE FROM task_queue`).run();
155
+ insert("plain_de", "DE", 0, ["search"]); // DE 在默认配置内
156
+ insert("plain_zz", "ZZ", 0, ["search"]); // ZZ 不在配置
157
+ {
158
+ const job = await store.claimNextJob("t9", 5 * 60 * 1000, null, true);
159
+ check("DE(配置内)先于 ZZ(配置外)", () =>
160
+ assert(job?.uniqueId === "plain_de", `实际: ${job?.uniqueId}`),
161
+ );
162
+ }
163
+
164
+ store.stopBackup?.();
165
+ console.log(
166
+ `\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
167
+ );
@@ -540,9 +540,21 @@ export async function handleProductFill(options) {
540
540
  batchSuccess++;
541
541
  totalSuccess++;
542
542
  failCount.delete(uniqueId);
543
+ const skus = detail.skus;
544
+ const skuInfo = skus
545
+ ? ` | SKU ${skus.length}个 总库存 ${skus.reduce((sum, s) => sum + (s.stock || 0), 0)}`
546
+ : "";
543
547
  log(
544
- ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"})`,
548
+ ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"}${skuInfo})`,
545
549
  );
550
+ if (skus) {
551
+ // SKU 明细日志:规格 | 库存 | 状态(先观察数据质量,暂不入库)
552
+ for (const s of skus) {
553
+ log(
554
+ ` · ${s.skuId} | ${s.specs || "(无规格名)"} | 库存${s.stock ?? "?"} | 状态${s.stockStatus ?? "?"}`,
555
+ );
556
+ }
557
+ }
546
558
  } else {
547
559
  batchFailed++;
548
560
  totalFailed++;
@@ -46,9 +46,17 @@ function extractProductInfo(item) {
46
46
 
47
47
  /**
48
48
  * 构建统一的视频对象
49
+ *
50
+ * 图文帖(photo post)与视频混在 item_list 响应里,用 imagePost 字段区分
51
+ * (实测 @zoecole.fr:图文 item 有 imagePost={cover,images,...},
52
+ * video.duration=0 且无 PlayAddrStruct/codecType;视频 duration>0)。
53
+ * - isPhoto 标记贯穿全链路:EC 筛选/入库照常保留图文,仅国家采样排除
54
+ * - href 按真实路径生成:图文 /photo/{id},视频 /video/{id}
49
55
  */
50
56
  function buildVideoObject(item, username) {
51
- const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
57
+ // 双保险:优先官方 imagePost 字段,duration=0 兜底(防字段偶发缺失)
58
+ const isPhoto = item.imagePost !== undefined || item.video?.duration === 0;
59
+ const href = `https://www.tiktok.com/@${username}/${isPhoto ? "photo" : "video"}/${item.id}`;
52
60
  const productInfo = extractProductInfo(item);
53
61
  return {
54
62
  id: item.id,
@@ -57,6 +65,7 @@ function buildVideoObject(item, username) {
57
65
  playCount: item.stats?.playCount || 0,
58
66
  isECVideo: item.isECVideo ? 1 : 0,
59
67
  isAd: item.isAd ? 1 : 0,
68
+ isPhoto: isPhoto ? 1 : 0,
60
69
  productId: productInfo?.productId || null,
61
70
  productTitle: productInfo?.productTitle || null,
62
71
  seoUrl: productInfo?.seoUrl || null,
@@ -10,7 +10,10 @@
10
10
  import { existsSync, readFileSync, writeFileSync } from "fs";
11
11
  import { homedir } from "os";
12
12
  import { join } from "path";
13
- import { DEFAULT_TARGET_LOCATIONS } from "./target-locations.js";
13
+ import {
14
+ DEFAULT_TARGET_LOCATIONS,
15
+ normalizeLocation,
16
+ } from "./target-locations.js";
14
17
 
15
18
  // 通用配置文件路径(与 ~/.tt-help.json 隔离,避免与看门狗 clientConfig 写入冲突)
16
19
  const CONFIG_FILE = join(homedir(), ".tt-help-config.json");
@@ -117,3 +120,14 @@ export function setTargetLocations(locations) {
117
120
  export function reloadConfig() {
118
121
  _cache = null;
119
122
  }
123
+
124
+ /**
125
+ * 国家优先级序号:位置越前优先级越高。
126
+ * 空/未配置国家返回 MAX_SAFE_INTEGER(排最后)。
127
+ */
128
+ export function locationRank(location) {
129
+ const normalized = normalizeLocation(location);
130
+ if (!normalized) return Number.MAX_SAFE_INTEGER;
131
+ const idx = getTargetLocations().indexOf(normalized);
132
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
133
+ }
@@ -241,6 +241,8 @@ function launchEdgeWithCDP(port, userDataDir, proxyServer) {
241
241
  "--disable-breakpad",
242
242
  "--disable-background-networking",
243
243
  "--disable-sync",
244
+ // 显式打开 about:blank,避免 Edge 默认打开 MSN 新标签页
245
+ "about:blank",
244
246
  ];
245
247
  if (proxyServer) {
246
248
  extraArgs.push(`--proxy-server=${proxyServer}`);
@@ -307,11 +307,25 @@ export async function findTikTokPage(browser) {
307
307
  }
308
308
 
309
309
  export async function getOrCreatePage(browser) {
310
+ // 先确定工作页:优先复用已有 TikTok 页,没有才新建。
311
+ // 必须先建后关:任何时刻至少保留 1 个标签页,避免 Windows 下
312
+ // 关闭最后一个标签页导致浏览器进程退出、CDP 断连。
310
313
  let page = await findTikTokPage(browser);
311
314
  if (!page) {
312
315
  const defaultCtx = browser.contexts()[0] || (await browser.newContext());
313
316
  page = await defaultCtx.newPage();
314
317
  }
318
+ // 关闭其余所有页面:清掉启动残留的默认新标签页(NTP/MSN),
319
+ // 以及进程崩溃泄漏的搜索 tab。不做 URL 匹配,无需随 Edge 改版维护。
320
+ // search-collector 的瞬态多 tab 在 finally 自行关闭,且本函数只在
321
+ // 启动/账户切换/浏览器重建时调用,不会撞上在途搜索 tab。
322
+ for (const ctx of browser.contexts()) {
323
+ for (const p of ctx.pages()) {
324
+ if (p !== page) {
325
+ await p.close().catch(() => {});
326
+ }
327
+ }
328
+ }
315
329
  return page;
316
330
  }
317
331
 
@@ -97,6 +97,81 @@ function extractJsonByKey(html, keyPattern) {
97
97
  return null;
98
98
  }
99
99
 
100
+ /**
101
+ * Extract a JSON array by its key name (counterpart of extractJsonByKey for `[`).
102
+ * 用于 PDP SSR HTML 中的 "skus":[...] 段。
103
+ */
104
+ function extractJsonArrayByKey(html, keyPattern) {
105
+ const match = html.match(keyPattern);
106
+ if (!match) return null;
107
+
108
+ const bracketIdx = html.indexOf("[", match.index);
109
+ if (bracketIdx === -1) return null;
110
+
111
+ let depth = 0;
112
+ let inString = false;
113
+ let escaped = false;
114
+
115
+ for (let i = bracketIdx; i < html.length; i++) {
116
+ const ch = html[i];
117
+ if (escaped) {
118
+ escaped = false;
119
+ continue;
120
+ }
121
+ if (ch === "\\") {
122
+ escaped = true;
123
+ continue;
124
+ }
125
+ if (ch === '"') {
126
+ inString = !inString;
127
+ continue;
128
+ }
129
+ if (inString) continue;
130
+ if (ch === "[") depth++;
131
+ else if (ch === "]") {
132
+ depth--;
133
+ if (depth === 0) {
134
+ try {
135
+ return JSON.parse(html.substring(bracketIdx, i + 1));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * 从 PDP SSR HTML 的 "skus":[...] 提取多 SKU 概要(原有商品级字段不动)。
147
+ * sku_id 为 19 位数字,必须按字符串保留(同 product_id 的精度坑)。
148
+ * @returns {Array<{skuId, specs, stock, stockStatus, warehouseId}>|null}
149
+ * 无 skus 段或解析失败返回 null(与现有字段缺失口径一致)
150
+ */
151
+ function extractSkus(html) {
152
+ const skus = extractJsonArrayByKey(html, /"skus"\s*:/);
153
+ if (!Array.isArray(skus) || skus.length === 0) return null;
154
+ const out = [];
155
+ for (const s of skus) {
156
+ try {
157
+ out.push({
158
+ skuId: String(s.sku_id ?? ""),
159
+ specs: (s.property_pairs || [])
160
+ .map((p) => `${p.sku_property_name}: ${p.sku_property_value_name}`)
161
+ .join(" | "),
162
+ stock: s.sku_quantity?.available_quantity ?? null,
163
+ stockStatus: s.sku_stock_status ?? null,
164
+ warehouseId: s.sku_quantity?.warehouse_id
165
+ ? String(s.sku_quantity.warehouse_id)
166
+ : null,
167
+ });
168
+ } catch {
169
+ // 单条损坏跳过,不影响其余 SKU
170
+ }
171
+ }
172
+ return out.length > 0 ? out : null;
173
+ }
174
+
100
175
  /**
101
176
  * Open a headed browser for manual captcha solving, extract cookies
102
177
  * so undici can reuse them for a while.
@@ -278,6 +353,7 @@ export function parseProductHtml(html) {
278
353
  ratingBreakdown: null,
279
354
  productId: null,
280
355
  sellerId: null,
356
+ skus: null, // 多 SKU 概要(无 skus 段或解析失败为 null,不影响原有字段)
281
357
  };
282
358
 
283
359
  // sold_count from product_model (string value, not shop-level integer)
@@ -308,6 +384,9 @@ export function parseProductHtml(html) {
308
384
  result.ratingBreakdown = ratings.rating_result ?? null;
309
385
  }
310
386
 
387
+ // skus(多规格概要:规格名/库存/仓库,sku_id 字符串保精度)
388
+ result.skus = extractSkus(html);
389
+
311
390
  // 壳页识别:有 product_id 骨架但 seller_id 为空且无任何销售/名称数据
312
391
  // → 商品已下架/店铺异常(200 空壳页),抛 delisted 供调用方标记放弃
313
392
  const hasSaleData =
@@ -1,9 +1,5 @@
1
+ // 顺序即领取优先级:越靠前越先领取(旧 tier1/tier2 硬编码分层已改为配置顺序驱动)
1
2
  const DEFAULT_TARGET_LOCATIONS = [
2
- "CZ",
3
- "GR",
4
- "HU",
5
- "PT",
6
- "ES",
7
3
  "PL",
8
4
  "NL",
9
5
  "BE",
@@ -11,9 +7,16 @@ const DEFAULT_TARGET_LOCATIONS = [
11
7
  "FR",
12
8
  "IT",
13
9
  "IE",
10
+ "ES",
11
+ "CZ",
12
+ "GR",
13
+ "HU",
14
+ "PT",
14
15
  "AT",
15
16
  "TH",
16
17
  "US",
18
+ "MY",
19
+ "VN",
17
20
  ];
18
21
 
19
22
  const DEFAULT_TARGET_LOCATIONS_CSV = DEFAULT_TARGET_LOCATIONS.join(",");
@@ -181,18 +181,24 @@ async function processExplore(page, username, options, log) {
181
181
  return result;
182
182
  }
183
183
 
184
- // 国家采样判断
184
+ // 国家采样判断(只用真视频:图文帖 /photo/ 链接查不到 locationCreated,
185
+ // 会浪费采样位并污染众数统计;图文 EC 帖仍在上方 EC 筛选中正常保留)
185
186
  // explore 模式:采样 5 个,优先命中目标国家,不匹配则回退众数 → 写 locationCreated
186
187
  // refresh 模式:采样 7 个,纯众数逻辑 → 写 confirmedLocation(二次确认)
187
188
  const SAMPLE_SIZE = locationMode === "refresh" ? 7 : 5;
188
189
  let locationCreated = null;
189
190
  let confirmedLocation = null;
190
191
  let locationDecision = null;
191
- const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
192
+ const sampleVideos = videoArray
193
+ .filter((v) => !v.isPhoto)
194
+ .slice(0, SAMPLE_SIZE);
192
195
  if (sampleVideos.length > 0) {
193
196
  const sampleUrls = sampleVideos.map((v) =>
194
197
  v.href.startsWith("http") ? v.href : `https://www.tiktok.com${v.href}`,
195
198
  );
199
+ log(
200
+ ` 国家采样(${sampleUrls.length}个, 已排除图文${videoArray.length - videoArray.filter((v) => !v.isPhoto).length}条): 开始查询`,
201
+ );
196
202
  let locations;
197
203
  if (webserverUrl) {
198
204
  // 走集中式 webserver,不再启动本地 TikTokScraper