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.
@@ -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
 
@@ -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);
@@ -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> 起始 CDP 端口,端口池默认为 N~N+9(默认 7222)",
363
+ " --port <N> 固定 CDP 端口(默认 7222,全程不切换)",
362
364
  " --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
363
365
  " --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
364
366
  " 行为:",
365
- " 连续 3 次 HTTP 403 → 判断账号被封 → 等 2 分钟后自动切换到池中另一端口",
367
+ " 错误只打印不自愈:403 连续 3 次 → 上报服务端并触发 daemon 换节点;",
368
+ " 网络错误/其他错误连续 11 次 → 退出;验证码连续 4 次未人工解决 → 退出",
369
+ " 成功一个 tag 全部计数清零;浏览器窗口被手动关闭 → 自动退出",
366
370
  " 环境变量:",
367
- " TAG_SCOREALL_PORT_POOL_SIZE=N 端口池大小(默认 10)",
368
- " TAG_SCOREALL_RECYCLE_RSS_MB=N 内存回收阈值 RSS MB(默认 2000)",
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",
@@ -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 {
@@ -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 = DEFAULT_TARGET_LOCATIONS;
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 || DEFAULT_TARGET_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 || DEFAULT_TARGET_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 || DEFAULT_TARGET_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,