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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.142",
3
+ "version": "1.4.145",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,13 +15,16 @@ import Database from "better-sqlite3";
15
15
  import { initDb, getDb } from "../src/watch/db-schema.js";
16
16
  import { loadLlmConfig, reloadLlmConfig } from "../src/watch/llm-config.js";
17
17
  import { scoreJobsBatch } from "../src/watch/llm-scoring.js";
18
- import { DEFAULT_TARGET_LOCATIONS } from "../src/lib/target-locations.js";
18
+ import { getTargetLocations, reloadConfig } from "../src/lib/app-config.js";
19
19
 
20
20
  // ═══════════════════════════════════════════════════════════════════
21
21
  // 常量
22
22
  // ═══════════════════════════════════════════════════════════════════
23
23
 
24
- const LOCATIONS = DEFAULT_TARGET_LOCATIONS;
24
+ // 目标国家:每轮 tick 前 reloadConfig() 重读配置文件,捕捉服务端(设置页)修改
25
+ function currentLocations() {
26
+ return getTargetLocations();
27
+ }
25
28
 
26
29
  const FILTER = "video_count>6 AND follower_count>0 AND following_count>0";
27
30
 
@@ -161,7 +164,7 @@ const vals = (row, cols) => cols.map((c) => row[c] ?? null);
161
164
  const ph = (n) => Array(n).fill("?").join(", ");
162
165
 
163
166
  /** 构建 WHERE 子句(含国家过滤)。strict=true 时仅匹配明确国家,不含 NULL/空 */
164
- function buildWhere(locations = LOCATIONS, strict = false) {
167
+ function buildWhere(locations = currentLocations(), strict = false) {
165
168
  const locs = [
166
169
  ...new Set(
167
170
  locations.map((l) => String(l).trim().toUpperCase()).filter(Boolean),
@@ -268,14 +271,14 @@ function countPendingByCountry() {
268
271
  const map = {};
269
272
  for (const r of rows) map[r.loc] = r.c;
270
273
  // 确保所有目标国家都有值
271
- for (const loc of LOCATIONS) {
274
+ for (const loc of currentLocations()) {
272
275
  if (!(loc in map)) map[loc] = 0;
273
276
  }
274
277
  return map;
275
278
  }
276
279
 
277
280
  function findNeedyCountries(byCountry, threshold) {
278
- return LOCATIONS.filter((loc) => (byCountry[loc] || 0) < threshold);
281
+ return currentLocations().filter((loc) => (byCountry[loc] || 0) < threshold);
279
282
  }
280
283
 
281
284
  // ═══════════════════════════════════════════════════════════════════
@@ -335,7 +338,7 @@ async function refillLLM(archiveDb, needyLocations) {
335
338
 
336
339
  // 2. LLM 打分
337
340
  const ts = Date.now();
338
- const scores = await scoreJobsBatch(samples, LOCATIONS);
341
+ const scores = await scoreJobsBatch(samples, currentLocations());
339
342
  log(" ", `${loc} 打分${scores.length}条 ${Date.now() - ts}ms`);
340
343
 
341
344
  if (
@@ -406,6 +409,7 @@ async function tick(archiveDb, opts) {
406
409
  const t0 = Date.now();
407
410
 
408
411
  archiveNewUsers(archiveDb);
412
+ reloadConfig(); // 每轮重读目标国家配置,支持服务端(设置页)修改
409
413
  if (opts.llm) reloadLlmConfig(); // 每轮重载,支持热切换模型
410
414
 
411
415
  const byCountry = countPendingByCountry();
@@ -414,7 +418,9 @@ async function tick(archiveDb, opts) {
414
418
  if (!needy.length) {
415
419
  log(
416
420
  "--",
417
- `轮${tickN} 全部充足 | ${LOCATIONS.map((l) => `${l}:${byCountry[l]}`).join(" ")}`,
421
+ `轮${tickN} 全部充足 | ${currentLocations()
422
+ .map((l) => `${l}:${byCountry[l]}`)
423
+ .join(" ")}`,
418
424
  );
419
425
  return;
420
426
  }
@@ -0,0 +1,122 @@
1
+ // 临时验证:fetchUserVideosAPI 对 HTTP 429 / ratelimit triggered 判定为"被封"
2
+ // 运行:RM_BLOCKED_WAIT_MS=1 node scripts/test-api-interceptor-429.mjs
3
+ import { fetchUserVideosAPI } from "../src/lib/api-interceptor.js";
4
+
5
+ // 构造一个可被 waitForResponse 返回的 429 响应
6
+ function make429Response() {
7
+ return {
8
+ url: () => "https://www.tiktok.com/api/post/item_list/?...",
9
+ status: () => 429,
10
+ headers: () => ({ "content-length": "19" }),
11
+ json: async () => {
12
+ throw new Error("Unexpected token 'r'...");
13
+ },
14
+ body: async () => Buffer.from("ratelimit triggered"),
15
+ };
16
+ }
17
+
18
+ // 构造一个正常响应(itemList 非空)
19
+ function make200Response(username) {
20
+ const itemList = [
21
+ { id: "111", stats: { playCount: 1 }, isECVideo: 0, isAd: 0 },
22
+ ];
23
+ return {
24
+ url: () => "https://www.tiktok.com/api/post/item_list/?...",
25
+ status: () => 200,
26
+ headers: () => ({}),
27
+ json: async () => ({ itemList, cursor: 0, hasMore: false }),
28
+ body: async () => Buffer.from(JSON.stringify({ itemList })),
29
+ username,
30
+ };
31
+ }
32
+
33
+ // 构造 mock page:封禁场景时 waitForResponse 返回 429 响应
34
+ function makePage(username, respFactory) {
35
+ const page = {
36
+ _handlers: {},
37
+ on(event, fn) {
38
+ (page._handlers[event] ||= []).push(fn);
39
+ return page;
40
+ },
41
+ off(event) {
42
+ delete page._handlers[event];
43
+ return page;
44
+ },
45
+ url: () => `https://www.tiktok.com/@${username}`,
46
+ goto: async () => {},
47
+ // checkCdpHealth 用 () => document.readyState(fn.length===0)→ 返回 "complete";
48
+ // detectPageError 用 (patterns)=>...(fn.length===1)→ 返回 null(页面无错误文案)
49
+ evaluate: async (fn) => (fn.length === 1 ? null : "complete"),
50
+ reload: async () => {},
51
+ waitForFunction: async () => null,
52
+ waitForResponse: async (pred, opts) => {
53
+ const resp = respFactory();
54
+ if (!pred(resp)) throw new Error("response 不匹配 item_list");
55
+ return resp;
56
+ },
57
+ };
58
+ return page;
59
+ }
60
+
61
+ let pass = 0;
62
+ let fail = 0;
63
+
64
+ async function expectReject(name, fn, expectedMsg) {
65
+ try {
66
+ await fn();
67
+ fail++;
68
+ console.error(`✗ ${name}: 未抛错(期望 "${expectedMsg}")`);
69
+ } catch (e) {
70
+ if (e.message === expectedMsg || e.message.includes(expectedMsg)) {
71
+ pass++;
72
+ console.log(`✓ ${name}: 抛错 "${e.message}"`);
73
+ } else {
74
+ fail++;
75
+ console.error(
76
+ `✗ ${name}: 抛错 "${e.message}"(期望含 "${expectedMsg}")`,
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ async function expectResolve(name, fn) {
83
+ try {
84
+ const r = await fn();
85
+ if (r instanceof Map && r.size === 1) {
86
+ pass++;
87
+ console.log(`✓ ${name}: 正常返回 ${r.size} 个视频`);
88
+ } else {
89
+ fail++;
90
+ console.error(`✗ ${name}: 返回异常 ${JSON.stringify(r)}`);
91
+ }
92
+ } catch (e) {
93
+ fail++;
94
+ console.error(`✗ ${name}: 抛错 ${e.message}`);
95
+ }
96
+ }
97
+
98
+ // 用例1:429 + ratelimit triggered → 判定为"被封"
99
+ await expectReject(
100
+ "HTTP 429 ratelimit triggered 判为被封",
101
+ () =>
102
+ fetchUserVideosAPI(
103
+ makePage("fumolbbf08o", make429Response),
104
+ "fumolbbf08o",
105
+ 5,
106
+ () => {},
107
+ ),
108
+ "被封: fumolbbf08o",
109
+ );
110
+
111
+ // 用例2:正常 200 itemList → 不误判,正常返回视频
112
+ await expectResolve("HTTP 200 正常 itemList 不被误伤", () =>
113
+ fetchUserVideosAPI(
114
+ makePage("fumolbbf08o", make200Response),
115
+ "fumolbbf08o",
116
+ 5,
117
+ () => {},
118
+ ),
119
+ );
120
+
121
+ console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
122
+ process.exit(fail > 0 ? 1 : 0);
@@ -32,6 +32,12 @@ const state = {
32
32
  closes: 0, // closeAllConnections(全断)调用次数
33
33
  idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
34
34
  failIdleClose: false, // 模拟优雅断连失败
35
+ // 订阅切换感知(巡检开头候选集存在性校验)相关
36
+ proxyMap: { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} }, // 当前 /proxies 中的代理名
37
+ pruneResult: { kept: 2, removed: 0, allGone: false },
38
+ pruneCalls: 0,
39
+ probeCalls: 0,
40
+ failProxies: false,
35
41
  };
36
42
 
37
43
  function resetState() {
@@ -50,6 +56,11 @@ function resetState() {
50
56
  state.closes = 0;
51
57
  state.idleCloses = 0;
52
58
  state.failIdleClose = false;
59
+ state.proxyMap = { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} };
60
+ state.pruneResult = { kept: 2, removed: 0, allGone: false };
61
+ state.pruneCalls = 0;
62
+ state.probeCalls = 0;
63
+ state.failProxies = false;
53
64
  }
54
65
 
55
66
  // ── 注册模块 mock(必须在 import daemon.js 之前) ──
@@ -73,16 +84,28 @@ before(async () => {
73
84
  return true;
74
85
  },
75
86
  applyDiscovered: () => {},
76
- runDelayProbe: async () => ({
77
- nodes: [...state.nodes],
78
- total: state.nodes.length,
79
- }),
87
+ checkClashAlive: () => true,
88
+ pruneHealthyListByCurrentProxies: () => {
89
+ state.pruneCalls += 1;
90
+ return { ...state.pruneResult };
91
+ },
92
+ runDelayProbe: async () => {
93
+ state.probeCalls += 1;
94
+ return {
95
+ nodes: [...state.nodes],
96
+ total: state.nodes.length,
97
+ };
98
+ },
80
99
  saveHealthyList: () => {},
81
100
  },
82
101
  });
83
102
  mock.module("../src/clash/api.js", {
84
103
  namedExports: {
85
104
  configure: () => {},
105
+ getProxies: async () => {
106
+ if (state.failProxies) throw new Error("mock /proxies 不可达");
107
+ return { proxies: { ...state.proxyMap } };
108
+ },
86
109
  getCurrentNode: async () => state.currentNode,
87
110
  closeAllConnections: async () => {
88
111
  state.closes += 1;
@@ -242,3 +265,37 @@ test("F10 强切(force):冷却期内探针健康仍强制轮换", async ()
242
265
  "强切后应更新锚点(给新节点观察期)",
243
266
  );
244
267
  });
268
+
269
+ // ── 订阅切换感知:巡检开头候选集存在性校验(2026-08-29) ──
270
+
271
+ test("S1 候选集全部失效:当前节点健康也立即触发 delay 初测", async () => {
272
+ resetState();
273
+ const { d, c } = makeCtx();
274
+ state.health = { ...state.health, healthy: true }; // 当前节点健康
275
+ state.pruneResult = { kept: 0, removed: 2, allGone: true };
276
+ await d._healthCheckController(c);
277
+ assert.equal(state.pruneCalls, 1, "巡检应执行候选集校验");
278
+ // 初测经串行锁排队执行,等队列清空后断言
279
+ await d._withLock(() => {});
280
+ assert.equal(state.probeCalls, 1, "全失效应立即触发 delay 初测");
281
+ });
282
+
283
+ test("S2 候选集部分失效:剔除后不触发初测,巡检正常", async () => {
284
+ resetState();
285
+ const { d, c } = makeCtx();
286
+ state.pruneResult = { kept: 1, removed: 1, allGone: false };
287
+ await d._healthCheckController(c);
288
+ await d._withLock(() => {});
289
+ assert.equal(state.pruneCalls, 1, "应执行候选集校验");
290
+ assert.equal(state.probeCalls, 0, "部分失效不应触发初测");
291
+ });
292
+
293
+ test("S3 /proxies 拉取失败:静默跳过校验,不阻断巡检", async () => {
294
+ resetState();
295
+ const { d, c } = makeCtx();
296
+ state.failProxies = true;
297
+ await d._healthCheckController(c);
298
+ await d._withLock(() => {});
299
+ assert.equal(state.pruneCalls, 0, "API 失败不应执行校验");
300
+ assert.equal(state.probeCalls, 0, "API 失败不应触发初测");
301
+ });
@@ -441,6 +441,47 @@ export function saveHealthyList(nodes, delays = {}) {
441
441
  }
442
442
  }
443
443
 
444
+ /**
445
+ * 剔除候选集中已不在当前内核配置中的节点(订阅切换 / 订阅更新场景)
446
+ *
447
+ * 基于原始全量列表过滤(不做国家过滤),避免把"国家过滤后的子集"写回文件造成数据丢失。
448
+ * 仅在确有剔除时写回文件。
449
+ *
450
+ * @param {Set<string>|string[]} validNames - 当前 /proxies 中的代理名集合
451
+ * @returns {{kept: number, removed: number, allGone: boolean}}
452
+ * allGone: 原列表非空且过滤后为空(判定"订阅已切换")
453
+ */
454
+ export function pruneHealthyListByCurrentProxies(validNames) {
455
+ try {
456
+ if (!existsSync(_healthyFile)) {
457
+ return { kept: 0, removed: 0, allGone: false };
458
+ }
459
+ const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
460
+ const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
461
+ const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
462
+ const valid = new Set(validNames);
463
+ const keptNodes = allNodes.filter((n) => valid.has(n));
464
+ const removed = allNodes.length - keptNodes.length;
465
+ if (removed === 0) {
466
+ return { kept: keptNodes.length, removed: 0, allGone: false };
467
+ }
468
+ const keptDelays = {};
469
+ for (const n of keptNodes) {
470
+ const v = allDelays[n];
471
+ if (typeof v === "number" && v > 0) keptDelays[n] = v;
472
+ }
473
+ saveHealthyList(keptNodes, keptDelays);
474
+ return {
475
+ kept: keptNodes.length,
476
+ removed,
477
+ allGone: keptNodes.length === 0 && allNodes.length > 0,
478
+ };
479
+ } catch (e) {
480
+ console.error(`[checker] 剔除失效候选节点失败: ${e.message}`);
481
+ return { kept: 0, removed: 0, allGone: false };
482
+ }
483
+ }
484
+
444
485
  function _emptyList() {
445
486
  return {
446
487
  updated: "",
@@ -370,6 +370,32 @@ export class ClashDaemon {
370
370
  title: h.title,
371
371
  });
372
372
 
373
+ // 订阅切换感知:候选集节点若已不在当前内核配置中,旧候选集无意义
374
+ // —— 全失效立即触发 delay 初测刷新,部分失效剔除写回。
375
+ // /proxies 拉取失败时静默跳过,不阻断巡检。
376
+ try {
377
+ const proxies = await api.getProxies();
378
+ const valid = new Set(Object.keys(proxies?.proxies || {}));
379
+ const pruned = checker.pruneHealthyListByCurrentProxies(valid);
380
+ if (pruned.removed > 0) {
381
+ if (pruned.allGone) {
382
+ this._log(
383
+ `🔁 [${c.proxyPort}] 候选集 ${pruned.removed} 个节点全部失效(疑似订阅已切换),立即触发 delay 初测`,
384
+ );
385
+ // 初测经串行锁排队执行;本轮巡检直接返回,避免长时间占用锁饿死其它控制端
386
+ this._withLock(() => this._runDelayProbeController(c)).catch(
387
+ () => {},
388
+ );
389
+ return;
390
+ }
391
+ this._log(
392
+ `🧹 [${c.proxyPort}] 候选集剔除 ${pruned.removed} 个失效节点,剩余 ${pruned.kept} 个`,
393
+ );
394
+ }
395
+ } catch {
396
+ // 忽略:校验仅作兜底,失败不影响巡检
397
+ }
398
+
373
399
  if (h.healthy) {
374
400
  c._apiFailCount = 0;
375
401
  if (Math.random() < 0.17)
@@ -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