tt-help-cli-ycl 1.4.142 → 1.4.145
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/sync-raw-jobs.mjs +13 -7
- package/scripts/test-api-interceptor-429.mjs +122 -0
- package/scripts/test-daemon-failover.mjs +61 -4
- package/src/clash/checker.js +41 -0
- package/src/clash/daemon.js +26 -0
- package/src/cli/comments.js +5 -5
- package/src/cli/precheck.js +2 -2
- package/src/cli/refresh-mini.js +1 -1
- package/src/cli/tag.js +110 -353
- package/src/lib/api-interceptor.js +26 -8
- package/src/lib/app-config.js +119 -0
- package/src/lib/args.js +3 -1
- package/src/lib/constants.js +9 -6
- package/src/lib/target-locations.js +1 -0
- package/src/scraper/core.js +2 -2
- package/src/scraper/explore-core.js +2 -2
- package/src/watch/data-store.js +94 -8
- package/src/watch/public/app.js +412 -26
- package/src/watch/public/index.html +74 -1
- package/src/watch/public/style.css +218 -0
- package/src/watch/server.js +144 -40
- package/src/watchdog/process-manager.js +11 -6
|
@@ -172,6 +172,8 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
|
|
|
172
172
|
let responsePromise = null;
|
|
173
173
|
let response = null;
|
|
174
174
|
let rawBodyBytes = -1; // -1 未知(未读取到 body);0 = 服务端空响应(封禁判定依据)
|
|
175
|
+
let httpStatus = null; // 响应 HTTP 状态码
|
|
176
|
+
let bodyText = ""; // 响应体文本(识别 ratelimit triggered 等限流文案)
|
|
175
177
|
|
|
176
178
|
try {
|
|
177
179
|
// 响应监听必须在 goto 之前注册:domcontentloaded 返回时首个 item_list
|
|
@@ -208,6 +210,7 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
|
|
|
208
210
|
// JSON 解析失败(如 "Unexpected end of JSON input")时打印原始响应,
|
|
209
211
|
// 便于诊断空响应/截断响应/非 JSON 响应等异常形态
|
|
210
212
|
if (response) {
|
|
213
|
+
httpStatus = response.status();
|
|
211
214
|
try {
|
|
212
215
|
// body 读取同样可能因页面异常而挂起,加独立超时保护
|
|
213
216
|
const raw = await Promise.race([
|
|
@@ -218,6 +221,7 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
|
|
|
218
221
|
]);
|
|
219
222
|
const text = raw.toString("utf-8");
|
|
220
223
|
rawBodyBytes = raw.length;
|
|
224
|
+
bodyText = text;
|
|
221
225
|
// content-length 用于区分两种 0 字节形态:
|
|
222
226
|
// 声明长度 > 实际字节 = 传输被截断;声明缺失/为 0 = 服务端主动空响应
|
|
223
227
|
const declared = parseInt(
|
|
@@ -247,6 +251,8 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
|
|
|
247
251
|
data,
|
|
248
252
|
interceptionError,
|
|
249
253
|
rawBodyBytes,
|
|
254
|
+
httpStatus,
|
|
255
|
+
bodyText,
|
|
250
256
|
elapsed: Date.now() - t0,
|
|
251
257
|
};
|
|
252
258
|
}
|
|
@@ -343,8 +349,14 @@ async function fetchUserVideosAPI(
|
|
|
343
349
|
|
|
344
350
|
while (true) {
|
|
345
351
|
attempt++;
|
|
346
|
-
const {
|
|
347
|
-
|
|
352
|
+
const {
|
|
353
|
+
data,
|
|
354
|
+
interceptionError,
|
|
355
|
+
rawBodyBytes,
|
|
356
|
+
httpStatus,
|
|
357
|
+
bodyText,
|
|
358
|
+
elapsed,
|
|
359
|
+
} = await attemptItemListFetch(page, username, log, apiRequestUrlRef);
|
|
348
360
|
|
|
349
361
|
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
350
362
|
log(` [API拦截] 成功,耗时 ${elapsed}ms`);
|
|
@@ -359,21 +371,27 @@ async function fetchUserVideosAPI(
|
|
|
359
371
|
);
|
|
360
372
|
}
|
|
361
373
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
374
|
+
// 判定为被封的两种形态,统一按封禁处理(默认 30/40/50 秒重试 3 次,仍封才抛错交给上层):
|
|
375
|
+
// 1. 0 字节空响应(content-length 缺失/为 0 或截断)—— 静默型封禁(页面正常渲染但 API 空响应)
|
|
376
|
+
// 2. HTTP 429 或 body 含 ratelimit —— 服务端主动限流(ratelimit triggered)
|
|
377
|
+
// 两种都不再依赖页面"出错了/很抱歉"文案判定,explore/refresh/refresh-mini 均受益
|
|
378
|
+
const rateLimited = httpStatus === 429 || /ratelimit/i.test(bodyText || "");
|
|
379
|
+
if (rawBodyBytes === 0 || rateLimited) {
|
|
380
|
+
const reason =
|
|
381
|
+
rawBodyBytes === 0
|
|
382
|
+
? "空响应(0 字节)"
|
|
383
|
+
: `限流(HTTP ${httpStatus}, ${bodyText.slice(0, 60)})`;
|
|
366
384
|
const retryIndex = attempt - 1;
|
|
367
385
|
if (retryIndex < retryWaits.length) {
|
|
368
386
|
const waitMs = retryWaits[retryIndex];
|
|
369
387
|
log(
|
|
370
|
-
` [API拦截] item_list
|
|
388
|
+
` [API拦截] item_list ${reason},判定为被封,${waitMs / 1000}s 后第 ${attempt}/${retryWaits.length} 次重试...`,
|
|
371
389
|
);
|
|
372
390
|
await new Promise((r) => setTimeout(r, waitMs));
|
|
373
391
|
continue;
|
|
374
392
|
}
|
|
375
393
|
log(
|
|
376
|
-
` [API拦截] item_list
|
|
394
|
+
` [API拦截] item_list ${reason},重试 ${retryWaits.length} 次仍被封`,
|
|
377
395
|
);
|
|
378
396
|
throw new Error(`被封: ${username}`);
|
|
379
397
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用应用配置模块(key-value,落盘到 ~/.tt-help-config.json)
|
|
3
|
+
*
|
|
4
|
+
* 设计为通用命名:任意配置项以 `key` 形式存放,后续可加入其他配置。
|
|
5
|
+
* 例如当前使用 `targetLocations`,未来可扩展 `xxx` 等。
|
|
6
|
+
*
|
|
7
|
+
* 读取规则:文件存在则用文件内容,否则回退内置默认值。
|
|
8
|
+
* 写入规则:整份 JSON 落盘(原子性不强,但配置变更频率低,可接受)。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { DEFAULT_TARGET_LOCATIONS } from "./target-locations.js";
|
|
14
|
+
|
|
15
|
+
// 通用配置文件路径(与 ~/.tt-help.json 隔离,避免与看门狗 clientConfig 写入冲突)
|
|
16
|
+
const CONFIG_FILE = join(homedir(), ".tt-help-config.json");
|
|
17
|
+
|
|
18
|
+
let _cache = null;
|
|
19
|
+
|
|
20
|
+
export function getConfigFilePath() {
|
|
21
|
+
return CONFIG_FILE;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readConfig() {
|
|
25
|
+
if (_cache) return _cache;
|
|
26
|
+
try {
|
|
27
|
+
if (existsSync(CONFIG_FILE)) {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
29
|
+
_cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
30
|
+
} else {
|
|
31
|
+
_cache = {};
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
_cache = {};
|
|
35
|
+
}
|
|
36
|
+
return _cache;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 通用读取:getConfig() 返回整份配置;getConfig(key) 返回指定项。
|
|
41
|
+
* @param {string|null} key
|
|
42
|
+
* @param {*} fallback
|
|
43
|
+
*/
|
|
44
|
+
export function getConfig(key = null, fallback = null) {
|
|
45
|
+
const cfg = readConfig();
|
|
46
|
+
if (key == null) return { ...cfg };
|
|
47
|
+
return cfg[key] !== undefined ? cfg[key] : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 通用写入:设置指定 key 并落盘,返回更新后的整份配置。
|
|
52
|
+
* @param {string} key
|
|
53
|
+
* @param {*} value
|
|
54
|
+
*/
|
|
55
|
+
export function setConfig(key, value) {
|
|
56
|
+
const cfg = readConfig();
|
|
57
|
+
cfg[key] = value;
|
|
58
|
+
_cache = cfg;
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
61
|
+
} catch {
|
|
62
|
+
// 写失败不抛出,保证调用方不至于崩溃;仅保留内存值
|
|
63
|
+
}
|
|
64
|
+
return { ...cfg };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 通用删除:移除指定 key 并落盘,返回更新后的整份配置。
|
|
69
|
+
* @param {string} key
|
|
70
|
+
*/
|
|
71
|
+
export function removeConfig(key) {
|
|
72
|
+
const cfg = readConfig();
|
|
73
|
+
if (key in cfg) {
|
|
74
|
+
delete cfg[key];
|
|
75
|
+
_cache = cfg;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
78
|
+
} catch {
|
|
79
|
+
// 写失败忽略,仅保留内存值
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { ...cfg };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeLocations(locations) {
|
|
86
|
+
if (!Array.isArray(locations)) return [];
|
|
87
|
+
return [
|
|
88
|
+
...new Set(
|
|
89
|
+
locations.map((l) => String(l).trim().toUpperCase()).filter(Boolean),
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 读取当前目标国家,未配置则回退 DEFAULT_TARGET_LOCATIONS */
|
|
95
|
+
export function getTargetLocations() {
|
|
96
|
+
const raw = getConfig("targetLocations", []);
|
|
97
|
+
const normalized = normalizeLocations(raw);
|
|
98
|
+
return normalized.length > 0 ? normalized : [...DEFAULT_TARGET_LOCATIONS];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 保存目标国家(自动去重、大写)。
|
|
103
|
+
* 传入空数组/非法值 = 移除配置项,回退到内置默认列表。
|
|
104
|
+
* 始终返回"生效后的列表"(getTargetLocations()),保证响应与读取一致。
|
|
105
|
+
*/
|
|
106
|
+
export function setTargetLocations(locations) {
|
|
107
|
+
const normalized = normalizeLocations(locations);
|
|
108
|
+
if (normalized.length === 0) {
|
|
109
|
+
removeConfig("targetLocations");
|
|
110
|
+
} else {
|
|
111
|
+
setConfig("targetLocations", normalized);
|
|
112
|
+
}
|
|
113
|
+
return getTargetLocations();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 清除内存缓存,强制下次读盘(配置被外部修改时调用) */
|
|
117
|
+
export function reloadConfig() {
|
|
118
|
+
_cache = null;
|
|
119
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { server as defaultServer } from "./constants.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getTargetLocations } from "./app-config.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TARGET_LOCATIONS_CSV = getTargetLocations().join(",");
|
|
4
6
|
|
|
5
7
|
const PRESETS = ["fast", "normal", "slow", "stealth"];
|
|
6
8
|
|
package/src/lib/constants.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join, dirname } from "path";
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
4
|
+
import { getTargetLocations } from "./app-config.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TARGET_LOCATIONS_CSV = getTargetLocations().join(",");
|
|
5
7
|
|
|
6
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
9
|
const __dirname = dirname(__filename);
|
|
@@ -358,15 +360,16 @@ const HELP_TEXT = [
|
|
|
358
360
|
" 选项:",
|
|
359
361
|
" --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
|
|
360
362
|
" -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
|
|
361
|
-
" --port <N>
|
|
363
|
+
" --port <N> 固定 CDP 端口(默认 7222,全程不切换)",
|
|
362
364
|
" --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
|
|
363
365
|
" --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
|
|
364
366
|
" 行为:",
|
|
365
|
-
" 连续 3 次
|
|
367
|
+
" 错误只打印不自愈:403 连续 3 次 → 上报服务端并触发 daemon 换节点;",
|
|
368
|
+
" 网络错误/其他错误连续 11 次 → 退出;验证码连续 4 次未人工解决 → 退出",
|
|
369
|
+
" 成功一个 tag 全部计数清零;浏览器窗口被手动关闭 → 自动退出",
|
|
366
370
|
" 环境变量:",
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
" TAG_SCOREALL_RECYCLE_HEAP_MB=N 内存回收阈值 Heap MB(默认 2000)",
|
|
371
|
+
" TAG_SCOREALL_MIN_COUNTRY_RATE=0.8 国家识别率下限(低于则重试)",
|
|
372
|
+
" TAG_SCOREALL_COUNTRY_MAX_RETRIES=2 国家识别率不足最大重试次数",
|
|
370
373
|
" 示例: tt-help tag score-all",
|
|
371
374
|
" tt-help tag score-all --countries ES -s http://127.0.0.1:3001",
|
|
372
375
|
" tt-help tag score-all --port 9222 -s http://127.0.0.1:3001 --discover",
|
package/src/scraper/core.js
CHANGED
|
@@ -11,17 +11,17 @@ import {
|
|
|
11
11
|
import { extractCommentAuthors } from "./modules/comment-extractor.js";
|
|
12
12
|
import { extractGuessVideos } from "./modules/guess-extractor.js";
|
|
13
13
|
import {
|
|
14
|
-
DEFAULT_TARGET_LOCATIONS_CSV,
|
|
15
14
|
isLocationInList,
|
|
16
15
|
normalizeLocationList,
|
|
17
16
|
} from "../lib/target-locations.js";
|
|
17
|
+
import { getTargetLocations } from "../lib/app-config.js";
|
|
18
18
|
|
|
19
19
|
async function scrapeSingleVideo(
|
|
20
20
|
page,
|
|
21
21
|
maxComments,
|
|
22
22
|
maxGuess,
|
|
23
23
|
log,
|
|
24
|
-
location =
|
|
24
|
+
location = getTargetLocations().join(","),
|
|
25
25
|
) {
|
|
26
26
|
const config = getDelayConfig();
|
|
27
27
|
|
|
@@ -5,12 +5,12 @@ import { getUserInfo, collectVideos } from "../videos/core.js";
|
|
|
5
5
|
import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
|
|
6
6
|
import { extractVideoLocation, setScraperProxy } from "../lib/scrape.js";
|
|
7
7
|
import {
|
|
8
|
-
DEFAULT_TARGET_LOCATIONS_CSV,
|
|
9
8
|
findBestMatchingLocation,
|
|
10
9
|
isLocationInList,
|
|
11
10
|
normalizeLocation,
|
|
12
11
|
normalizeLocationList,
|
|
13
12
|
} from "../lib/target-locations.js";
|
|
13
|
+
import { getTargetLocations } from "../lib/app-config.js";
|
|
14
14
|
import {
|
|
15
15
|
maxFollowing as globalMaxFollowing,
|
|
16
16
|
maxFollowers as globalMaxFollowers,
|
|
@@ -23,7 +23,7 @@ async function processExplore(page, username, options, log) {
|
|
|
23
23
|
loggedIn = false, // 由外部传入登录状态,避免每次调用 isLoggedIn(page)
|
|
24
24
|
maxFollowing = 50,
|
|
25
25
|
maxFollowers = 50,
|
|
26
|
-
location =
|
|
26
|
+
location = getTargetLocations().join(","),
|
|
27
27
|
locationMode = "explore", // "explore" | "refresh"
|
|
28
28
|
proxyServer = null,
|
|
29
29
|
webserverUrl = null,
|
package/src/watch/data-store.js
CHANGED
|
@@ -19,10 +19,8 @@
|
|
|
19
19
|
import fs from "fs";
|
|
20
20
|
import path from "path";
|
|
21
21
|
import Database from "better-sqlite3";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
DEFAULT_TARGET_LOCATIONS,
|
|
25
|
-
} from "../lib/target-locations.js";
|
|
22
|
+
import { isLocationInList } from "../lib/target-locations.js";
|
|
23
|
+
import { getTargetLocations } from "../lib/app-config.js";
|
|
26
24
|
|
|
27
25
|
// Schema 与连接管理
|
|
28
26
|
import {
|
|
@@ -610,6 +608,92 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
610
608
|
};
|
|
611
609
|
}
|
|
612
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
|
+
|
|
613
697
|
/**
|
|
614
698
|
* 查询已修改(modified_at IS NOT NULL)且 location_created 不在 targetLocations 中的目标商家,
|
|
615
699
|
* 按国家分组返回。
|
|
@@ -665,7 +749,7 @@ function getModifiedTargetUsersOutsideLocations(targetLocations = []) {
|
|
|
665
749
|
function getTargetRefreshStats(hours = 24) {
|
|
666
750
|
if (!getDb()) return { countries: [], total: 0, refreshed: 0 };
|
|
667
751
|
|
|
668
|
-
const targetLocations =
|
|
752
|
+
const targetLocations = getTargetLocations();
|
|
669
753
|
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
670
754
|
|
|
671
755
|
// 按国家统计总数
|
|
@@ -2226,7 +2310,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2226
2310
|
const now = Date.now();
|
|
2227
2311
|
const threshold = now - maxAgeSeconds * 1000;
|
|
2228
2312
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
2229
|
-
const targetLocations = locations ||
|
|
2313
|
+
const targetLocations = locations || getTargetLocations();
|
|
2230
2314
|
const placeholders = targetLocations.map(() => "?").join(",");
|
|
2231
2315
|
const row = getDb()
|
|
2232
2316
|
.prepare(
|
|
@@ -2261,7 +2345,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2261
2345
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
2262
2346
|
|
|
2263
2347
|
// 筛选目标国家用户,按 refreshTime 升序取最远的(没有则默认 2016-01-01)
|
|
2264
|
-
const targetLocations = locations ||
|
|
2348
|
+
const targetLocations = locations || getTargetLocations();
|
|
2265
2349
|
const targetUsers = data.filter(
|
|
2266
2350
|
(u) =>
|
|
2267
2351
|
u.ttSeller &&
|
|
@@ -2405,7 +2489,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2405
2489
|
const now = Date.now();
|
|
2406
2490
|
const threshold = now - intervalSeconds * 1000;
|
|
2407
2491
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
2408
|
-
const targetLocations = locations ||
|
|
2492
|
+
const targetLocations = locations || getTargetLocations();
|
|
2409
2493
|
const placeholders = targetLocations.map(() => "?").join(",");
|
|
2410
2494
|
|
|
2411
2495
|
// SELECT 候选用户
|
|
@@ -3551,6 +3635,8 @@ export function createStore(filePath, options = {}) {
|
|
|
3551
3635
|
getRawJobsPage: getRawJobsPageFromDb,
|
|
3552
3636
|
getTargetUsers: getTargetUsersFromDb,
|
|
3553
3637
|
getTargetUsersByCountry: getTargetUsersByCountryFromDb,
|
|
3638
|
+
getPreciseMerchants: (targetLocations, options) =>
|
|
3639
|
+
getPreciseMerchantsFromDb(targetLocations, options),
|
|
3554
3640
|
getModifiedTargetUsersOutsideLocations: (loc) =>
|
|
3555
3641
|
getModifiedTargetUsersOutsideLocations(loc),
|
|
3556
3642
|
getStats,
|