tt-help-cli-ycl 1.4.144 → 1.4.146
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-daemon-failover.mjs +61 -4
- package/scripts/test-product-fill-cdp.mjs +168 -0
- package/src/clash/checker.js +41 -0
- package/src/clash/daemon.js +26 -0
- package/src/cli/comments.js +265 -388
- package/src/cli/product-fill.js +255 -128
- package/src/cli/tag.js +143 -334
- package/src/lib/args.js +15 -5
- package/src/lib/constants.js +14 -10
- package/src/lib/shop-scraper.mjs +69 -6
- package/src/watch/data-store.js +129 -6
- package/src/watch/public/app.js +290 -0
- package/src/watch/public/index.html +99 -1
- package/src/watch/public/style.css +202 -0
- package/src/watch/server.js +64 -1
- package/src/watchdog/process-manager.js +11 -6
- package/scripts/test-product-fill.mjs +0 -306
package/src/lib/args.js
CHANGED
|
@@ -894,15 +894,25 @@ function parseProductFillArgs(args) {
|
|
|
894
894
|
let limit = 5;
|
|
895
895
|
let interval = 10;
|
|
896
896
|
let serverUrl = defaultServer;
|
|
897
|
-
let webserverUrl = null;
|
|
898
897
|
let proxyUrl = null;
|
|
898
|
+
/** 端口/国家配置:--profiles US/9223,JP/1222 → [{country:"US",port:9223},...] */
|
|
899
|
+
let profiles = [];
|
|
899
900
|
|
|
900
901
|
for (let i = 0; i < args.length; i++) {
|
|
901
902
|
const arg = args[i];
|
|
902
903
|
if (arg === "-s" || arg === "--server") {
|
|
903
904
|
serverUrl = args[++i];
|
|
904
|
-
} else if (arg === "
|
|
905
|
-
|
|
905
|
+
} else if (arg === "--profiles") {
|
|
906
|
+
for (const seg of String(args[++i] || "").split(",")) {
|
|
907
|
+
const [country, portStr] = seg.trim().split("/");
|
|
908
|
+
const port = parseInt(portStr, 10);
|
|
909
|
+
const cc = String(country || "")
|
|
910
|
+
.trim()
|
|
911
|
+
.toUpperCase();
|
|
912
|
+
if (port && /^[A-Z]{2}$/.test(cc)) {
|
|
913
|
+
profiles.push({ country: cc, port });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
906
916
|
} else if (arg === "--proxy") {
|
|
907
917
|
proxyUrl = args[++i] || null;
|
|
908
918
|
} else if (arg === "-i" || arg === "--interval") {
|
|
@@ -917,8 +927,8 @@ function parseProductFillArgs(args) {
|
|
|
917
927
|
productFillLimit: limit,
|
|
918
928
|
productFillInterval: interval,
|
|
919
929
|
serverUrl,
|
|
920
|
-
productFillWebserver: webserverUrl,
|
|
921
930
|
productFillProxy: proxyUrl,
|
|
931
|
+
productFillProfiles: profiles,
|
|
922
932
|
urls: [],
|
|
923
933
|
outputFormat: "json",
|
|
924
934
|
exploreCount: 0,
|
|
@@ -1564,7 +1574,7 @@ export function parseArgs() {
|
|
|
1564
1574
|
productFillLimit: 5,
|
|
1565
1575
|
productFillInterval: 10,
|
|
1566
1576
|
serverUrl: defaultServer,
|
|
1567
|
-
|
|
1577
|
+
productFillProfiles: [],
|
|
1568
1578
|
showHelp: true,
|
|
1569
1579
|
showVersion: false,
|
|
1570
1580
|
urls: [],
|
package/src/lib/constants.js
CHANGED
|
@@ -222,14 +222,17 @@ const HELP_TEXT = [
|
|
|
222
222
|
" 示例: tt-help attach -p 5 -i 10",
|
|
223
223
|
" tt-help attach -c PL,DE,FR -p 5",
|
|
224
224
|
"",
|
|
225
|
-
" tt-help product-fill [
|
|
226
|
-
"
|
|
225
|
+
" tt-help product-fill --profiles <XX/port>[,<XX/port>...] [-s 服务端地址] [-i 间隔秒数] [-l 每批上限] [--proxy URL]",
|
|
226
|
+
" 循环领取缺商品详情任务,抓取评分/销量/评论并回写 jobs 表",
|
|
227
|
+
" --profiles 国家/端口对绑定(端口 profile 区域需与商品国家一致,如 US/9223,JP/1222);",
|
|
228
|
+
" 每轮探测代理出口,在配置中匹配端口领取对应国家任务,出口不在配置范围则跳过",
|
|
227
229
|
" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
|
|
228
|
-
"
|
|
230
|
+
" --proxy <URL> 浏览器启动代理及出口探测(默认: http://127.0.0.1:7897)",
|
|
229
231
|
" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
|
|
230
232
|
" -l, --limit <N> 每批领取任务数上限(默认: 5)",
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
+
" 下架壳页/连续失败3次/无效URL → 回写 rating=-1 哨兵永久移出候选",
|
|
234
|
+
" 示例: tt-help product-fill --profiles US/9223",
|
|
235
|
+
" tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
|
|
233
236
|
"",
|
|
234
237
|
" tt-help recheck [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
|
|
235
238
|
" 重检 jobs 表中 done 非商家用户(tt_seller!=1),7 天未更新才检测",
|
|
@@ -360,15 +363,16 @@ const HELP_TEXT = [
|
|
|
360
363
|
" 选项:",
|
|
361
364
|
" --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
|
|
362
365
|
" -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
|
|
363
|
-
" --port <N>
|
|
366
|
+
" --port <N> 固定 CDP 端口(默认 7222,全程不切换)",
|
|
364
367
|
" --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
|
|
365
368
|
" --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
|
|
366
369
|
" 行为:",
|
|
367
|
-
" 连续 3 次
|
|
370
|
+
" 错误只打印不自愈:403 连续 3 次 → 上报服务端并触发 daemon 换节点;",
|
|
371
|
+
" 网络错误/其他错误连续 11 次 → 退出;验证码连续 4 次未人工解决 → 退出",
|
|
372
|
+
" 成功一个 tag 全部计数清零;浏览器窗口被手动关闭 → 自动退出",
|
|
368
373
|
" 环境变量:",
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
" TAG_SCOREALL_RECYCLE_HEAP_MB=N 内存回收阈值 Heap MB(默认 2000)",
|
|
374
|
+
" TAG_SCOREALL_MIN_COUNTRY_RATE=0.8 国家识别率下限(低于则重试)",
|
|
375
|
+
" TAG_SCOREALL_COUNTRY_MAX_RETRIES=2 国家识别率不足最大重试次数",
|
|
372
376
|
" 示例: tt-help tag score-all",
|
|
373
377
|
" tt-help tag score-all --countries ES -s http://127.0.0.1:3001",
|
|
374
378
|
" tt-help tag score-all --port 9222 -s http://127.0.0.1:3001 --discover",
|
package/src/lib/shop-scraper.mjs
CHANGED
|
@@ -190,6 +190,8 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
190
190
|
const agent = proxy ? new ProxyAgent(proxy) : null;
|
|
191
191
|
|
|
192
192
|
let lastError;
|
|
193
|
+
let captchaRetries = 0; // 验证码弹浏览器重试次数(防无限重试)
|
|
194
|
+
const MAX_CAPTCHA_RETRIES = 2;
|
|
193
195
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
194
196
|
try {
|
|
195
197
|
const headers = { ...PDP_HEADERS };
|
|
@@ -216,8 +218,15 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
216
218
|
err.message?.includes("Blocked by TikTok") &&
|
|
217
219
|
options.captchaFallback
|
|
218
220
|
) {
|
|
221
|
+
if (captchaRetries >= MAX_CAPTCHA_RETRIES) {
|
|
222
|
+
console.error(
|
|
223
|
+
`[shop-scraper] Captcha persists after ${captchaRetries} browser attempts, giving up`,
|
|
224
|
+
);
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
captchaRetries++;
|
|
219
228
|
console.error(
|
|
220
|
-
`[shop-scraper] Captcha on ${proxy}, clearing cached cookies...`,
|
|
229
|
+
`[shop-scraper] Captcha on ${proxy}, clearing cached cookies (browser attempt ${captchaRetries}/${MAX_CAPTCHA_RETRIES})...`,
|
|
221
230
|
);
|
|
222
231
|
clearCachedCookies(proxy);
|
|
223
232
|
console.error("[shop-scraper] Opening browser for manual captcha...");
|
|
@@ -255,6 +264,12 @@ export function parseProductHtml(html) {
|
|
|
255
264
|
throw new Error("Blocked by TikTok security check (captcha)");
|
|
256
265
|
}
|
|
257
266
|
|
|
267
|
+
// 页面不含任何商品数据标识 → 空壳页(风控降级/跳转/空白),
|
|
268
|
+
// 抛可重试错误,避免把全 null 数据当成功回写
|
|
269
|
+
if (!html.includes('"product_model"') && !html.includes('"review_ratings"')) {
|
|
270
|
+
throw new Error("Empty product page (no product data markers)");
|
|
271
|
+
}
|
|
272
|
+
|
|
258
273
|
const result = {
|
|
259
274
|
rating: null,
|
|
260
275
|
reviewCount: null,
|
|
@@ -293,6 +308,19 @@ export function parseProductHtml(html) {
|
|
|
293
308
|
result.ratingBreakdown = ratings.rating_result ?? null;
|
|
294
309
|
}
|
|
295
310
|
|
|
311
|
+
// 壳页识别:有 product_id 骨架但 seller_id 为空且无任何销售/名称数据
|
|
312
|
+
// → 商品已下架/店铺异常(200 空壳页),抛 delisted 供调用方标记放弃
|
|
313
|
+
const hasSaleData =
|
|
314
|
+
result.soldCount !== null ||
|
|
315
|
+
result.rating !== null ||
|
|
316
|
+
result.reviewCount !== null ||
|
|
317
|
+
result.productName !== null;
|
|
318
|
+
if (result.productId && !result.sellerId && !hasSaleData) {
|
|
319
|
+
const err = new Error("Product delisted (seller_id empty, no sale data)");
|
|
320
|
+
err.delisted = true;
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
|
|
296
324
|
return result;
|
|
297
325
|
}
|
|
298
326
|
|
|
@@ -303,11 +331,29 @@ export function parseProductHtml(html) {
|
|
|
303
331
|
* @param {string} url - TikTok Shop PDP URL
|
|
304
332
|
* @returns {Promise<{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}>}
|
|
305
333
|
*/
|
|
306
|
-
export async function getProductInfoCDP(page, url) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
334
|
+
export async function getProductInfoCDP(page, url, options = {}) {
|
|
335
|
+
const { gotoTimeout = 60000, renderWaitMs = 15000 } = options;
|
|
336
|
+
await page.goto(url, {
|
|
337
|
+
waitUntil: "domcontentloaded",
|
|
338
|
+
timeout: gotoTimeout,
|
|
339
|
+
});
|
|
340
|
+
// 轮询等待 CSR 渲染出商品数据标识(或出现验证码标记),
|
|
341
|
+
// 不用 networkidle:TikTok 页面持续请求,networkidle 极易超时
|
|
342
|
+
const deadline = Date.now() + renderWaitMs;
|
|
343
|
+
let html = "";
|
|
344
|
+
while (Date.now() < deadline) {
|
|
345
|
+
html = await page.content();
|
|
346
|
+
if (
|
|
347
|
+
html.includes('"product_model"') ||
|
|
348
|
+
html.includes('"review_ratings"') ||
|
|
349
|
+
html.includes("captcha-verify-container") ||
|
|
350
|
+
html.includes("<title>Security Check</title>")
|
|
351
|
+
) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
355
|
+
}
|
|
356
|
+
if (!html) html = await page.content();
|
|
311
357
|
return parseProductHtml(html);
|
|
312
358
|
}
|
|
313
359
|
|
|
@@ -320,3 +366,20 @@ export function formatCount(n) {
|
|
|
320
366
|
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
|
321
367
|
return String(n);
|
|
322
368
|
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 判断商品详情是否全空(页面被拦截/结构变化时解析结果全为 null)。
|
|
372
|
+
* 只判实际回写的四个字段:rating/soldCount/reviewCount/productName。
|
|
373
|
+
* productId/sellerId 不回写,有值也不能视为"抓到了数据",
|
|
374
|
+
* 否则壳页(仅含 product_id 骨架)会被误报成功、任务永久留在候选池。
|
|
375
|
+
* @param {object} detail - parseProductHtml 的返回值
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
export function isEmptyProductDetail(detail) {
|
|
379
|
+
return (
|
|
380
|
+
(detail?.rating ?? null) === null &&
|
|
381
|
+
(detail?.reviewCount ?? null) === null &&
|
|
382
|
+
(detail?.soldCount ?? null) === null &&
|
|
383
|
+
!detail?.productName
|
|
384
|
+
);
|
|
385
|
+
}
|
package/src/watch/data-store.js
CHANGED
|
@@ -608,6 +608,92 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
608
608
|
};
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
+
/**
|
|
612
|
+
* 精确商家:目标商家(tt_seller=1 且 verified=0 且 目标国家)中,
|
|
613
|
+
* 最近 7 天有 isECVideo 播放 > 1000 的视频,且有商品、商品评论数=0、销量 > 10。
|
|
614
|
+
* 支持分页与搜索(unique_id/nickname),返回 total + users。
|
|
615
|
+
*/
|
|
616
|
+
function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
|
|
617
|
+
if (!getDb()) return null;
|
|
618
|
+
if (!targetLocations.length) return { total: 0, users: [] };
|
|
619
|
+
|
|
620
|
+
const { limit, offset, search } = options;
|
|
621
|
+
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
622
|
+
|
|
623
|
+
const where = `
|
|
624
|
+
tt_seller = 1
|
|
625
|
+
AND verified = 0
|
|
626
|
+
AND location_created IN (${placeholders})
|
|
627
|
+
AND top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
|
|
628
|
+
AND product_review_count = 0
|
|
629
|
+
AND product_sold_count > 10
|
|
630
|
+
AND recent_ec_videos IS NOT NULL AND recent_ec_videos != ''
|
|
631
|
+
AND EXISTS (
|
|
632
|
+
SELECT 1 FROM json_each(recent_ec_videos)
|
|
633
|
+
WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000
|
|
634
|
+
)
|
|
635
|
+
`;
|
|
636
|
+
|
|
637
|
+
let searchSql = "";
|
|
638
|
+
const searchParams = [];
|
|
639
|
+
if (search) {
|
|
640
|
+
searchSql = ` AND (unique_id LIKE ? OR nickname LIKE ?)`;
|
|
641
|
+
searchParams.push(`%${search}%`, `%${search}%`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const totalRow = getDb()
|
|
645
|
+
.prepare(`SELECT COUNT(*) AS n FROM jobs WHERE ${where}${searchSql}`)
|
|
646
|
+
.get(...targetLocations, ...searchParams);
|
|
647
|
+
const total = totalRow?.n || 0;
|
|
648
|
+
|
|
649
|
+
const selectSql = `
|
|
650
|
+
SELECT
|
|
651
|
+
unique_id,
|
|
652
|
+
nickname,
|
|
653
|
+
sec_uid,
|
|
654
|
+
status,
|
|
655
|
+
follower_count,
|
|
656
|
+
video_count,
|
|
657
|
+
location_created,
|
|
658
|
+
latest_video_time,
|
|
659
|
+
refresh_time,
|
|
660
|
+
top_video_play_count,
|
|
661
|
+
top_video_href,
|
|
662
|
+
top_video_is_ad,
|
|
663
|
+
top_video_product_id,
|
|
664
|
+
top_video_product_title,
|
|
665
|
+
top_video_seo_url,
|
|
666
|
+
product_rating,
|
|
667
|
+
product_review_count,
|
|
668
|
+
product_sold_count,
|
|
669
|
+
product_name,
|
|
670
|
+
recent_ec_videos,
|
|
671
|
+
(SELECT COUNT(*) FROM json_each(recent_ec_videos)
|
|
672
|
+
WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) AS ecOver1k,
|
|
673
|
+
(SELECT MAX(CAST(json_extract(value, '$.playCount') AS INTEGER))
|
|
674
|
+
FROM json_each(recent_ec_videos)) AS maxEcPlay
|
|
675
|
+
FROM jobs
|
|
676
|
+
WHERE ${where}${searchSql}
|
|
677
|
+
ORDER BY product_sold_count DESC, unique_id ASC
|
|
678
|
+
`;
|
|
679
|
+
|
|
680
|
+
if (limit !== undefined) {
|
|
681
|
+
const safeLimit = Math.max(Math.min(Math.floor(limit), 1000), 1);
|
|
682
|
+
const safeOffset = Math.max(Math.floor(offset || 0), 0);
|
|
683
|
+
const rows = getDb()
|
|
684
|
+
.prepare(`${selectSql} LIMIT ? OFFSET ?`)
|
|
685
|
+
.all(...targetLocations, ...searchParams, safeLimit, safeOffset)
|
|
686
|
+
.map(mapJobRow);
|
|
687
|
+
return { total, limit: safeLimit, offset: safeOffset, users: rows };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const rows = getDb()
|
|
691
|
+
.prepare(selectSql)
|
|
692
|
+
.all(...targetLocations, ...searchParams)
|
|
693
|
+
.map(mapJobRow);
|
|
694
|
+
return { total, users: rows };
|
|
695
|
+
}
|
|
696
|
+
|
|
611
697
|
/**
|
|
612
698
|
* 查询已修改(modified_at IS NOT NULL)且 location_created 不在 targetLocations 中的目标商家,
|
|
613
699
|
* 按国家分组返回。
|
|
@@ -2686,20 +2772,27 @@ export function createStore(filePath, options = {}) {
|
|
|
2686
2772
|
* @param {number|string} limit
|
|
2687
2773
|
* @param {string|null} claimBy 领取者标识(userId),仅作标记/日志
|
|
2688
2774
|
* @param {number} claimTtlMs 锁定超时(默认 5 分钟)
|
|
2775
|
+
* @param {string|null} country 国家过滤(大写两位,如 US/JP);非空时只返回
|
|
2776
|
+
* location_created 匹配的任务,location_created 为 NULL 的任务不分发;
|
|
2777
|
+
* 传 null/空串表示不限(兼容旧客户端)
|
|
2689
2778
|
* @returns {{uniqueId:string, nickname:string|null, seoUrl:string, productId:string|null, productTitle:string|null}[]}
|
|
2690
2779
|
*/
|
|
2691
2780
|
function getPendingProductTasks(
|
|
2692
2781
|
limit,
|
|
2693
2782
|
claimBy = null,
|
|
2694
2783
|
claimTtlMs = 5 * 60 * 1000,
|
|
2784
|
+
country = null,
|
|
2695
2785
|
) {
|
|
2696
2786
|
if (getDb()) {
|
|
2697
2787
|
const l = Math.max(1, parseInt(limit) || 5);
|
|
2698
2788
|
const now = Date.now();
|
|
2699
2789
|
const claimExpireBefore = now - claimTtlMs;
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2790
|
+
// 国家过滤:只认大写两位字母,避免异常值影响;无国家段的任务
|
|
2791
|
+
// 不分发给任何按国家领取的客户端(后续人工回填处理)
|
|
2792
|
+
const countryFilter = country
|
|
2793
|
+
? String(country).trim().toUpperCase().slice(0, 2)
|
|
2794
|
+
: "";
|
|
2795
|
+
let sql = `
|
|
2703
2796
|
SELECT unique_id, nickname, top_video_seo_url, top_video_product_id,
|
|
2704
2797
|
top_video_product_title
|
|
2705
2798
|
FROM jobs
|
|
@@ -2707,12 +2800,21 @@ export function createStore(filePath, options = {}) {
|
|
|
2707
2800
|
AND (product_name IS NULL OR product_name = '')
|
|
2708
2801
|
AND product_rating IS NULL
|
|
2709
2802
|
AND product_sold_count IS NULL
|
|
2803
|
+
`;
|
|
2804
|
+
const params = [];
|
|
2805
|
+
if (countryFilter.length === 2) {
|
|
2806
|
+
sql += ` AND location_created = ?`;
|
|
2807
|
+
params.push(countryFilter);
|
|
2808
|
+
}
|
|
2809
|
+
sql += `
|
|
2710
2810
|
AND (claimed_at IS NULL OR claimed_at < ?)
|
|
2711
2811
|
ORDER BY COALESCE(claimed_at, 0) ASC, updated_at DESC
|
|
2712
2812
|
LIMIT ?
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2813
|
+
`;
|
|
2814
|
+
params.push(claimExpireBefore, l);
|
|
2815
|
+
const rows = getDb()
|
|
2816
|
+
.prepare(sql)
|
|
2817
|
+
.all(...params);
|
|
2716
2818
|
if (rows.length > 0) {
|
|
2717
2819
|
// 领取即锁定:写入 claimed_at/claimed_by,避免多客户端重复抓取同一商品
|
|
2718
2820
|
const ids = rows.map((r) => r.unique_id);
|
|
@@ -2739,6 +2841,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2739
2841
|
* 回写商品详情到 jobs 表(product_* 列)
|
|
2740
2842
|
* @param {string} uniqueId
|
|
2741
2843
|
* @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
|
|
2844
|
+
* rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
|
|
2742
2845
|
*/
|
|
2743
2846
|
function updateProductInfo(uniqueId, productDetail) {
|
|
2744
2847
|
if (getDb()) {
|
|
@@ -2757,6 +2860,24 @@ export function createStore(filePath, options = {}) {
|
|
|
2757
2860
|
"jobs",
|
|
2758
2861
|
);
|
|
2759
2862
|
if (ret.error) return ret;
|
|
2863
|
+
// 校验:写入后 product_* 仍全空 → 回写无效(壳页假成功防御),
|
|
2864
|
+
// 防止任务在候选池里无限轮转
|
|
2865
|
+
const row = getDb()
|
|
2866
|
+
.prepare(
|
|
2867
|
+
`SELECT product_name, product_rating, product_sold_count,
|
|
2868
|
+
product_review_count
|
|
2869
|
+
FROM jobs WHERE unique_id = ?`,
|
|
2870
|
+
)
|
|
2871
|
+
.get(uniqueId);
|
|
2872
|
+
if (
|
|
2873
|
+
row &&
|
|
2874
|
+
(row.product_name === null || row.product_name === "") &&
|
|
2875
|
+
row.product_rating === null &&
|
|
2876
|
+
row.product_sold_count === null &&
|
|
2877
|
+
row.product_review_count === null
|
|
2878
|
+
) {
|
|
2879
|
+
return { ok: false, error: "product 字段仍全空,回写无效" };
|
|
2880
|
+
}
|
|
2760
2881
|
return { ok: true };
|
|
2761
2882
|
}
|
|
2762
2883
|
return { ok: false, error: "db not initialized" };
|
|
@@ -3549,6 +3670,8 @@ export function createStore(filePath, options = {}) {
|
|
|
3549
3670
|
getRawJobsPage: getRawJobsPageFromDb,
|
|
3550
3671
|
getTargetUsers: getTargetUsersFromDb,
|
|
3551
3672
|
getTargetUsersByCountry: getTargetUsersByCountryFromDb,
|
|
3673
|
+
getPreciseMerchants: (targetLocations, options) =>
|
|
3674
|
+
getPreciseMerchantsFromDb(targetLocations, options),
|
|
3552
3675
|
getModifiedTargetUsersOutsideLocations: (loc) =>
|
|
3553
3676
|
getModifiedTargetUsersOutsideLocations(loc),
|
|
3554
3677
|
getStats,
|