tt-help-cli-ycl 1.4.144 → 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.144",
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": {
@@ -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)