tt-help-cli-ycl 1.4.150 → 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.
- package/package.json +1 -1
- package/scripts/test-photo-post.mjs +156 -0
- package/scripts/test-precheck-perf.mjs +270 -0
- package/scripts/test-product-fill-cdp.mjs +44 -0
- package/scripts/test-product-fill-db.mjs +43 -0
- package/scripts/test-seller-any-country.mjs +167 -0
- package/src/cli/product-fill.js +13 -1
- package/src/lib/api-interceptor.js +10 -1
- package/src/lib/shop-scraper.mjs +79 -0
- package/src/scraper/explore-core.js +8 -2
- package/src/watch/data-store.js +67 -20
- package/src/watch/db-columns.js +1 -0
- package/src/watch/db-schema.js +3 -0
- package/src/watch/db-tags.js +15 -5
- package/src/watch/public/app.js +89 -17
- package/src/watch/public/index.html +60 -55
- package/src/watch/public/style.css +80 -4
- package/src/watch/server.js +14 -0
|
@@ -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
|
+
);
|
package/src/cli/product-fill.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/lib/shop-scraper.mjs
CHANGED
|
@@ -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 =
|
|
@@ -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
|
|
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
|
package/src/watch/data-store.js
CHANGED
|
@@ -683,7 +683,13 @@ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
|
|
|
683
683
|
product_review_count,
|
|
684
684
|
product_sold_count,
|
|
685
685
|
product_name,
|
|
686
|
+
product_skus,
|
|
686
687
|
recent_ec_videos,
|
|
688
|
+
(CASE WHEN json_valid(product_skus) THEN
|
|
689
|
+
(SELECT COUNT(*) FROM json_each(product_skus)) ELSE 0 END) AS skuCount,
|
|
690
|
+
(CASE WHEN json_valid(product_skus) THEN
|
|
691
|
+
(SELECT COALESCE(SUM(CAST(json_extract(value, '$.stock') AS INTEGER)), 0)
|
|
692
|
+
FROM json_each(product_skus)) ELSE 0 END) AS skuTotalStock,
|
|
687
693
|
(SELECT COUNT(*) FROM json_each(recent_ec_videos)
|
|
688
694
|
WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) AS ecOver1k,
|
|
689
695
|
(SELECT MAX(CAST(json_extract(value, '$.playCount') AS INTEGER))
|
|
@@ -1388,7 +1394,11 @@ export function createStore(filePath, options = {}) {
|
|
|
1388
1394
|
filter: `instr(COALESCE(sources, ''), '"seed"') > 0`,
|
|
1389
1395
|
loggedInOnly: false,
|
|
1390
1396
|
},
|
|
1391
|
-
{
|
|
1397
|
+
{
|
|
1398
|
+
filter: `tt_seller = 1 AND verified = 0`,
|
|
1399
|
+
loggedInOnly: true,
|
|
1400
|
+
sellerAnyCountry: true,
|
|
1401
|
+
},
|
|
1392
1402
|
{
|
|
1393
1403
|
filter: `(instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0 OR instr(COALESCE(sources, ''), '"comment"') > 0)`,
|
|
1394
1404
|
loggedInOnly: false,
|
|
@@ -1407,8 +1417,28 @@ export function createStore(filePath, options = {}) {
|
|
|
1407
1417
|
);
|
|
1408
1418
|
if (normalizedLocations.length === 0)
|
|
1409
1419
|
locationBranches.push(locNotIn(orderedCountries));
|
|
1420
|
+
// 商家级专用(sellerAnyCountry):国家对商家放开——末尾追加"任意国家"分支。
|
|
1421
|
+
// 传了国家子集时对应国家的商家仍优先,领完再兜任意国家(含空位置商家);
|
|
1422
|
+
// 未传国家时同理(配置内各国 → 配置外 → 任意)。其他来源级分支不受影响。
|
|
1423
|
+
if (p.sellerAnyCountry) locationBranches.push(null); // null → 任意国家分支
|
|
1410
1424
|
let subPrio = 0;
|
|
1411
1425
|
for (const loc of locationBranches) {
|
|
1426
|
+
// 商家任意国家分支:不限 guessed_location(含空位置的商家)
|
|
1427
|
+
if (loc === null) {
|
|
1428
|
+
// hasVideo
|
|
1429
|
+
addBranch(
|
|
1430
|
+
`status = 'pending' AND pinned = 0 AND ${p.filter} AND video_count > 0 AND ${statusCodeWhere} ${notSellerWhere}`,
|
|
1431
|
+
[],
|
|
1432
|
+
);
|
|
1433
|
+
subPrio++;
|
|
1434
|
+
// noVideoReq
|
|
1435
|
+
addBranch(
|
|
1436
|
+
`status = 'pending' AND pinned = 0 AND ${p.filter} AND ${statusCodeWhere} ${notSellerWhere}`,
|
|
1437
|
+
[],
|
|
1438
|
+
);
|
|
1439
|
+
subPrio++;
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1412
1442
|
if (!loc) continue;
|
|
1413
1443
|
// hasVideo
|
|
1414
1444
|
addBranch(
|
|
@@ -2452,28 +2482,40 @@ export function createStore(filePath, options = {}) {
|
|
|
2452
2482
|
const placeholders = targetLocations.map(() => "?").join(",");
|
|
2453
2483
|
|
|
2454
2484
|
// SELECT 候选用户
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2485
|
+
// 性能三件套(76 万行实测 1~3s → ~80ms):
|
|
2486
|
+
// 1. WHERE/ORDER BY 不用 COALESCE(refresh_time, ?)(表达式挡索引);
|
|
2487
|
+
// ASC 排序 NULL 天然最前,与 COALESCE 2016 默认值语义等价
|
|
2488
|
+
// 2. INDEXED BY 强制走 idx_jobs_precheck 部分索引(优化器无统计时会
|
|
2489
|
+
// 误选 idx_jobs_seller_verified_loc 走 TEMP B-TREE 全排序)
|
|
2490
|
+
// 3. ORDER BY jobs.refresh_time 限定表名——裸列名会被 SELECT 输出别名
|
|
2491
|
+
// refresh_time(COALESCE 表达式)遮蔽,又退回表达式排序
|
|
2492
|
+
// 索引不存在的环境(老库未重启)抛 "no such index",自动回退普通查询。
|
|
2493
|
+
const baseWhere = `
|
|
2460
2494
|
WHERE tt_seller = 1
|
|
2461
2495
|
AND verified = 0
|
|
2462
2496
|
AND (pending_refresh IS NULL OR pending_refresh = 0)
|
|
2463
2497
|
AND location_created IN (${placeholders})
|
|
2464
|
-
AND
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2498
|
+
AND (refresh_time IS NULL OR refresh_time < ?)`;
|
|
2499
|
+
const orderLimit = `
|
|
2500
|
+
ORDER BY jobs.refresh_time ASC
|
|
2501
|
+
LIMIT ?`;
|
|
2502
|
+
const selectSql = `SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
|
|
2503
|
+
FROM jobs`;
|
|
2504
|
+
const args = [defaultTime, ...targetLocations, threshold, limit];
|
|
2505
|
+
|
|
2506
|
+
let selected;
|
|
2507
|
+
try {
|
|
2508
|
+
selected = getDb()
|
|
2509
|
+
.prepare(
|
|
2510
|
+
`${selectSql} INDEXED BY idx_jobs_precheck${baseWhere}${orderLimit}`,
|
|
2511
|
+
)
|
|
2512
|
+
.all(...args);
|
|
2513
|
+
} catch (e) {
|
|
2514
|
+
if (!/no such index/i.test(e.message)) throw e;
|
|
2515
|
+
selected = getDb()
|
|
2516
|
+
.prepare(`${selectSql}${baseWhere}${orderLimit}`)
|
|
2517
|
+
.all(...args);
|
|
2518
|
+
}
|
|
2477
2519
|
|
|
2478
2520
|
// 立即更新 refresh_time 防重复领取(非事务,小概率并发重复可接受)
|
|
2479
2521
|
if (selected.length > 0) {
|
|
@@ -2799,8 +2841,9 @@ export function createStore(filePath, options = {}) {
|
|
|
2799
2841
|
/**
|
|
2800
2842
|
* 回写商品详情到 jobs 表(product_* 列)
|
|
2801
2843
|
* @param {string} uniqueId
|
|
2802
|
-
* @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
|
|
2844
|
+
* @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string, skus?:Array}} productDetail
|
|
2803
2845
|
* rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
|
|
2846
|
+
* skus = 多 SKU 概要数组(skuId/specs/stock/stockStatus/warehouseId),存 product_skus JSON
|
|
2804
2847
|
*/
|
|
2805
2848
|
function updateProductInfo(uniqueId, productDetail) {
|
|
2806
2849
|
if (getDb()) {
|
|
@@ -2814,6 +2857,10 @@ export function createStore(filePath, options = {}) {
|
|
|
2814
2857
|
productReviewCount: productDetail.reviewCount ?? null,
|
|
2815
2858
|
productSoldCount: productDetail.soldCount ?? null,
|
|
2816
2859
|
productName: productDetail.productName || null,
|
|
2860
|
+
// 多 SKU 概要:数组经 normalizeJobValue 自动序列化为 JSON(无 skus 存 null)
|
|
2861
|
+
productSkus: Array.isArray(productDetail.skus)
|
|
2862
|
+
? productDetail.skus
|
|
2863
|
+
: null,
|
|
2817
2864
|
},
|
|
2818
2865
|
false, // 商品补全与用户刷新计数无关,不增加 user_update_count
|
|
2819
2866
|
"jobs",
|
package/src/watch/db-columns.js
CHANGED
package/src/watch/db-schema.js
CHANGED
|
@@ -173,6 +173,7 @@ export function initDb(filePath) {
|
|
|
173
173
|
product_review_count: "INTEGER",
|
|
174
174
|
product_sold_count: "INTEGER",
|
|
175
175
|
product_name: "TEXT",
|
|
176
|
+
product_skus: "TEXT",
|
|
176
177
|
user_create_time: "INTEGER",
|
|
177
178
|
recent_ec_videos: "TEXT",
|
|
178
179
|
});
|
|
@@ -209,6 +210,7 @@ export function initDb(filePath) {
|
|
|
209
210
|
product_review_count: "INTEGER",
|
|
210
211
|
product_sold_count: "INTEGER",
|
|
211
212
|
product_name: "TEXT",
|
|
213
|
+
product_skus: "TEXT",
|
|
212
214
|
user_create_time: "INTEGER",
|
|
213
215
|
recent_ec_videos: "TEXT",
|
|
214
216
|
});
|
|
@@ -246,6 +248,7 @@ export function initDb(filePath) {
|
|
|
246
248
|
product_review_count: "INTEGER",
|
|
247
249
|
product_sold_count: "INTEGER",
|
|
248
250
|
product_name: "TEXT",
|
|
251
|
+
product_skus: "TEXT",
|
|
249
252
|
user_create_time: "INTEGER",
|
|
250
253
|
recent_ec_videos: "TEXT",
|
|
251
254
|
});
|
package/src/watch/db-tags.js
CHANGED
|
@@ -375,15 +375,25 @@ export function getAllTags(
|
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
export function rawQuery(sql, params = []) {
|
|
378
|
-
// 通用查询器:默认查主库(stats/db-query 复用),勿切到 tag
|
|
378
|
+
// 通用查询器:默认查主库(stats/db-query 复用),勿切到 tag 库。
|
|
379
|
+
// 注意:原实现用 prepare().all() 跑一切语句,DDL(CREATE INDEX 等)会抛
|
|
380
|
+
// "This statement does not return data" 且被 catch 吞成 {error} 返回——
|
|
381
|
+
// server.js 启动清单 ensureIdx 曾因此静默失效(生产库从未建过启动索引)。
|
|
382
|
+
// 现按语句类型分流:DDL/写语句走 exec,查询走 all;失败时抛错让调用方感知。
|
|
379
383
|
const db = getMainDb();
|
|
380
|
-
if (!db)
|
|
381
|
-
|
|
384
|
+
if (!db) throw new Error("db not ready");
|
|
385
|
+
const trimmed = sql.trim().toUpperCase();
|
|
386
|
+
const isQuery =
|
|
387
|
+
trimmed.startsWith("SELECT") ||
|
|
388
|
+
trimmed.startsWith("PRAGMA") ||
|
|
389
|
+
trimmed.startsWith("EXPLAIN") ||
|
|
390
|
+
trimmed.startsWith("WITH");
|
|
391
|
+
if (isQuery) {
|
|
382
392
|
const rows = db.prepare(sql).all(...params);
|
|
383
393
|
return { rows };
|
|
384
|
-
} catch (e) {
|
|
385
|
-
return { error: e.message };
|
|
386
394
|
}
|
|
395
|
+
db.exec(sql);
|
|
396
|
+
return { rows: [] };
|
|
387
397
|
}
|
|
388
398
|
|
|
389
399
|
export function normalizeTags() {
|