tt-help-cli-ycl 1.4.162 → 1.4.164

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.162",
3
+ "version": "1.4.164",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -175,20 +175,31 @@ export class ClashDaemon {
175
175
 
176
176
  /**
177
177
  * 逐个尝试候选节点并内容验证(巡检/failover/白名单守卫共用切换循环)
178
+ * @param {object} [opts]
179
+ * @param {Set<string>} [opts.occupied] 同订阅其它实例已占用的节点(互斥避让);
180
+ * 有空闲候选时跳过被占用节点;候选全被占/仅一个候选时放行(“只允许一个节点”例外)
178
181
  * @returns {{target: string|null, h2: object|null}} 第一个满足(健康或壳页)的候选;全部失败返回 {target:null}
179
182
  */
180
- async _tryRotateCandidates(c, ordered, currentNode, reason = "") {
183
+ async _tryRotateCandidates(c, ordered, currentNode, reason = "", opts = {}) {
181
184
  const tag = reason ? ` [原因: ${reason}]` : "";
185
+ // 同订阅互斥: 计算空闲候选(排除被占用与当前节点);全被占时不排除(放行)
186
+ const occ = opts.occupied || null;
187
+ const free = occ ? ordered.filter((x) => !occ.has(x) && x !== currentNode) : null;
182
188
  let target = null;
183
189
  let h2 = null;
184
190
  for (const cand of ordered) {
185
191
  if (cand === currentNode) continue;
192
+ if (free && free.length > 0 && !free.includes(cand)) {
193
+ this._log(` ⏭ [${c.proxyPort}] ${cand} 被同订阅其它实例占用,跳过`);
194
+ continue;
195
+ }
186
196
  this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand}${tag}`);
187
197
  if (!(await checker.switchToNode(cand))) {
188
198
  this._log(` ↺ ${cand} 切换失败,下一个`);
189
199
  continue;
190
200
  }
191
201
  this._markSwitch(c, cand);
202
+ c._currentNode = cand; // 同订阅互斥: 维护当前节点内存快照
192
203
  h2 = checker.checkNodeHealth();
193
204
  if (h2.healthy || this._isShellPage(h2)) {
194
205
  target = cand;
@@ -199,6 +210,120 @@ export class ClashDaemon {
199
210
  return { target, h2 };
200
211
  }
201
212
 
213
+ // ── 同订阅节点互斥(同 URL 多实例不允许同时使用同一节点,除非无空闲可用节点) ──
214
+
215
+ /** 内置实例序号(proxyPort 推导;0 = 非内置段如外部 Verge,不参与互斥) */
216
+ _instanceIndexOf(c) {
217
+ const idx = (c?.proxyPort || 0) - DISCOVER_DEFAULTS.proxyPort + 1;
218
+ return idx >= 1 && idx <= RUNTIME_MAX_INSTANCES ? idx : 0;
219
+ }
220
+
221
+ /** 同 URL 订阅的同组其它实例控制器(before=true 仅序号比自己小的) */
222
+ _mutexPeers(c, beforeOnly = false) {
223
+ const idx = this._instanceIndexOf(c);
224
+ if (!idx) return [];
225
+ let stored;
226
+ try {
227
+ stored = runtime.readStoredSubscriptions();
228
+ } catch {
229
+ return [];
230
+ }
231
+ const myUrl = stored[idx - 1]?.url;
232
+ if (!myUrl) return [];
233
+ const out = [];
234
+ for (let i = 1; i <= stored.length; i++) {
235
+ if (i === idx || stored[i - 1]?.url !== myUrl) continue;
236
+ if (beforeOnly && i > idx) continue;
237
+ const peer = this._controllers.find((x) => this._instanceIndexOf(x) === i);
238
+ if (peer && peer !== c) out.push(peer);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /** 当前被同组其它实例占用的节点集合(无同组返回 null = 不启用避让) */
244
+ _occupiedNodes(c) {
245
+ const peers = this._mutexPeers(c);
246
+ if (!peers.length) return null;
247
+ const occ = new Set();
248
+ for (const p of peers) if (p._currentNode) occ.add(p._currentNode);
249
+ return occ.size ? occ : null;
250
+ }
251
+
252
+ /**
253
+ * 巡检常驻互斥对齐(与白名单守卫同口径,不受 failover 冷却约束):
254
+ * 与序号更小的同组实例撞节点 → 自己让位(序号小的保留);10 分钟防抖。
255
+ * 让位无空闲可用节点(候选全被占/全不可用)→ 保留现状,下轮再试。
256
+ * @returns {Promise<boolean>} true=已让位切换(调用方跳过自身 health 写入)
257
+ */
258
+ async _enforceMutex(c, node) {
259
+ if (!node || node === "?") return false;
260
+ const conflict = this._mutexPeers(c, true).find((p) => p._currentNode === node);
261
+ if (!conflict) return false;
262
+ if (Date.now() - (c._lastMutexAlignAt || 0) < 600_000) return false;
263
+ c._lastMutexAlignAt = Date.now();
264
+ this._log(
265
+ `🔒 [${c.proxyPort}] 同订阅互斥: 节点 ${node} 已被实例@${conflict.proxyPort} 占用,让位切换`,
266
+ );
267
+ const { nodes } = checker.loadHealthyList();
268
+ if (!nodes.length) return false;
269
+ const ordered = this._sortCandidates(nodes, c);
270
+ const { target, h2 } = await this._tryRotateCandidates(
271
+ c, ordered, node, "同订阅节点互斥", { occupied: this._occupiedNodes(c) },
272
+ );
273
+ if (!target) {
274
+ await checker.switchToNode(node).catch(() => {});
275
+ this._log(`🔒 [${c.proxyPort}] 互斥让位失败(无空闲可用节点),暂保留 ${node}`);
276
+ return false;
277
+ }
278
+ await this._disconnect(c);
279
+ this._markSwitchTime(c);
280
+ this._writeHealth(c, {
281
+ node: target,
282
+ healthy: h2.healthy,
283
+ httpCode: h2.httpCode,
284
+ timeTotal: h2.timeTotal,
285
+ sizeDownload: h2.sizeDownload,
286
+ title: h2.title,
287
+ lastSwitch: `同订阅互斥: ${node} → ${target}`,
288
+ });
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * 手动切换后让位: 同组其它实例若占用了新节点 → 挪它(用户显式意图优先,不挪切换者)
294
+ */
295
+ async _mutexYieldTo(c, node) {
296
+ for (const p of this._mutexPeers(c)) {
297
+ if (p._currentNode !== node || p._probing) continue;
298
+ this._useController(p);
299
+ this._log(
300
+ `🔒 [${p.proxyPort}] 同订阅互斥: 节点 ${node} 被手动切换给 [${c.proxyPort}],让位`,
301
+ );
302
+ const { nodes } = checker.loadHealthyList();
303
+ if (!nodes.length) continue;
304
+ const ordered = this._sortCandidates(nodes, p);
305
+ const { target, h2 } = await this._tryRotateCandidates(
306
+ p, ordered, node, "同订阅互斥(手动让位)", { occupied: this._occupiedNodes(p) },
307
+ );
308
+ if (!target) {
309
+ await checker.switchToNode(node).catch(() => {});
310
+ continue;
311
+ }
312
+ await this._disconnect(p);
313
+ this._markSwitchTime(p);
314
+ this._writeHealth(p, {
315
+ node: target,
316
+ healthy: h2.healthy,
317
+ httpCode: h2.httpCode,
318
+ timeTotal: h2.timeTotal,
319
+ sizeDownload: h2.sizeDownload,
320
+ title: h2.title,
321
+ lastSwitch: `同订阅互斥(手动): ${node} → ${target}`,
322
+ });
323
+ }
324
+ this._useController(c); // 恢复切换者上下文
325
+ }
326
+
202
327
  /** 将全局 api/checker 上下文指向指定控制器 */
203
328
  _useController(c) {
204
329
  api.configure({ port: c.port, secret: c.secret });
@@ -486,8 +611,7 @@ export class ClashDaemon {
486
611
  const node = await api
487
612
  .getCurrentNode(checker.CONFIG.groupName)
488
613
  .catch(() => "?");
489
-
490
- // 订阅信息 + 流量采样(随巡检周期,差分算速率)
614
+ if (node && node !== "?") c._currentNode = node; // 同订阅互斥: 维护当前节点内存快照
491
615
  c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
492
616
  const conn = await api.getConnections().catch(() => null);
493
617
  if (conn) {
@@ -555,25 +679,26 @@ export class ClashDaemon {
555
679
  }
556
680
 
557
681
  if (h.healthy) {
558
- c._apiFailCount = 0;
559
682
  if (Math.random() < 0.17)
560
683
  this._log(
561
684
  `✓ [${c.proxyPort}] ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
562
685
  );
563
- // 白名单守卫:节点健康但不在咱们节点列表内(如人工在 Clash Verge 界面手动切换)→ 强制切回
564
- await this._enforceWhitelist(c, node);
686
+ // 白名单守卫 + 同订阅互斥:节点健康但不在咱们节点列表内 → 强制切回;
687
+ // 与序号更小的同组实例撞节点 → 让位
688
+ if (await this._enforceWhitelist(c, node)) return;
689
+ if (await this._enforceMutex(c, node)) return;
565
690
  return;
566
691
  }
567
692
 
568
693
  // 空壳页(TikTok SSR 降级响应):链路可用、浏览器会自行渲染,
569
694
  // 切换无收益且会打断业务连接,不触发切换
570
695
  if (this._isShellPage(h)) {
571
- c._apiFailCount = 0;
572
696
  this._log(
573
697
  `~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
574
698
  );
575
- // 白名单守卫与主分支同口径:壳页=链路正常,但列表外节点仍不允许
699
+ // 白名单守卫与互斥:壳页=链路正常,但列表外节点仍不允许;撞节点让位同口径
576
700
  if (await this._enforceWhitelist(c, node)) return;
701
+ if (await this._enforceMutex(c, node)) return;
577
702
  return this._writeHealth(c, {
578
703
  node,
579
704
  healthy: true,
@@ -594,8 +719,9 @@ export class ClashDaemon {
594
719
  this._log(
595
720
  `~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
596
721
  );
597
- // 白名单守卫:复测恢复同样守口径(列表外节点不允许)
722
+ // 白名单守卫与互斥:复测恢复同样守口径(列表外不允许;撞节点让位)
598
723
  if (await this._enforceWhitelist(c, node)) return;
724
+ if (await this._enforceMutex(c, node)) return;
599
725
  return this._writeHealth(c, {
600
726
  node,
601
727
  healthy: true,
@@ -638,13 +764,15 @@ export class ClashDaemon {
638
764
  `✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
639
765
  );
640
766
  // 逐个切换候选 + 内容验证,第一个满足即采用
641
- // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
767
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
768
+ // 同订阅互斥: 避开同组其它实例占用的节点
642
769
  const ordered = this._sortCandidates(nodes, c);
643
770
  const { target, h2 } = await this._tryRotateCandidates(
644
771
  c,
645
772
  ordered,
646
773
  node,
647
774
  reason,
775
+ { occupied: this._occupiedNodes(c) },
648
776
  );
649
777
 
650
778
  if (!target) {
@@ -720,6 +848,8 @@ export class ClashDaemon {
720
848
  d._subsSnapshot = old._subsSnapshot;
721
849
  d._applyResult = old._applyResult;
722
850
  d._lastProbeAt = old._lastProbeAt;
851
+ d._currentNode = old._currentNode;
852
+ d._lastMutexAlignAt = old._lastMutexAlignAt;
723
853
  }
724
854
  return true;
725
855
  });
@@ -776,6 +906,7 @@ export class ClashDaemon {
776
906
  ordered,
777
907
  node,
778
908
  "白名单守卫",
909
+ { occupied: this._occupiedNodes(c) },
779
910
  );
780
911
  if (!target) {
781
912
  // 与 failover/巡检切换口径一致:候选全部不可用时恢复原节点
@@ -926,9 +1057,12 @@ export class ClashDaemon {
926
1057
  }
927
1058
  this._markSwitch(c, cmd.node);
928
1059
  this._markSwitchTime(c);
1060
+ c._currentNode = cmd.node; // 同订阅互斥: 维护当前节点内存快照
929
1061
  const h = checker.checkNodeHealth();
930
1062
  // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
931
1063
  await this._disconnect(c);
1064
+ // 同订阅互斥: 让同组占用新节点的其它实例让位(用户显式意图优先,不挪切换者)
1065
+ await this._mutexYieldTo(c, cmd.node);
932
1066
  this._log(
933
1067
  `🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
934
1068
  );
@@ -1014,13 +1148,15 @@ export class ClashDaemon {
1014
1148
  return;
1015
1149
  }
1016
1150
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
1017
- // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
1151
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
1152
+ // 同订阅互斥: 避开同组其它实例占用的节点
1018
1153
  const ordered = this._sortCandidates(nodes, c);
1019
1154
  const { target, h2 } = await this._tryRotateCandidates(
1020
1155
  c,
1021
1156
  ordered,
1022
1157
  node,
1023
1158
  reason,
1159
+ { occupied: this._occupiedNodes(c) },
1024
1160
  );
1025
1161
  // 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
1026
1162
  this._markSwitchTime(c);
@@ -78,12 +78,13 @@ const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317
78
78
 
79
79
  /**
80
80
  * 内核/面板下载源(顺序 failover):
81
- * 1. ~/.tt-help.json → clash.vendorBase 或环境变量 TT_VENDOR_BASE(覆盖)
82
- * 2. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
83
- * (内网 watch 优先:213 等内网机器走内网下载,不绕公网)
84
- * 3. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
85
- * 4. 自建服务器内网(农场机器直连)
86
- * 5. GitHub Releases(仅海外/兜底;农场不可达属预期)
81
+ * 1. ~/.tt-help.json → clash.vendorBase / 环境变量 TT_VENDOR_BASE(显式覆盖,逃生舱)
82
+ * 2. HTTPS 域名镜像(最优默认源:域名+TLS+CDN,比公网 IP 直连更稳)
83
+ * 3. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
84
+ * (内网机器的 server 为内网地址,作为 CDN 不可达时的就近回退)
85
+ * 4. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
86
+ * 5. 自建服务器内网(农场机器直连)
87
+ * 6. GitHub Releases(仅海外/兜底;农场不可达属预期)
87
88
  */
88
89
  export function vendorBases() {
89
90
  const urls = [];
@@ -95,6 +96,14 @@ export function vendorBases() {
95
96
  if (existsSync(cfgPath)) {
96
97
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
97
98
  push(cfg?.clash?.vendorBase);
99
+ }
100
+ } catch {}
101
+ push(process.env.TT_VENDOR_BASE);
102
+ push("https://call.huochedejia.com/vendor");
103
+ try {
104
+ const cfgPath = join(homedir(), ".tt-help.json");
105
+ if (existsSync(cfgPath)) {
106
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
98
107
  const server = String(cfg?.server || "").replace(/\/+$/, "");
99
108
  // 显式配置的服务端地址 → 推导 vendor 下载源(未配置/非 http 则跳过,避免污染 127.0.0.1 默认值)
100
109
  if (/^https?:\/\//.test(server) && !/^https?:\/\/127\.0\.0\.1/.test(server)) {
@@ -102,7 +111,6 @@ export function vendorBases() {
102
111
  }
103
112
  }
104
113
  } catch {}
105
- push(process.env.TT_VENDOR_BASE);
106
114
  push("http://117.71.53.99:17301/scripts/vendor");
107
115
  push("http://172.18.154.201:3001/scripts/vendor");
108
116
  push("https://github.com/MetaCubeX/mihomo/releases/download");
@@ -206,7 +214,33 @@ async function installCoreFromArt(artPath, entry, { log = () => {} } = {}) {
206
214
  * 确保内核二进制就位(多级获取: 本地缓存 → 包内 vendor → 服务器下载 → GitHub 兼底)
207
215
  * @returns {Promise<{ok:boolean, bin?:string, source?:string, error?:string}>}
208
216
  */
217
+ // ── 自定义内核(老 CPU 机器用本机可用内核,如 Verge 的 verge-mihomo.exe) ──
218
+
219
+ /**
220
+ * 自定义内核路径(~/.tt-help.json → clash.corePath):
221
+ * 官方 mihomo 二进制与 CPU 不兼容的机器(如 AMD A8-6410,启动即 0xC0000005),
222
+ * 指定本机已验证可用的内核二进制(例:C:\Program Files\Clash Verge\verge-mihomo.exe)。
223
+ * 指定后跳过下载/sha256 校验,直接用该路径 spawn(-d/-f 仍指向我们的运行目录,配置/日志隔离不变)。
224
+ */
225
+ export function customCorePath() {
226
+ try {
227
+ const cfgPath = join(homedir(), ".tt-help.json");
228
+ if (!existsSync(cfgPath)) return null;
229
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
230
+ const p = cfg?.clash?.corePath;
231
+ return p && existsSync(p) ? p : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
209
237
  export async function ensureBinary({ log = () => {} } = {}) {
238
+ // 0) 自定义内核(clash.corePath,存在即用,跳过下载/校验)
239
+ const custom = customCorePath();
240
+ if (custom) {
241
+ log(`⚙ 使用自定义内核: ${custom}`);
242
+ return { ok: true, bin: custom, source: "custom" };
243
+ }
210
244
  const { bin } = runtimePaths();
211
245
  const entry = VENDOR_MANIFEST[platformKey()];
212
246
  if (!entry) return { ok: false, error: `不支持的平台: ${platformKey()}` };
@@ -446,7 +480,9 @@ export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
446
480
  ` ${s.name}:`,
447
481
  " type: http",
448
482
  ` url: ${s.url}`,
449
- " interval: 86400",
483
+ // 订阅文件+userinfo(subscription-userinfo 响应头)刷新周期: 1 小时。
484
+ // 同一订阅可配多实例,各自快照时刻不同;24h 周期会导致页面剩余流量显示差异巨大(214 实测一天差 447GB)
485
+ " interval: 3600",
450
486
  ` path: ./providers/${s.name}.yaml`,
451
487
  " proxy: DIRECT",
452
488
  " health-check:",
@@ -4779,6 +4779,10 @@ async function wdSaveProxySettings() {
4779
4779
  return s.trim();
4780
4780
  })
4781
4781
  .filter(Boolean);
4782
+ if (lines.length > 8) {
4783
+ alert("最多 8 条订阅(每条一个独立 mihomo 实例,超出部分无法巡检);当前 " + lines.length + " 行");
4784
+ return;
4785
+ }
4782
4786
  try {
4783
4787
  var res = await fetch("/api/watchdog/set-proxy-settings", {
4784
4788
  method: "POST",
@@ -786,7 +786,7 @@
786
786
  style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
787
787
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
788
788
  <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
789
- <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例</b>:按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”总览表可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在总览表中标红。</div>
789
+ <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例,最多 8 行</b>;<b style="color:#fbbf24">同一地址可配置多行</b>(各自独立使用不同节点):按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”tabs 可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在 tab 上标红。</div>
790
790
  <textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
791
791
  style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
792
792
  <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
@@ -831,7 +831,12 @@ async function handleSetErrorTrigger(params, body, res) {
831
831
  sendJSON(res, 200, { ok: true, config: cleaned });
832
832
  }
833
833
 
834
- /** 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空) */
834
+ /**
835
+ * 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空)
836
+ * 允许同一订阅地址配置多行(每行一个独立实例,可各自使用不同节点)
837
+ * 上限 8 条: 与 discover.js RUNTIME_MAX_INSTANCES=8 对齐(第 9 条实例端口会超出
838
+ * 巡检枚举段 9097-9104,永远进不了 daemon 巡检列表)
839
+ */
835
840
  async function handleSetProxySettings(params, body, res) {
836
841
  const { userId, subscriptions } = body || {};
837
842
  if (!userId) {
@@ -842,16 +847,19 @@ async function handleSetProxySettings(params, body, res) {
842
847
  sendJSON(res, 400, { error: "subscriptions 须为数组" });
843
848
  return;
844
849
  }
845
- // 归一化: 字符串或 {name,url},去重去空
846
- const seen = new Set();
850
+ // 归一化: 字符串或 {name,url},去空去非法;不去重(同 URL 多行 = 多实例)
851
+ const MAX_SUBS = 8;
847
852
  const cleaned = [];
848
853
  for (const it of subscriptions) {
849
854
  const url = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
850
- if (!url || !/^https?:\/\//.test(url) || seen.has(url)) continue;
851
- seen.add(url);
855
+ if (!url || !/^https?:\/\//.test(url)) continue;
852
856
  const name = typeof it === "object" && it.name ? String(it.name).trim() : "";
853
857
  cleaned.push({ name, url });
854
858
  }
859
+ if (cleaned.length > MAX_SUBS) {
860
+ sendJSON(res, 400, { error: `最多 ${MAX_SUBS} 条(每条一个独立 mihomo 实例,超出部分无法巡检)` });
861
+ return;
862
+ }
855
863
  setProxySettingsStored(userId, { subscriptions: cleaned });
856
864
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
857
865
  console.error(`[watchdog ${ts}] ${userId} 代理订阅配置已更新: ${cleaned.length} 条`);
@@ -113,7 +113,7 @@ const NETWORK_ERROR_PATTERNS = [
113
113
  ];
114
114
  const NETWORK_ERROR_RE = new RegExp(NETWORK_ERROR_PATTERNS.join("|"), "i");
115
115
  const ERROR_SCAN_INTERVAL_MS = 30_000; // 日志扫描间隔
116
- const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启全部命令)
116
+ const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启对应通道命令)
117
117
  const ERROR_TRIGGER_COUNT = 10; // 连续错误阈值
118
118
  const ERROR_MIN_MS = 5 * 60 * 1000; // 最小持续时间(时间只做最小值判断)
119
119
  const ERROR_BREAK_MS = 120_000; // 两次错误间隔超过则视为连续中断,重置计数
@@ -145,6 +145,27 @@ function isWebserverMode(cmdDef) {
145
145
  return /--webserver(?=\s|=|$)/.test(cmdDef?.command || "");
146
146
  }
147
147
 
148
+ /**
149
+ * 解析命令使用的代理端口(多实例通道路由):
150
+ * --proxy http://127.0.0.1:7898 / --proxy 127.0.0.1:7898 / --proxy=:7898 → 7898
151
+ * 无 --proxy(走系统代理 = 主控制端)→ null
152
+ * 用于: 错误触发 failover / 代理切换重启 时路由到出错命令实际使用的实例,
153
+ * 避免多实例下切错对象(用 7898 的命令报错却切了 7897 的节点)。
154
+ */
155
+ function parseCmdProxyPort(cmdDef) {
156
+ const s = cmdDef?.command || "";
157
+ const m = s.match(/--proxy(?:=|\s+)(\S+)/);
158
+ if (!m) return null;
159
+ const pm = m[1].match(/:(\d{2,5})(?:\/|$)/);
160
+ return pm ? parseInt(pm[1], 10) : null;
161
+ }
162
+
163
+ /** 从 daemon 健康数据提取主控制端代理端口(meta.proxyUrl = http://127.0.0.1:7897) */
164
+ function mainProxyPortOf(health) {
165
+ const m = String(health?.meta?.proxyUrl || "").match(/:(\d{2,5})\/?$/);
166
+ return m ? parseInt(m[1], 10) : null;
167
+ }
168
+
148
169
  /** 自动发现 daemon.js 路径 */
149
170
  function findDaemonPath() {
150
171
  // 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
@@ -459,35 +480,54 @@ export class WatchdogAgent {
459
480
  try {
460
481
  const health = getClashHealth();
461
482
  if (!health) return; // daemon 未就绪,暂不初始化基线
483
+ // 通道 → seq 映射: 主控制端(默认文件)与其代理端口通道是同一控制器双写,
484
+ // 用代理端口作 key 自动合并,避免同一次切换被当成两个通道
485
+ const mainPort = mainProxyPortOf(health);
462
486
  const seqMap = new Map();
463
- seqMap.set("main", Number(health.restartSeq) || 0);
487
+ seqMap.set(mainPort || "main", Number(health.restartSeq) || 0);
464
488
  for (const ch of getClashHealthChannels()) {
465
- seqMap.set(`ch${ch.port}`, Number(ch.restartSeq) || 0);
489
+ if (mainPort && ch.port === mainPort) continue; // 已并入主控制端 key
490
+ seqMap.set(ch.port, Number(ch.restartSeq) || 0);
466
491
  }
467
492
  if (this._restartSeqBaseline === null) {
468
493
  this._restartSeqBaseline = seqMap; // 首次读取建基线(watchdog 重启不误触发)
469
494
  return;
470
495
  }
471
- let switched = false;
496
+ // 收集发生切换的通道(多实例各自独立切换,互不影响)
497
+ const switchedPorts = []; // 代理端口(数字);主控制端无端口信息时为 null
498
+ let mainSwitched = false;
472
499
  for (const [key, seq] of seqMap) {
473
500
  if (seq > (this._restartSeqBaseline.get(key) || 0)) {
474
- switched = true;
475
- break;
501
+ if (key === "main") mainSwitched = true;
502
+ else {
503
+ switchedPorts.push(key);
504
+ if (key === mainPort) mainSwitched = true; // 主控制端端口通道切换
505
+ }
476
506
  }
477
507
  }
478
508
  this._restartSeqBaseline = seqMap;
479
- if (!switched) return;
509
+ if (!switchedPorts.length && !mainSwitched) return;
510
+ const switchedDesc = switchedPorts.length
511
+ ? switchedPorts.join(",") + (mainSwitched ? ",主控制端" : "")
512
+ : "主控制端";
480
513
  if (!this._switchRestartEnabled) {
481
514
  console.error(
482
- `[watchdog] 检测到代理切换但重启功能未启用(后台可开启),跳过重启`,
515
+ `[watchdog] 检测到代理切换(通道 ${switchedDesc})但重启功能未启用(后台可开启),跳过重启`,
483
516
  );
484
517
  return;
485
518
  }
486
519
  const reason = health.lastSwitch
487
520
  ? `代理切换(${health.lastSwitch})`
488
521
  : "代理切换";
489
- console.error(`[watchdog] 检测到${reason},强制重启全部命令...`);
490
- await this.processManager.restartAll(reason);
522
+ // 只重启使用切换通道的命令(按 --proxy 匹配;无 --proxy 的命令走系统代理=主控制端)
523
+ // 避免多实例下误伤其它通道的命令
524
+ await this.processManager.restartMatching(
525
+ `代理切换(通道 ${switchedDesc}, ${reason})`,
526
+ (cmdDef) => {
527
+ const p = parseCmdProxyPort(cmdDef);
528
+ return p === null ? mainSwitched : switchedPorts.includes(p);
529
+ },
530
+ );
491
531
  } catch (e) {
492
532
  console.error(`[watchdog] 代理切换感知检查失败: ${e.message}`);
493
533
  }
@@ -548,18 +588,21 @@ export class WatchdogAgent {
548
588
  if (!this.running || !this._errorTriggerEnabled) return;
549
589
  const now = Date.now();
550
590
  const scanned = this.processManager.scanErrorLogs(NETWORK_ERROR_RE);
551
- let failoverTriggered = false; // 本轮是否已触发全局切换
591
+ // 本轮已触发切换的通道(同通道只触发一次;不同通道互不阻塞——多实例各自出口独立)
592
+ const triggeredChannels = new Set();
552
593
  for (const { cmdId, cmdDef, newErrors } of scanned) {
553
594
  if (!isCollectorCommand(cmdDef)) continue;
554
595
  // webserver 模式:错误发生在远端出口,本地切节点无法解决,不计入错误触发统计
555
596
  if (isWebserverMode(cmdDef)) continue;
597
+ // 通道键: 命令实际使用的代理端口(多实例各切各的);无 --proxy = 主控制端
598
+ const channelKey = parseCmdProxyPort(cmdDef) || "main";
556
599
  let st = this._errorStates.get(cmdId) || {
557
600
  consecutive: 0,
558
601
  firstErrorAt: 0,
559
602
  lastErrorAt: 0,
560
603
  };
561
- if (failoverTriggered) {
562
- // 本轮已有进程触发全局切换 → 该进程也从新计数开始(防同轮接力触发)
604
+ if (triggeredChannels.has(channelKey)) {
605
+ // 本轮该通道已触发切换 → 该进程也从新计数开始(防同轮接力触发)
563
606
  st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
564
607
  }
565
608
  const hadNew = newErrors.length > 0;
@@ -581,14 +624,13 @@ export class WatchdogAgent {
581
624
  now - st.firstErrorAt >= this._errorTriggerMinMs
582
625
  ) {
583
626
  console.error(
584
- `[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换`,
627
+ `[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换 (通道 ${channelKey})`,
585
628
  );
586
- this._triggerFailover(cmdId);
587
- // 切换是全局的(所有子进程共用节点):清空全部错误计数,
588
- // 让新节点有干净的观察窗口,旧节点错误不再驱动后续切换;
589
- // 触发者自身也重新计数(含本轮后续错误行,从 0 累计)。
629
+ this._triggerFailover(cmdId, parseCmdProxyPort(cmdDef));
630
+ // 切换作用于该命令实际使用的代理通道;同通道命令共用出口,
631
+ // 全部重新计数给新节点干净观察窗口(含触发者自身,从本轮后续错误重新累计)。
590
632
  this._errorStates.clear();
591
- failoverTriggered = true;
633
+ triggeredChannels.add(channelKey);
592
634
  st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
593
635
  }
594
636
  }
@@ -616,31 +658,59 @@ export class WatchdogAgent {
616
658
  }
617
659
  }
618
660
 
619
- /** 下发 failover 命令给 daemon(候选集切下一个可用节点) */
620
- async _triggerFailover(cmdId) {
661
+ /**
662
+ * 下发 failover 命令给 daemon(候选集切下一个可用节点)
663
+ * @param {string} cmdId 触发源命令 id(日志用)
664
+ * @param {number|null} proxyPort 命令实际使用的代理端口(多实例路由;null=主控制端)
665
+ */
666
+ async _triggerFailover(cmdId, proxyPort = null) {
621
667
  try {
622
668
  const ipc = await import("../clash/ipc.js");
623
- ipc.writeCommand({
624
- action: "failover",
625
- reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
626
- });
627
- console.error(`[watchdog] 已下发 failover 命令 (触发源: ${cmdId})`);
669
+ ipc.writeCommand(
670
+ {
671
+ action: "failover",
672
+ reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
673
+ ...(proxyPort ? { proxyPort } : {}),
674
+ },
675
+ proxyPort || 0,
676
+ );
677
+ console.error(
678
+ `[watchdog] 已下发 failover 命令 (触发源: ${cmdId}, 通道: ${proxyPort || "主控制端"})`,
679
+ );
628
680
  } catch (e) {
629
681
  console.error(`[watchdog] 下发 failover 失败: ${e.message}`);
630
682
  }
631
683
  }
632
684
 
633
- /** 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选) */
685
+ /**
686
+ * 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选)
687
+ * 客户端异常告警无法归因到具体通道 → 对全部代理通道各发一条强切
688
+ * (默认通道=主控制端 + 其余实例通道;主控制端的代理端口通道与默认通道
689
+ * 是同一控制器双写,跳过避免同一次切换被切两次)
690
+ */
634
691
  async _forceFailover(reason) {
635
692
  try {
636
693
  const ipc = await import("../clash/ipc.js");
637
- ipc.writeCommand({
638
- action: "failover",
639
- force: true,
640
- reason: reason || "客户端异常强切",
641
- });
694
+ const mainPort = mainProxyPortOf(getClashHealth());
695
+ const ports = [
696
+ 0,
697
+ ...getClashHealthChannels()
698
+ .map((ch) => ch.port)
699
+ .filter((p) => p !== mainPort),
700
+ ];
701
+ for (const p of ports) {
702
+ ipc.writeCommand(
703
+ {
704
+ action: "failover",
705
+ force: true,
706
+ reason: reason || "客户端异常强切",
707
+ ...(p ? { proxyPort: p } : {}),
708
+ },
709
+ p,
710
+ );
711
+ }
642
712
  console.error(
643
- `[watchdog] 已下发强切命令 (原因: ${reason || "客户端异常"})`,
713
+ `[watchdog] 已下发强切命令 (${ports.length} 个通道, 原因: ${reason || "客户端异常"})`,
644
714
  );
645
715
  } catch (e) {
646
716
  console.error(`[watchdog] 下发强切失败: ${e.message}`);
@@ -740,18 +740,29 @@ export class ProcessManager extends EventEmitter {
740
740
  * @returns {Promise<{restarted:number}>}
741
741
  */
742
742
  async restartAll(reason) {
743
+ return this.restartMatching(reason, () => true);
744
+ }
745
+
746
+ /**
747
+ * 按条件选择性强制重启(错峰停止 + 错峰启动,同 restartAll 语义)
748
+ * 多实例场景: 代理切换/故障切换只重启使用对应通道(--proxy 端口)的命令,
749
+ * 避免误伤其它通道的命令。
750
+ * @param {string} reason 重启原因(日志)
751
+ * @param {(cmdDef: object) => boolean} matchFn 命令匹配函数
752
+ */
753
+ async restartMatching(reason, matchFn) {
743
754
  const toRestart = [];
744
755
  for (const [, info] of this.processes) {
745
- if (!info.stopped && info.child && info.cmdDef) {
756
+ if (!info.stopped && info.child && info.cmdDef && matchFn(info.cmdDef)) {
746
757
  toRestart.push(info.cmdDef);
747
758
  }
748
759
  }
749
760
  if (!toRestart.length) {
750
- console.error(`[watchdog] ${reason},无运行中的命令,跳过重启`);
761
+ console.error(`[watchdog] ${reason},无匹配的运行中命令,跳过重启`);
751
762
  return { restarted: 0 };
752
763
  }
753
764
  console.error(
754
- `[watchdog] ${reason},强制重启全部命令 (${toRestart.length}个)...`,
765
+ `[watchdog] ${reason},强制重启匹配命令 (${toRestart.length}个)...`,
755
766
  );
756
767
  // 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
757
768
  for (let i = 0; i < toRestart.length; i++) {