mihomo-cli 1.0.2 → 1.1.0

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": "mihomo-cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A terminal-based mihomo (Clash.Meta) client for macOS",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -25,10 +25,11 @@ const USER_DATA_DIR = getUserDataDir();
25
25
  const DIRS = {
26
26
  root: PROJECT_ROOT,
27
27
  core: path.join(USER_DATA_DIR, 'core'),
28
- subs: path.join(USER_DATA_DIR, 'subs'),
28
+ subscriptions: path.join(USER_DATA_DIR, 'subscriptions'),
29
29
  logs: path.join(USER_DATA_DIR, 'logs'),
30
30
  data: path.join(USER_DATA_DIR, 'data'),
31
31
  runtime: path.join(USER_DATA_DIR, '.runtime'),
32
+ overwrites: path.join(USER_DATA_DIR, 'overwrites'),
32
33
  };
33
34
 
34
35
  const PATHS = {
@@ -37,7 +38,7 @@ const PATHS = {
37
38
  userDataDir: USER_DATA_DIR,
38
39
  mihomoBinary: path.join(DIRS.core, 'mihomo'),
39
40
  settingsFile: path.join(USER_DATA_DIR, 'settings.json'),
40
- subsCacheFile: path.join(USER_DATA_DIR, 'subs-cache.json'),
41
+ subscriptionsCacheFile: path.join(DIRS.subscriptions, 'cache.json'),
41
42
  configFile: path.join(DIRS.runtime, 'config.yaml'),
42
43
  logFile: path.join(DIRS.logs, 'mihomo.log'),
43
44
  pidFile: path.join(DIRS.runtime, 'pid'),
@@ -110,10 +111,10 @@ const AVAILABLE_MIRRORS = [
110
111
  function getGitHubMirror() {
111
112
  const settings = readSettings();
112
113
  // 空字符串或 false 表示禁用镜像
113
- if (settings.githubMirror === '' || settings.githubMirror === false) {
114
+ if (settings.github_mirror === '' || settings.github_mirror === false) {
114
115
  return null;
115
116
  }
116
- return settings.githubMirror || DEFAULT_GITHUB_MIRROR;
117
+ return settings.github_mirror || DEFAULT_GITHUB_MIRROR;
117
118
  }
118
119
 
119
120
  function setGitHubMirror(mirror) {
@@ -125,13 +126,13 @@ function setGitHubMirror(mirror) {
125
126
 
126
127
  if (mirror === null || mirror === undefined) {
127
128
  const settings = readSettings();
128
- delete settings.githubMirror;
129
+ delete settings.github_mirror;
129
130
  writeSettings(settings);
130
131
  return DEFAULT_GITHUB_MIRROR;
131
132
  }
132
133
 
133
134
  if (mirror === '' || mirror === false) {
134
- writeSettings({ githubMirror: '' });
135
+ writeSettings({ github_mirror: '' });
135
136
  return null;
136
137
  }
137
138
 
@@ -143,16 +144,16 @@ function setGitHubMirror(mirror) {
143
144
  mirrorUrl += '/';
144
145
  }
145
146
 
146
- writeSettings({ githubMirror: mirrorUrl });
147
+ writeSettings({ github_mirror: mirrorUrl });
147
148
  return mirrorUrl;
148
149
  }
149
150
 
150
- // 订阅缓存读写(动态数据:流量、用户名等)
151
- function readSubsCache() {
151
+ // 订阅缓存读写(动态数据:流量、用户名、更新时间等)
152
+ function readSubscriptionsCache() {
152
153
  ensureDirs();
153
- if (fs.existsSync(PATHS.subsCacheFile)) {
154
+ if (fs.existsSync(PATHS.subscriptionsCacheFile)) {
154
155
  try {
155
- const content = fs.readFileSync(PATHS.subsCacheFile, 'utf8');
156
+ const content = fs.readFileSync(PATHS.subscriptionsCacheFile, 'utf8');
156
157
  return JSON.parse(content);
157
158
  } catch (e) {
158
159
  return {};
@@ -161,15 +162,15 @@ function readSubsCache() {
161
162
  return {};
162
163
  }
163
164
 
164
- function writeSubsCache(cache) {
165
+ function writeSubscriptionsCache(cache) {
165
166
  ensureDirs();
166
- fs.writeFileSync(PATHS.subsCacheFile, JSON.stringify(cache, null, 2), { mode: 0o600 });
167
+ fs.writeFileSync(PATHS.subscriptionsCacheFile, JSON.stringify(cache, null, 2), { mode: 0o600 });
167
168
  }
168
169
 
169
- function saveSubCache(subName, data) {
170
- const cache = readSubsCache();
170
+ function saveSubscriptionCache(subName, data) {
171
+ const cache = readSubscriptionsCache();
171
172
  cache[subName] = { ...cache[subName], ...data };
172
- writeSubsCache(cache);
173
+ writeSubscriptionsCache(cache);
173
174
  }
174
175
 
175
176
  function getSubscriptions() {
@@ -180,7 +181,7 @@ function getSubscriptions() {
180
181
  // 获取合并了缓存数据的订阅列表
181
182
  function getSubscriptionsWithCache() {
182
183
  const subs = getSubscriptions();
183
- const cache = readSubsCache();
184
+ const cache = readSubscriptionsCache();
184
185
  return subs.map(s => ({
185
186
  ...s,
186
187
  ...(cache[s.name] || {}),
@@ -193,9 +194,9 @@ function addSubscription(url, name) {
193
194
  const subs = settings.subscriptions || [];
194
195
  const existingIndex = subs.findIndex(s => s.name === name);
195
196
  if (existingIndex >= 0) {
196
- subs[existingIndex] = { name, url, updatedAt: new Date().toISOString() };
197
+ subs[existingIndex] = { name, url };
197
198
  } else {
198
- subs.push({ name, url, updatedAt: null });
199
+ subs.push({ name, url });
199
200
  }
200
201
  writeSettings({ subscriptions: subs });
201
202
  }
@@ -217,7 +218,7 @@ function setDefaultSubscription(name) {
217
218
  }
218
219
 
219
220
  function getSubRawConfigPath(subName) {
220
- return path.join(DIRS.subs, subName + '.yaml');
221
+ return path.join(DIRS.subscriptions, subName + '.yaml');
221
222
  }
222
223
 
223
224
  function saveSubRawConfig(subName, content) {
@@ -305,7 +306,14 @@ function buildConfig(subRawContent, mode) {
305
306
  throw new Error('订阅内容为空');
306
307
  }
307
308
 
308
- const merged = { ...baseConfig, ...BASE_CONFIG };
309
+ // 延迟加载以避免循环依赖
310
+ const overwrite = require('./overwrite');
311
+
312
+ // 应用覆写配置
313
+ const withOverwrites = overwrite.applyOverwrites(baseConfig);
314
+
315
+ // 合并 BASE_CONFIG(优先级高于覆写)
316
+ const merged = { ...withOverwrites, ...BASE_CONFIG };
309
317
 
310
318
  if (mode === 'tun') {
311
319
  // 合并 TUN 配置
@@ -351,7 +359,9 @@ function getConfigInfo() {
351
359
  proxies: cfg.proxies ? cfg.proxies.length : 0,
352
360
  proxyGroups: cfg['proxy-groups'] ? cfg['proxy-groups'].length : 0,
353
361
  mode: cfg.mode || 'rule',
354
- port: cfg.port || cfg['mixed-port'] || '未知',
362
+ mixedPort: cfg['mixed-port'] || null,
363
+ httpPort: cfg.port || null,
364
+ socksPort: cfg['socks-port'] || null,
355
365
  tun: cfg.tun ? cfg.tun.enable : false,
356
366
  };
357
367
  } catch (e) {
@@ -369,7 +379,7 @@ function resetUserData(options) {
369
379
 
370
380
  const itemsToRemove = [
371
381
  PATHS.settingsFile,
372
- DIRS.subs,
382
+ DIRS.subscriptions,
373
383
  DIRS.logs,
374
384
  DIRS.data,
375
385
  DIRS.runtime,
@@ -404,9 +414,9 @@ module.exports = {
404
414
  ensureDirs,
405
415
  readSettings,
406
416
  writeSettings,
407
- readSubsCache,
408
- writeSubsCache,
409
- saveSubCache,
417
+ readSubscriptionsCache,
418
+ writeSubscriptionsCache,
419
+ saveSubscriptionCache,
410
420
  maskUrl,
411
421
  getSubscriptions,
412
422
  getSubscriptionsWithCache,
@@ -0,0 +1,258 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const config = require('./config');
5
+
6
+ /**
7
+ * 解析覆写键名
8
+ * 支持的格式:
9
+ * - key! → 强制覆盖整个对象
10
+ * - +key → 数组前置插入
11
+ * - key+ → 数组追加
12
+ * - <+key> → 实际键名是 +key(当键名以 + 开头/结尾时)
13
+ * - +<+key> → 为键名 +key 执行前置插入
14
+ * - <+key>+ → 为键名 +key 执行追加
15
+ *
16
+ * 返回: { key: string, forceOverwrite: boolean, arrayPrepend: boolean, arrayAppend: boolean }
17
+ */
18
+ function parseOverrideKey(key) {
19
+ let actualKey = key;
20
+ let forceOverwrite = false;
21
+ let arrayPrepend = false;
22
+ let arrayAppend = false;
23
+
24
+ // 1. 检查强制覆盖标记 (! 后缀,不在 <> 内时)
25
+ // 只有当 ! 是最后一个字符,且前面没有未闭合的 < 时才是标记
26
+ const lastChar = key[key.length - 1];
27
+ const openAngleCount = (key.match(/</g) || []).length;
28
+ const closeAngleCount = (key.match(/>/g) || []).length;
29
+
30
+ if (lastChar === '!' && openAngleCount === closeAngleCount) {
31
+ forceOverwrite = true;
32
+ actualKey = key.slice(0, -1);
33
+ }
34
+
35
+ // 2. 解析 <> 包裹的键名和 + 操作符
36
+ // 支持的模式:
37
+ // +<key> → prepend, key
38
+ // <key>+ → append, key
39
+ // <+key> → 无操作, 实际键名是 +key
40
+ // +<+key> → prepend, 实际键名是 +key
41
+ // <+key>+ → append, 实际键名是 +key
42
+
43
+ const wrappedMatch = actualKey.match(/^(\+)?(<[^>]+>)(\+)?$/);
44
+ if (wrappedMatch) {
45
+ const prefixPlus = wrappedMatch[1] === '+';
46
+ const wrappedPart = wrappedMatch[2];
47
+ const suffixPlus = wrappedMatch[3] === '+';
48
+
49
+ // 提取 <> 内的内容作为实际键名
50
+ const unwrapped = wrappedPart.slice(1, -1);
51
+
52
+ if (prefixPlus || suffixPlus) {
53
+ // 有 + 操作符,<> 内是实际键名
54
+ actualKey = unwrapped;
55
+ if (prefixPlus) arrayPrepend = true;
56
+ if (suffixPlus) arrayAppend = true;
57
+ } else {
58
+ // 没有 + 操作符,<key> 形式本身就是为了表示键名含特殊字符
59
+ // 这种情况下不需要额外处理,actualKey 已经是 wrappedPart,但我们需要 unwrap
60
+ // 例如:<+.google.cn> 的实际键名就是 +.google.cn
61
+ actualKey = unwrapped;
62
+ }
63
+ } else {
64
+ // 没有被 <> 完整包裹,检查开头和结尾的 +
65
+ if (actualKey.startsWith('+')) {
66
+ arrayPrepend = true;
67
+ actualKey = actualKey.slice(1);
68
+ }
69
+ if (actualKey.endsWith('+')) {
70
+ arrayAppend = true;
71
+ actualKey = actualKey.slice(0, -1);
72
+ }
73
+ }
74
+
75
+ return { key: actualKey, forceOverwrite, arrayPrepend, arrayAppend };
76
+ }
77
+
78
+ /**
79
+ * 深度合并带覆写规则
80
+ */
81
+ function deepMergeWithOverrides(target, override) {
82
+ if (target === null || target === undefined) {
83
+ target = Array.isArray(override) ? [] : {};
84
+ }
85
+
86
+ if (override === null || override === undefined) {
87
+ return target;
88
+ }
89
+
90
+ // 如果 override 不是对象,直接返回 override(覆盖)
91
+ if (typeof override !== 'object') {
92
+ return override;
93
+ }
94
+
95
+ // 如果 override 是数组,target 也必须是数组
96
+ if (Array.isArray(override)) {
97
+ return override;
98
+ }
99
+
100
+ // 此时 override 是普通对象
101
+
102
+ const result = { ...target };
103
+
104
+ for (const [rawKey, value] of Object.entries(override)) {
105
+ const { key, forceOverwrite, arrayPrepend, arrayAppend } = parseOverrideKey(rawKey);
106
+
107
+ const existingValue = result[key];
108
+
109
+ // 处理数组操作
110
+ if (arrayPrepend || arrayAppend) {
111
+ const existingArr = Array.isArray(existingValue) ? existingValue : [];
112
+ const overrideArr = Array.isArray(value) ? value : [value];
113
+
114
+ if (arrayPrepend) {
115
+ result[key] = [...overrideArr, ...existingArr];
116
+ } else {
117
+ result[key] = [...existingArr, ...overrideArr];
118
+ }
119
+ continue;
120
+ }
121
+
122
+ // 处理强制覆盖
123
+ if (forceOverwrite) {
124
+ result[key] = value;
125
+ continue;
126
+ }
127
+
128
+ // 递归合并对象
129
+ if (
130
+ value !== null &&
131
+ typeof value === 'object' &&
132
+ !Array.isArray(value) &&
133
+ existingValue !== null &&
134
+ typeof existingValue === 'object' &&
135
+ !Array.isArray(existingValue)
136
+ ) {
137
+ result[key] = deepMergeWithOverrides(existingValue, value);
138
+ continue;
139
+ }
140
+
141
+ // 其他情况直接覆盖
142
+ result[key] = value;
143
+ }
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * 获取覆写目录路径
150
+ */
151
+ function getOverwritesDir() {
152
+ return config.DIRS.overwrites;
153
+ }
154
+
155
+ /**
156
+ * 检查覆写功能是否启用
157
+ */
158
+ function isOverwriteEnabled() {
159
+ const settings = config.readSettings();
160
+ return settings.overwrite_enabled !== false; // 默认启用
161
+ }
162
+
163
+ /**
164
+ * 启用/禁用覆写功能
165
+ */
166
+ function setOverwriteEnabled(enabled) {
167
+ config.writeSettings({ overwrite_enabled: enabled });
168
+ }
169
+
170
+ /**
171
+ * 读取 overwrites 目录下的所有 yaml 文件
172
+ * 按文件名排序返回
173
+ */
174
+ function loadOverwriteFiles() {
175
+ const dir = getOverwritesDir();
176
+
177
+ if (!fs.existsSync(dir)) {
178
+ return [];
179
+ }
180
+
181
+ const files = fs.readdirSync(dir)
182
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
183
+ .sort();
184
+
185
+ const results = [];
186
+
187
+ for (const file of files) {
188
+ const filePath = path.join(dir, file);
189
+ try {
190
+ const content = fs.readFileSync(filePath, 'utf8');
191
+ const parsed = yaml.load(content);
192
+ if (parsed && typeof parsed === 'object') {
193
+ results.push({
194
+ name: file,
195
+ path: filePath,
196
+ config: parsed,
197
+ });
198
+ }
199
+ } catch (e) {
200
+ // 忽略解析错误的文件
201
+ }
202
+ }
203
+
204
+ return results;
205
+ }
206
+
207
+ /**
208
+ * 应用所有覆写配置到基础配置
209
+ */
210
+ function applyOverwrites(baseConfig) {
211
+ if (!isOverwriteEnabled()) {
212
+ return baseConfig;
213
+ }
214
+
215
+ const overwriteFiles = loadOverwriteFiles();
216
+
217
+ if (overwriteFiles.length === 0) {
218
+ return baseConfig;
219
+ }
220
+
221
+ let result = { ...baseConfig };
222
+
223
+ for (const file of overwriteFiles) {
224
+ result = deepMergeWithOverrides(result, file.config);
225
+ }
226
+
227
+ return result;
228
+ }
229
+
230
+ /**
231
+ * 列出覆写文件信息
232
+ */
233
+ function listOverwriteFiles() {
234
+ const files = loadOverwriteFiles();
235
+ const enabled = isOverwriteEnabled();
236
+ const dir = getOverwritesDir();
237
+
238
+ return {
239
+ enabled,
240
+ dir,
241
+ files: files.map(f => ({
242
+ name: f.name,
243
+ path: f.path,
244
+ keys: Object.keys(f.config || {}),
245
+ })),
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ parseOverrideKey,
251
+ deepMergeWithOverrides,
252
+ getOverwritesDir,
253
+ isOverwriteEnabled,
254
+ setOverwriteEnabled,
255
+ loadOverwriteFiles,
256
+ applyOverwrites,
257
+ listOverwriteFiles,
258
+ };
package/src/process.js CHANGED
@@ -329,15 +329,15 @@ async function start(mode) {
329
329
  async function startMixedMode(staleState) {
330
330
  if (staleState.needsCleanup) {
331
331
  if (staleState.needsSudo) {
332
- console.log('\n 发现需要 root 权限清理的残留进程/文件');
333
- console.log(' 请先手动清理: sudo pkill -9 mihomo');
334
- console.log(' 或者切换到 TUN 模式,启动时会自动清理');
332
+ console.log('\n发现需要 root 权限清理的残留进程/文件');
333
+ console.log('请先手动清理: sudo pkill -9 mihomo');
334
+ console.log('或者切换到 TUN 模式,启动时会自动清理');
335
335
  throw new Error('存在需要 root 权限清理的残留');
336
336
  }
337
337
 
338
338
  const cleanupResult = cleanupAll();
339
339
  if (cleanupResult.killed > 0) {
340
- console.log(' 清理了 ' + cleanupResult.killed + ' 个残留进程');
340
+ console.log('清理了 ' + cleanupResult.killed + ' 个残留进程');
341
341
  }
342
342
  }
343
343
 
@@ -389,7 +389,7 @@ async function startMixedMode(staleState) {
389
389
  try {
390
390
  const logs = fs.readFileSync(logFile, 'utf8').slice(-3000);
391
391
  if (logs.trim()) {
392
- errorMsg += '\n 最近的日志:\n' + logs.split('\n').map(l => ' ' + l).join('\n');
392
+ errorMsg += '\n最近的日志:\n' + logs.split('\n').map(l => ' ' + l).join('\n');
393
393
  }
394
394
  } catch {}
395
395
  }
@@ -417,9 +417,9 @@ async function startTunMode(staleState) {
417
417
  const launchScript = createTunLaunchScript();
418
418
 
419
419
  if (staleState.needsCleanup) {
420
- console.log(' 清理 ' + staleState.allPids.length + ' 个残留进程...');
420
+ console.log('清理 ' + staleState.allPids.length + ' 个残留进程...');
421
421
  }
422
- console.log(' TUN 模式需要 sudo 权限...');
422
+ console.log('TUN 模式需要 sudo 权限...');
423
423
 
424
424
  try {
425
425
  execSync('sudo "' + launchScript + '"', {
@@ -459,9 +459,9 @@ function stop(wasTunMode) {
459
459
  const remaining = getAllMihomoPids();
460
460
  if (remaining.length > 0) {
461
461
  console.log('');
462
- console.log(' 仍有进程残留,需要手动清理:');
463
- console.log(' 进程 PID: ' + remaining.join(', '));
464
- console.log(' 手动命令: sudo pkill -9 mihomo');
462
+ console.log('仍有进程残留,需要手动清理:');
463
+ console.log('进程 PID: ' + remaining.join(', '));
464
+ console.log('手动命令: sudo pkill -9 mihomo');
465
465
  console.log('');
466
466
  return { success: true, warning: '部分进程未终止', remaining };
467
467
  }
@@ -104,23 +104,11 @@ async function downloadSubscription(url, subName) {
104
104
  const webPageUrl = headers['profile-web-page-url'] || null;
105
105
  const username = parseUsernameFromContentDisposition(headers['content-disposition']);
106
106
 
107
- // 1. updatedAt 保存到 settings.json(元数据,用于判断更新间隔)
108
- // 同时清理旧的动态字段(迁移到缓存文件)
109
- const subs = config.getSubscriptions();
110
- const subIndex = subs.findIndex(s => s.name === subName);
111
- if (subIndex >= 0) {
112
- // 只保留核心配置字段,动态字段已迁移到缓存
113
- const cleanedSub = {
114
- name: subs[subIndex].name,
115
- url: subs[subIndex].url,
116
- updatedAt: new Date().toISOString(),
117
- };
118
- subs[subIndex] = cleanedSub;
119
- config.writeSettings({ subscriptions: subs });
120
- }
107
+ // 2. 动态数据 + updated_at 保存到缓存文件(settings 只存 name/url)
121
108
 
122
- // 2. 动态数据保存到缓存文件
123
- const cacheData = {};
109
+ const cacheData = {
110
+ updated_at: new Date().toISOString(),
111
+ };
124
112
  if (userInfo) {
125
113
  cacheData.upload = userInfo.upload;
126
114
  cacheData.download = userInfo.download;
@@ -128,15 +116,15 @@ async function downloadSubscription(url, subName) {
128
116
  cacheData.expire = userInfo.expire;
129
117
  }
130
118
  if (updateInterval) {
131
- cacheData.updateInterval = updateInterval;
119
+ cacheData.update_interval = updateInterval;
132
120
  }
133
121
  if (webPageUrl) {
134
- cacheData.webPageUrl = webPageUrl;
122
+ cacheData.web_page_url = webPageUrl;
135
123
  }
136
124
  if (username) {
137
125
  cacheData.username = username;
138
126
  }
139
- config.saveSubCache(subName, cacheData);
127
+ config.saveSubscriptionCache(subName, cacheData);
140
128
 
141
129
  return {
142
130
  proxies: parsed.proxies ? parsed.proxies.length : 0,
@@ -166,10 +154,10 @@ function prepareConfigForStart(mode, subName) {
166
154
  }
167
155
 
168
156
  function needsAutoUpdate(sub) {
169
- if (!sub.updatedAt) return true;
170
- const lastUpdate = new Date(sub.updatedAt).getTime();
157
+ if (!sub.updated_at) return true;
158
+ const lastUpdate = new Date(sub.updated_at).getTime();
171
159
  if (isNaN(lastUpdate)) return true;
172
- const intervalHours = sub.updateInterval || DEFAULT_UPDATE_INTERVAL_HOURS;
160
+ const intervalHours = sub.update_interval || DEFAULT_UPDATE_INTERVAL_HOURS;
173
161
  const intervalMs = intervalHours * 60 * 60 * 1000;
174
162
  return (Date.now() - lastUpdate) > intervalMs;
175
163
  }
@@ -177,7 +165,7 @@ function needsAutoUpdate(sub) {
177
165
  async function tryUpdateOne(sub) {
178
166
  try {
179
167
  const info = await downloadSubscription(sub.url, sub.name);
180
- return { name: sub.name, success: true, proxies: info.proxies };
168
+ return { name: sub.name, success: true, proxies: info.proxies, proxyGroups: info.proxyGroups };
181
169
  } catch (e) {
182
170
  return { name: sub.name, success: false, error: e.message };
183
171
  }
@@ -193,10 +181,10 @@ async function autoUpdateStaleSubscriptions() {
193
181
 
194
182
  if (staleSubs.length === 1) {
195
183
  const sub = staleSubs[0];
196
- const interval = sub.updateInterval || DEFAULT_UPDATE_INTERVAL_HOURS;
197
- console.log(' 订阅 "' + sub.name + '" 超过 ' + interval + ' 小时未更新,正在更新...');
184
+ const interval = sub.update_interval || DEFAULT_UPDATE_INTERVAL_HOURS;
185
+ console.log('订阅 "' + sub.name + '" 超过 ' + interval + ' 小时未更新,正在更新...');
198
186
  } else {
199
- console.log(' 检查到 ' + staleSubs.length + ' 个订阅需要更新,正在并行更新...');
187
+ console.log('检查到 ' + staleSubs.length + ' 个订阅需要更新,正在并行更新...');
200
188
  }
201
189
 
202
190
  const results = await Promise.all(staleSubs.map(tryUpdateOne));
@@ -205,10 +193,12 @@ async function autoUpdateStaleSubscriptions() {
205
193
  results.forEach(r => {
206
194
  if (r.success) {
207
195
  updatedCount++;
208
- console.log(' ✓ ' + r.name + ': 已更新 (' + r.proxies + ' 节点)');
196
+ const parts = [];
197
+ if (r.proxyGroups && r.proxyGroups > 0) parts.push(r.proxyGroups + ' 组');
198
+ parts.push(r.proxies + ' 节点');
199
+ console.log('✓ ' + r.name + ': 已更新 (' + parts.join(', ') + ')');
209
200
  } else {
210
- console.log(' ✗ ' + r.name + ': 更新失败,使用本地缓存');
211
- console.log(' 原因: ' + r.error.split('\n')[0]);
201
+ console.log('✗ ' + r.name + ': 失败 (' + r.error.split('\n')[0] + ')');
212
202
  }
213
203
  });
214
204