tt-help-cli-ycl 1.4.140 → 1.4.144

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.
@@ -23,10 +23,10 @@ import {
23
23
  saveUserId,
24
24
  } from "../lib/constants.js";
25
25
  import {
26
- DEFAULT_TARGET_LOCATIONS,
27
26
  isLocationInList,
28
27
  normalizeLocation,
29
28
  } from "../lib/target-locations.js";
29
+ import { getTargetLocations } from "../lib/app-config.js";
30
30
  import { createApiClient } from "../lib/api-client.js";
31
31
  import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
32
32
  import { delay } from "../lib/delay.js";
@@ -562,7 +562,7 @@ async function runAutoMode(options) {
562
562
  console.error(`\n╔══════════════════════════════════════════╗`);
563
563
  console.error(`║ 自动评论采集模式(CDP 连接已登录浏览器)`);
564
564
  console.error(
565
- `║ 目标国家: ${jobLocations || DEFAULT_TARGET_LOCATIONS.slice(0, 6).join(",") + "..."}`,
565
+ `║ 目标国家: ${jobLocations || getTargetLocations().slice(0, 6).join(",") + "..."}`,
566
566
  );
567
567
  if (jobLocations) console.error(`║ 任务过滤: ${jobLocations}`);
568
568
  console.error(`║ 服务端: ${serverUrl}`);
@@ -636,7 +636,7 @@ async function runAutoMode(options) {
636
636
  const taskNum = totalTasks() + 1;
637
637
 
638
638
  // 非目标国家 → 跳过
639
- if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
639
+ if (loc && !isLocationInList(loc, getTargetLocations())) {
640
640
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
641
641
  skippedCount++;
642
642
  console.error(
@@ -960,10 +960,10 @@ async function runManualMode(url, options) {
960
960
  // ── --save: 去重保存到服务端 ──
961
961
  if (
962
962
  guessedLocation &&
963
- !isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
963
+ !isLocationInList(guessedLocation, getTargetLocations())
964
964
  ) {
965
965
  console.error(`\n猜测国家: ${guessedLocation},非目标国家,跳过保存`);
966
- console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
966
+ console.error(`目标国家: ${getTargetLocations().join(", ")}`);
967
967
  } else {
968
968
  console.error(`\n正在提交 ${authors.length} 个评论作者到服务端...`);
969
969
  console.error(`服务端: ${serverUrl}`);
@@ -8,7 +8,7 @@ import {
8
8
  } from "../lib/constants.js";
9
9
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
10
10
  import { createApiClient } from "../lib/api-client.js";
11
- import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
11
+ import { getTargetLocations } from "../lib/app-config.js";
12
12
  import v8 from "node:v8";
13
13
 
14
14
  const HEAP_RESTART_RATIO = 0.72;
@@ -104,7 +104,7 @@ export async function handlePrecheck(options) {
104
104
  const locations =
105
105
  precheckCountries && precheckCountries.length > 0
106
106
  ? precheckCountries
107
- : DEFAULT_TARGET_LOCATIONS;
107
+ : getTargetLocations();
108
108
 
109
109
  precheckLog(`[Precheck] 服务端: ${serverUrl}`);
110
110
  precheckLog(
@@ -8,7 +8,7 @@
8
8
  * 需要切换时向 clash-daemon 发 failover 命令(fire-and-forget),
9
9
  * 选节点、内容验证、失败回滚均由 daemon 完成
10
10
  * 触发条件:定时轮换(--rotate-min)/ 验证码超时 / 连续错误 ≥ 3 / CDN ≥ 3
11
- * 被封(0 字节空响应,api-interceptor 内部按 30/40/50s 重试 3 次)→ 仍封才触发切换
11
+ * 被封(0 字节空响应 / HTTP 429 限流,api-interceptor 内部按 30/40/50s 重试 3 次)→ 仍封才触发切换
12
12
  * - --ensure-daemon:启动时检查 daemon 是否存活,未存活则自动拉起(默认关闭)
13
13
  */
14
14
 
package/src/cli/tag.js CHANGED
@@ -2,6 +2,7 @@ import { writeFileSync } from "fs";
2
2
  import { randomUUID } from "crypto";
3
3
  import http from "http";
4
4
  import readline from "readline";
5
+ import os from "os";
5
6
  import {
6
7
  getBrowserMemory,
7
8
  formatMemoryWithBrowser,
@@ -14,10 +15,8 @@ import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
14
15
  import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
15
16
  import { getOrCreatePage, isBrowserClosedError } from "../lib/browser/page.js";
16
17
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
17
- import {
18
- DEFAULT_TARGET_LOCATIONS,
19
- isLocationInList,
20
- } from "../lib/target-locations.js";
18
+ import { isLocationInList } from "../lib/target-locations.js";
19
+ import { getTargetLocations } from "../lib/app-config.js";
21
20
  import { delay as randomDelay } from "../lib/delay.js";
22
21
  import { discoverTags } from "../lib/tag-discover.js";
23
22
  import {
@@ -32,26 +31,10 @@ import {
32
31
  closeCaptcha,
33
32
  } from "../scraper/modules/captcha-handler.js";
34
33
 
35
- const ALL_COUNTRIES = DEFAULT_TARGET_LOCATIONS;
36
34
  const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
37
- const DEFAULT_SCORE_COUNTRIES = [
38
- "ES",
39
- "FR",
40
- "DE",
41
- "PT",
42
- "IT",
43
- "NL",
44
- "BE",
45
- "AT",
46
- "IE",
47
- "PL",
48
- "CZ",
49
- "GR",
50
- "HU",
51
- ];
52
35
 
53
36
  function resolveTargetCountries(countries) {
54
- return countries || DEFAULT_SCORE_COUNTRIES;
37
+ return countries || getTargetLocations();
55
38
  }
56
39
 
57
40
  /**
@@ -464,7 +447,7 @@ export async function handleDiscover(parsed) {
464
447
  countries.length === 1 &&
465
448
  countries[0].toUpperCase() === "ALL"
466
449
  ) {
467
- countries = ALL_COUNTRIES;
450
+ countries = getTargetLocations();
468
451
  }
469
452
 
470
453
  if (!countries || countries.length === 0) {
@@ -1058,11 +1041,12 @@ export async function handleScoreAll(parsed) {
1058
1041
  let retrySameTag = false; // 是否重试同一个 tag
1059
1042
  let tag403Attempts = 0; // 当前 tag 已发生 403 的次数
1060
1043
  let bannedCount = 0; // 连续判定“被封”的次数(成功 tag 清零)
1061
- // 按判封次数递增的重试预算:第 1 次判封前重试 30、第 2 次 40、第 3 次及以后 50
1062
- const BAN_RETRY_BUDGETS = [
1063
- parseInt(process.env.TAG_SCOREALL_403_RETRY_1 || "30", 10) || 30,
1064
- parseInt(process.env.TAG_SCOREALL_403_RETRY_2 || "40", 10) || 40,
1065
- parseInt(process.env.TAG_SCOREALL_403_RETRY_3 || "50", 10) || 50,
1044
+ // 403 渐退重试间隔(秒):30/40/50s 各重试一次,仍 403 才判定“被封”一次。
1045
+ // 与 api-interceptor 被封重试(30/40/50s 共 3 次)保持一致
1046
+ const BAN_RETRY_WAITS = [
1047
+ (parseInt(process.env.TAG_SCOREALL_403_RETRY_1 || "30", 10) || 30) * 1000,
1048
+ (parseInt(process.env.TAG_SCOREALL_403_RETRY_2 || "40", 10) || 40) * 1000,
1049
+ (parseInt(process.env.TAG_SCOREALL_403_RETRY_3 || "50", 10) || 50) * 1000,
1066
1050
  ];
1067
1051
 
1068
1052
  // 生成客户端 ID,用于服务端追踪
@@ -1439,26 +1423,32 @@ export async function handleScoreAll(parsed) {
1439
1423
  e.message &&
1440
1424
  (e.message.includes("验证码") || e.message.includes("captcha"));
1441
1425
 
1442
- // ── 403:重试同一 tag,预算按判封次数递增 ──
1426
+ // ── 403:渐退等待 30/40/50s 各重试一次,仍 403 判定“被封” ──
1427
+ // 首次被封重启浏览器(同端口换新会话),后续仍 403 只清 tab
1443
1428
  if (is403) {
1429
+ const retryIndex = tag403Attempts;
1444
1430
  tag403Attempts++;
1445
- const budget =
1446
- BAN_RETRY_BUDGETS[
1447
- Math.min(bannedCount, BAN_RETRY_BUDGETS.length - 1)
1448
- ];
1449
- if (tag403Attempts <= budget) {
1431
+ if (retryIndex < BAN_RETRY_WAITS.length) {
1432
+ const waitMs = BAN_RETRY_WAITS[retryIndex];
1450
1433
  retrySameTag = true;
1451
- log(
1452
- ` 🚫 HTTP 403 (${tag403Attempts}/${budget}) — 重试同一 tag #${currentTag} ...`,
1453
- );
1454
- await recyclePage();
1455
- await randomDelay(2000, 5000);
1434
+ if (retryIndex === 0) {
1435
+ log(
1436
+ ` 🚫 HTTP 403,判定被封,重启浏览器后 ${waitMs / 1000}s 第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
1437
+ );
1438
+ await recycleCdpSession(`首次被封`, { switchPort: false });
1439
+ } else {
1440
+ log(
1441
+ ` 🚫 HTTP 403,判定被封,${waitMs / 1000}s 后第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
1442
+ );
1443
+ await recyclePage();
1444
+ }
1445
+ await new Promise((r) => setTimeout(r, waitMs));
1456
1446
  continue;
1457
1447
  }
1458
1448
  // 重试耗尽:判定“被封”
1459
1449
  bannedCount++;
1460
1450
  log(
1461
- ` 🚫 重试 ${tag403Attempts} 次仍 403,判定账号被封(连续判封 ${bannedCount} 次)`,
1451
+ ` 🚫 30/40/50s 重试 ${BAN_RETRY_WAITS.length} 次仍 403,判定账号被封(连续判封 ${bannedCount} 次)`,
1462
1452
  );
1463
1453
  if (bannedCount >= 2) {
1464
1454
  log(` ⚠️ 连续两次判封 → 汇报服务端 + 触发 daemon fallback`);
@@ -1511,6 +1501,7 @@ export async function handleScoreAll(parsed) {
1511
1501
  errorMessage: e.message,
1512
1502
  stage: "tag-scoring",
1513
1503
  errorStack: "",
1504
+ hostname: os.hostname(),
1514
1505
  }),
1515
1506
  });
1516
1507
  } catch {}
@@ -1640,7 +1631,7 @@ export async function handleTag(parsed) {
1640
1631
  .split(",")
1641
1632
  .map((s) => s.trim().toUpperCase())
1642
1633
  .filter(Boolean)
1643
- : DEFAULT_TARGET_LOCATIONS;
1634
+ : getTargetLocations();
1644
1635
 
1645
1636
  const autoEnrich = enrich || !!discover;
1646
1637
 
@@ -2120,7 +2111,7 @@ export async function handleTagRefresh(parsed) {
2120
2111
  }
2121
2112
 
2122
2113
  // 过滤目标国家的视频和作者
2123
- const targetLocations = DEFAULT_TARGET_LOCATIONS;
2114
+ const targetLocations = getTargetLocations();
2124
2115
  const filteredVideos = videos.filter((v) =>
2125
2116
  isLocationInList(v.locationCreated, targetLocations),
2126
2117
  );
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import os from "os";
2
3
 
3
4
  const MAX_RETRY_WAIT = 5 * 60 * 1000;
4
5
 
@@ -68,13 +69,18 @@ export function createApiClient(opts = {}) {
68
69
  return withRetry(
69
70
  `POST ${url}`,
70
71
  async () => {
72
+ const headers = buildHeaders(
73
+ { "Content-Type": "application/json" },
74
+ clientId,
75
+ meta,
76
+ );
77
+ // error-report 自动带上本机 hostname,供看门狗按机器关联异常
78
+ if (/\/api\/error-report(\?|$)/.test(String(url))) {
79
+ headers["X-Client-Hostname"] = os.hostname();
80
+ }
71
81
  const res = await fetch(url, {
72
82
  method: "POST",
73
- headers: buildHeaders(
74
- { "Content-Type": "application/json" },
75
- clientId,
76
- meta,
77
- ),
83
+ headers,
78
84
  body: JSON.stringify(body),
79
85
  signal: AbortSignal.timeout(30000),
80
86
  });
@@ -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 { data, interceptionError, rawBodyBytes, elapsed } =
347
- await attemptItemListFetch(page, username, log, apiRequestUrlRef);
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
- // 0 字节空响应 = 被封(content-length 缺失/为 0 或截断,统一按封禁处理)。
363
- // 不再依赖页面"出错了/很抱歉"文案判定,静默型封禁(页面正常渲染但 API 空响应)也能识别。
364
- // 默认 30/40/50 秒等待重试 3 次,仍封才抛错交给上层(explore/refresh/refresh-mini 均受益)
365
- if (rawBodyBytes === 0) {
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 空响应(0 字节),判定为被封,${waitMs / 1000}s 后第 ${attempt}/${retryWaits.length} 次重试...`,
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 空响应(0 字节),重试 ${retryWaits.length} 次仍被封`,
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 { DEFAULT_TARGET_LOCATIONS_CSV } from "./target-locations.js";
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
 
@@ -3,6 +3,8 @@
3
3
  * 供 tag / comments 等业务命令复用,避免重复实现。
4
4
  */
5
5
 
6
+ import os from "os";
7
+
6
8
  /**
7
9
  * 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
8
10
  * 复用 ../clash/ipc.js 现成的 writeCommand 接口,不改动 daemon/ipc 模块。
@@ -47,6 +49,7 @@ export async function reportClientErrorAndFailover({
47
49
  errorMessage: message,
48
50
  stage,
49
51
  errorStack: "",
52
+ hostname: os.hostname(), // 机器标识:看门狗按 hostname 关联本机异常
50
53
  });
51
54
  } catch {}
52
55
  await triggerClashFailover(message, log);
@@ -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 { DEFAULT_TARGET_LOCATIONS_CSV } from "./target-locations.js";
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);
@@ -12,6 +12,7 @@ const DEFAULT_TARGET_LOCATIONS = [
12
12
  "IT",
13
13
  "IE",
14
14
  "AT",
15
+ "TH",
15
16
  "US",
16
17
  ];
17
18
 
@@ -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 = DEFAULT_TARGET_LOCATIONS_CSV,
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 = DEFAULT_TARGET_LOCATIONS_CSV,
26
+ location = getTargetLocations().join(","),
27
27
  locationMode = "explore", // "explore" | "refresh"
28
28
  proxyServer = null,
29
29
  webserverUrl = null,
@@ -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
- isLocationInList,
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 {
@@ -665,7 +663,7 @@ function getModifiedTargetUsersOutsideLocations(targetLocations = []) {
665
663
  function getTargetRefreshStats(hours = 24) {
666
664
  if (!getDb()) return { countries: [], total: 0, refreshed: 0 };
667
665
 
668
- const targetLocations = DEFAULT_TARGET_LOCATIONS;
666
+ const targetLocations = getTargetLocations();
669
667
  const placeholders = targetLocations.map(() => "?").join(", ");
670
668
 
671
669
  // 按国家统计总数
@@ -2226,7 +2224,7 @@ export function createStore(filePath, options = {}) {
2226
2224
  const now = Date.now();
2227
2225
  const threshold = now - maxAgeSeconds * 1000;
2228
2226
  const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
2229
- const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
2227
+ const targetLocations = locations || getTargetLocations();
2230
2228
  const placeholders = targetLocations.map(() => "?").join(",");
2231
2229
  const row = getDb()
2232
2230
  .prepare(
@@ -2261,7 +2259,7 @@ export function createStore(filePath, options = {}) {
2261
2259
  const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
2262
2260
 
2263
2261
  // 筛选目标国家用户,按 refreshTime 升序取最远的(没有则默认 2016-01-01)
2264
- const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
2262
+ const targetLocations = locations || getTargetLocations();
2265
2263
  const targetUsers = data.filter(
2266
2264
  (u) =>
2267
2265
  u.ttSeller &&
@@ -2405,7 +2403,7 @@ export function createStore(filePath, options = {}) {
2405
2403
  const now = Date.now();
2406
2404
  const threshold = now - intervalSeconds * 1000;
2407
2405
  const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
2408
- const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
2406
+ const targetLocations = locations || getTargetLocations();
2409
2407
  const placeholders = targetLocations.map(() => "?").join(",");
2410
2408
 
2411
2409
  // SELECT 候选用户
@@ -2477,10 +2475,12 @@ export function createStore(filePath, options = {}) {
2477
2475
  username,
2478
2476
  stage,
2479
2477
  errorStack,
2478
+ hostname,
2480
2479
  ) {
2481
2480
  const existing = clientErrors.get(userId);
2482
2481
  if (existing) {
2483
2482
  existing.timestamp = Date.now();
2483
+ if (hostname) existing.hostname = hostname;
2484
2484
  if (errorType === "captcha") {
2485
2485
  existing.captchaCount = (existing.captchaCount || 0) + 1;
2486
2486
  if (!existing.captchaStage) existing.captchaStage = stage || "";
@@ -2498,6 +2498,7 @@ export function createStore(filePath, options = {}) {
2498
2498
  } else {
2499
2499
  clientErrors.set(userId, {
2500
2500
  userId,
2501
+ hostname: hostname || "",
2501
2502
  errorType,
2502
2503
  errorMessage: errorMessage || "",
2503
2504
  errorStack: errorStack || "",