mihomo-cli 1.0.0-alpha.1 → 1.0.1

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.
@@ -1,6 +1,9 @@
1
1
  const axios = require('axios');
2
+ const yaml = require('js-yaml');
2
3
  const config = require('./config');
3
4
 
5
+ const DEFAULT_UPDATE_INTERVAL_HOURS = 12;
6
+
4
7
  const HTTP_CLIENT = axios.create({
5
8
  timeout: 60000,
6
9
  headers: {
@@ -10,6 +13,60 @@ const HTTP_CLIENT = axios.create({
10
13
  maxBodyLength: 50 * 1024 * 1024,
11
14
  });
12
15
 
16
+ function parseUserInfo(header) {
17
+ if (!header) return null;
18
+ const info = {};
19
+ const parts = header.split(';').map(p => p.trim());
20
+ for (const part of parts) {
21
+ const [key, val] = part.split('=').map(s => s.trim());
22
+ if (key && val !== undefined) {
23
+ const numVal = parseFloat(val);
24
+ info[key] = isNaN(numVal) ? val : numVal;
25
+ }
26
+ }
27
+ return info;
28
+ }
29
+
30
+ function parseUsernameFromContentDisposition(header) {
31
+ if (!header) return null;
32
+ // 匹配 filename="..." 或 filename='...'
33
+ const match = header.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i);
34
+ if (!match) return null;
35
+ const filename = match[1];
36
+ // 可能是 "glados.one/user@example.com" 格式,取最后一部分
37
+ const parts = filename.split('/');
38
+ return parts[parts.length - 1] || null;
39
+ }
40
+
41
+ function formatBytes(bytes) {
42
+ if (bytes === undefined || bytes === null) return '未知';
43
+ if (bytes === 0) return '0 B';
44
+ const k = 1024;
45
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
46
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
47
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
48
+ }
49
+
50
+ function formatTimestamp(ts) {
51
+ if (!ts) return '未知';
52
+ try {
53
+ return new Date(ts * 1000).toLocaleString('zh-CN');
54
+ } catch {
55
+ return '未知';
56
+ }
57
+ }
58
+
59
+ function formatDate(dateOrIso) {
60
+ if (!dateOrIso) return '未知';
61
+ try {
62
+ const d = dateOrIso instanceof Date ? dateOrIso : new Date(dateOrIso);
63
+ if (isNaN(d.getTime())) return '未知';
64
+ return d.toLocaleString('zh-CN');
65
+ } catch {
66
+ return '未知';
67
+ }
68
+ }
69
+
13
70
  async function downloadSubscription(url, subName) {
14
71
  if (subName === undefined) subName = 'default';
15
72
 
@@ -33,34 +90,61 @@ async function downloadSubscription(url, subName) {
33
90
  throw new Error('订阅内容为空');
34
91
  }
35
92
 
36
- let parsed;
37
- try {
38
- const yaml = require('js-yaml');
39
- parsed = yaml.load(content);
40
- } catch (e) {
41
- try {
42
- parsed = JSON.parse(content);
43
- } catch (e2) {
44
- throw new Error('订阅内容格式错误,无法解析为 YAML 或 JSON');
45
- }
46
- }
47
-
93
+ const parsed = config.parseYamlOrJson(content, '订阅内容');
48
94
  if (!parsed) {
49
95
  throw new Error('订阅内容为空');
50
96
  }
51
97
 
52
98
  config.saveSubRawConfig(subName, content);
53
99
 
100
+ // 提取 response headers 中的订阅信息
101
+ const headers = response.headers;
102
+ const userInfo = parseUserInfo(headers['subscription-userinfo']);
103
+ const updateInterval = headers['profile-update-interval'] ? parseInt(headers['profile-update-interval']) : null;
104
+ const webPageUrl = headers['profile-web-page-url'] || null;
105
+ const username = parseUsernameFromContentDisposition(headers['content-disposition']);
106
+
107
+ // 1. updatedAt 保存到 settings.json(元数据,用于判断更新间隔)
108
+ // 同时清理旧的动态字段(迁移到缓存文件)
54
109
  const subs = config.getSubscriptions();
55
- const sub = subs.find(s => s.name === subName);
56
- if (sub) {
57
- sub.updatedAt = new Date().toISOString();
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;
58
119
  config.writeSettings({ subscriptions: subs });
59
120
  }
60
121
 
122
+ // 2. 动态数据保存到缓存文件
123
+ const cacheData = {};
124
+ if (userInfo) {
125
+ cacheData.upload = userInfo.upload;
126
+ cacheData.download = userInfo.download;
127
+ cacheData.total = userInfo.total;
128
+ cacheData.expire = userInfo.expire;
129
+ }
130
+ if (updateInterval) {
131
+ cacheData.updateInterval = updateInterval;
132
+ }
133
+ if (webPageUrl) {
134
+ cacheData.webPageUrl = webPageUrl;
135
+ }
136
+ if (username) {
137
+ cacheData.username = username;
138
+ }
139
+ config.saveSubCache(subName, cacheData);
140
+
61
141
  return {
62
142
  proxies: parsed.proxies ? parsed.proxies.length : 0,
63
143
  proxyGroups: parsed['proxy-groups'] ? parsed['proxy-groups'].length : 0,
144
+ userInfo,
145
+ updateInterval,
146
+ webPageUrl,
147
+ username,
64
148
  };
65
149
  }
66
150
 
@@ -81,9 +165,69 @@ function prepareConfigForStart(mode, subName) {
81
165
  };
82
166
  }
83
167
 
168
+ function needsAutoUpdate(sub) {
169
+ if (!sub.updatedAt) return true;
170
+ const lastUpdate = new Date(sub.updatedAt).getTime();
171
+ if (isNaN(lastUpdate)) return true;
172
+ const intervalHours = sub.updateInterval || DEFAULT_UPDATE_INTERVAL_HOURS;
173
+ const intervalMs = intervalHours * 60 * 60 * 1000;
174
+ return (Date.now() - lastUpdate) > intervalMs;
175
+ }
176
+
177
+ async function tryUpdateOne(sub) {
178
+ try {
179
+ const info = await downloadSubscription(sub.url, sub.name);
180
+ return { name: sub.name, success: true, proxies: info.proxies };
181
+ } catch (e) {
182
+ return { name: sub.name, success: false, error: e.message };
183
+ }
184
+ }
185
+
186
+ async function autoUpdateStaleSubscriptions() {
187
+ const allSubs = config.getSubscriptionsWithCache();
188
+ const staleSubs = allSubs.filter(needsAutoUpdate);
189
+
190
+ if (staleSubs.length === 0) {
191
+ return { total: 0, updated: 0, failed: 0 };
192
+ }
193
+
194
+ if (staleSubs.length === 1) {
195
+ const sub = staleSubs[0];
196
+ const interval = sub.updateInterval || DEFAULT_UPDATE_INTERVAL_HOURS;
197
+ console.log(' 订阅 "' + sub.name + '" 超过 ' + interval + ' 小时未更新,正在更新...');
198
+ } else {
199
+ console.log(' 检查到 ' + staleSubs.length + ' 个订阅需要更新,正在并行更新...');
200
+ }
201
+
202
+ const results = await Promise.all(staleSubs.map(tryUpdateOne));
203
+ let updatedCount = 0;
204
+
205
+ results.forEach(r => {
206
+ if (r.success) {
207
+ updatedCount++;
208
+ console.log(' ✓ ' + r.name + ': 已更新 (' + r.proxies + ' 节点)');
209
+ } else {
210
+ console.log(' ✗ ' + r.name + ': 更新失败,使用本地缓存');
211
+ console.log(' 原因: ' + r.error.split('\n')[0]);
212
+ }
213
+ });
214
+
215
+ return {
216
+ total: staleSubs.length,
217
+ updated: updatedCount,
218
+ failed: staleSubs.length - updatedCount,
219
+ };
220
+ }
221
+
84
222
  module.exports = {
223
+ DEFAULT_UPDATE_INTERVAL_HOURS,
85
224
  downloadSubscription,
86
225
  prepareConfigForStart,
87
226
  hasConfig: config.hasConfig,
88
227
  getConfigInfo: config.getConfigInfo,
228
+ formatBytes,
229
+ formatTimestamp,
230
+ formatDate,
231
+ tryUpdateOne,
232
+ autoUpdateStaleSubscriptions,
89
233
  };