clash-switcher 0.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.
- package/README.md +220 -0
- package/dist/api.d.ts +30 -0
- package/dist/api.js +43 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +27 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +51 -0
- package/dist/commands/profile.d.ts +16 -0
- package/dist/commands/profile.js +240 -0
- package/dist/commands/switch.d.ts +3 -0
- package/dist/commands/switch.js +48 -0
- package/dist/commands/test.d.ts +3 -0
- package/dist/commands/test.js +49 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +84 -0
- package/dist/core.d.ts +184 -0
- package/dist/core.js +677 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +243 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +24 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +2 -0
- package/dist/utils/group-resolver.d.ts +8 -0
- package/dist/utils/group-resolver.js +38 -0
- package/dist/utils/proxy-resolver.d.ts +7 -0
- package/dist/utils/proxy-resolver.js +29 -0
- package/package.json +48 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.ClashSwitcher = void 0;
|
|
40
|
+
const os = __importStar(require("os"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const util_1 = require("util");
|
|
45
|
+
const axios_1 = __importDefault(require("axios"));
|
|
46
|
+
/**
|
|
47
|
+
* 获取默认的 Clash Verge 配置目录
|
|
48
|
+
*/
|
|
49
|
+
function getDefaultVergeConfigDir() {
|
|
50
|
+
const platform = os.platform();
|
|
51
|
+
if (platform === 'win32') {
|
|
52
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
53
|
+
return path.join(appData, 'io.github.clash-verge-rev.clash-verge-rev');
|
|
54
|
+
}
|
|
55
|
+
else if (platform === 'darwin') {
|
|
56
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'io.github.clash-verge-rev.clash-verge-rev');
|
|
57
|
+
}
|
|
58
|
+
return path.join(os.homedir(), '.config', 'clash-verge');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 默认配置
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_CONFIG = {
|
|
64
|
+
host: '127.0.0.1',
|
|
65
|
+
port: 9097,
|
|
66
|
+
testUrl: 'http://www.gstatic.com/generate_204',
|
|
67
|
+
timeout: 5000,
|
|
68
|
+
vergeConfigDir: getDefaultVergeConfigDir(),
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Clash Switcher 主类
|
|
72
|
+
* @description 用于控制 Clash Verge 代理切换的核心类
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* import { ClashSwitcher } from 'clash-switcher';
|
|
76
|
+
*
|
|
77
|
+
* const switcher = new ClashSwitcher({
|
|
78
|
+
* port: 9097,
|
|
79
|
+
* timeout: 3000,
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* // 获取所有代理组
|
|
83
|
+
* const groups = await switcher.getProxyGroups();
|
|
84
|
+
*
|
|
85
|
+
* // 切换节点
|
|
86
|
+
* await switcher.setNode('代理组名', '节点名');
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
class ClashSwitcher {
|
|
90
|
+
/**
|
|
91
|
+
* 创建 ClashSwitcher 实例
|
|
92
|
+
* @param config - 配置选项
|
|
93
|
+
* @param config.host - API 主机地址,默认 '127.0.0.1'
|
|
94
|
+
* @param config.port - API 端口号,默认 9097
|
|
95
|
+
* @param config.secret - API 访问密钥,默认无
|
|
96
|
+
* @param config.testUrl - 延迟测试 URL,默认 'http://www.gstatic.com/generate_204'
|
|
97
|
+
* @param config.timeout - 超时时间(毫秒),默认 5000
|
|
98
|
+
* @param config.vergeConfigDir - Clash Verge 配置目录,默认自动检测
|
|
99
|
+
*/
|
|
100
|
+
constructor(config) {
|
|
101
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
102
|
+
this.client = this.createClient();
|
|
103
|
+
}
|
|
104
|
+
createClient() {
|
|
105
|
+
const { host, port, timeout } = this.config;
|
|
106
|
+
const secret = this.config.secret ?? this.readSecretFromConfig();
|
|
107
|
+
return axios_1.default.create({
|
|
108
|
+
baseURL: `http://${host}:${port}`,
|
|
109
|
+
timeout,
|
|
110
|
+
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 从 Clash Verge 配置文件读取 secret
|
|
115
|
+
*/
|
|
116
|
+
readSecretFromConfig() {
|
|
117
|
+
try {
|
|
118
|
+
const configPath = path.join(this.config.vergeConfigDir, 'config.yaml');
|
|
119
|
+
if (!fs.existsSync(configPath))
|
|
120
|
+
return undefined;
|
|
121
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
122
|
+
const match = content.match(/^secret:\s*['"]?([^'"\n]+)['"]?/m);
|
|
123
|
+
return match?.[1]?.trim() || undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 更新配置
|
|
131
|
+
* @param config - 要更新的配置项,只需传入需要修改的字段
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* switcher.setConfig({ port: 9090 });
|
|
135
|
+
* switcher.setConfig({ timeout: 3000, testUrl: 'http://example.com' });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
setConfig(config) {
|
|
139
|
+
this.config = { ...this.config, ...config };
|
|
140
|
+
this.client = this.createClient();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 获取当前配置
|
|
144
|
+
* @returns 当前完整配置对象
|
|
145
|
+
*/
|
|
146
|
+
getConfig() {
|
|
147
|
+
return { ...this.config };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 获取当前代理模式
|
|
151
|
+
* @returns 当前模式:'rule'(规则) | 'global'(全局) | 'direct'(直连)
|
|
152
|
+
*/
|
|
153
|
+
async getMode() {
|
|
154
|
+
const { data } = await this.client.get('/configs');
|
|
155
|
+
return data.mode;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 设置代理模式
|
|
159
|
+
* @param mode - 目标模式:'rule'(规则) | 'global'(全局) | 'direct'(直连)
|
|
160
|
+
*/
|
|
161
|
+
async setMode(mode) {
|
|
162
|
+
await this.client.patch('/configs', { mode });
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 获取所有代理信息
|
|
166
|
+
*/
|
|
167
|
+
async getProxies() {
|
|
168
|
+
const { data } = await this.client.get('/proxies');
|
|
169
|
+
return data.proxies;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 获取代理组列表
|
|
173
|
+
* @param mode - 模式筛选
|
|
174
|
+
* - 不传或 'active': 当前激活模式的代理组
|
|
175
|
+
* - 'rule': 规则模式的代理组(排除 GLOBAL)
|
|
176
|
+
* - 'global': 全局模式的代理组(仅 GLOBAL)
|
|
177
|
+
* - 'direct': 直连模式(返回空数组)
|
|
178
|
+
* - 'all': 所有代理组
|
|
179
|
+
* @param filter - 结果筛选
|
|
180
|
+
* - 不传: 返回所有匹配的代理组
|
|
181
|
+
* - number: 按索引位置返回(从 0 开始)
|
|
182
|
+
* - string: 按名称模糊匹配返回
|
|
183
|
+
* @returns 代理组列表,按配置文件顺序排序
|
|
184
|
+
*/
|
|
185
|
+
async getGroups(mode, filter) {
|
|
186
|
+
// direct 模式直接返回空数组
|
|
187
|
+
if (mode === 'direct') {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
const proxies = await this.getProxies();
|
|
191
|
+
const allGroups = Object.values(proxies).filter((p) => 'all' in p && Array.isArray(p.all));
|
|
192
|
+
// 按配置文件顺序排序
|
|
193
|
+
const order = this.getProxyGroupOrder();
|
|
194
|
+
const sortedGroups = order.length > 0
|
|
195
|
+
? allGroups.sort((a, b) => {
|
|
196
|
+
const indexA = order.indexOf(a.name);
|
|
197
|
+
const indexB = order.indexOf(b.name);
|
|
198
|
+
if (indexA === -1 && indexB === -1)
|
|
199
|
+
return 0;
|
|
200
|
+
if (indexA === -1)
|
|
201
|
+
return 1;
|
|
202
|
+
if (indexB === -1)
|
|
203
|
+
return -1;
|
|
204
|
+
return indexA - indexB;
|
|
205
|
+
})
|
|
206
|
+
: allGroups;
|
|
207
|
+
// 系统保留组
|
|
208
|
+
const systemGroups = ['DIRECT', 'REJECT', 'PASS', 'COMPATIBLE'];
|
|
209
|
+
// 根据 mode 过滤
|
|
210
|
+
const effectiveMode = mode === 'active' || mode === undefined
|
|
211
|
+
? await this.getMode()
|
|
212
|
+
: mode;
|
|
213
|
+
let result;
|
|
214
|
+
if (effectiveMode === 'all') {
|
|
215
|
+
result = sortedGroups.filter(g => !systemGroups.includes(g.name));
|
|
216
|
+
}
|
|
217
|
+
else if (effectiveMode === 'global') {
|
|
218
|
+
result = sortedGroups.filter(g => g.name === 'GLOBAL');
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// rule 模式:排除 GLOBAL 和系统组
|
|
222
|
+
result = sortedGroups.filter(g => g.all.length > 0 &&
|
|
223
|
+
g.name !== 'GLOBAL' &&
|
|
224
|
+
!systemGroups.includes(g.name));
|
|
225
|
+
}
|
|
226
|
+
// 应用 filter 筛选
|
|
227
|
+
if (filter === undefined) {
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
if (typeof filter === 'number') {
|
|
231
|
+
// 按索引返回
|
|
232
|
+
if (filter < 0 || filter >= result.length) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
return [result[filter]];
|
|
236
|
+
}
|
|
237
|
+
// 按名称模糊匹配
|
|
238
|
+
const keyword = filter.toLowerCase();
|
|
239
|
+
return result.filter(g => g.name.toLowerCase().includes(keyword));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* 获取单个代理组
|
|
243
|
+
* @param mode - 模式筛选(同 getGroups)
|
|
244
|
+
* @param filter - 结果筛选
|
|
245
|
+
* - 不传: 返回第一个代理组(主代理组)
|
|
246
|
+
* - number: 按索引位置返回(从 0 开始)
|
|
247
|
+
* - string: 按名称模糊匹配返回第一个匹配的
|
|
248
|
+
* @returns 代理组对象,未找到返回 null
|
|
249
|
+
*/
|
|
250
|
+
async getGroup(mode, filter) {
|
|
251
|
+
const groups = await this.getGroups(mode);
|
|
252
|
+
if (groups.length === 0) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (filter === undefined) {
|
|
256
|
+
return groups[0];
|
|
257
|
+
}
|
|
258
|
+
if (typeof filter === 'number') {
|
|
259
|
+
if (filter < 0 || filter >= groups.length)
|
|
260
|
+
return null;
|
|
261
|
+
return groups[filter];
|
|
262
|
+
}
|
|
263
|
+
// 按名称模糊匹配
|
|
264
|
+
const keyword = filter.toLowerCase();
|
|
265
|
+
return groups.find(g => g.name.toLowerCase().includes(keyword)) || null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 从当前订阅配置文件读取代理组顺序
|
|
269
|
+
*/
|
|
270
|
+
getProxyGroupOrder() {
|
|
271
|
+
try {
|
|
272
|
+
const currentProfile = this.getSub();
|
|
273
|
+
if (!currentProfile)
|
|
274
|
+
return [];
|
|
275
|
+
const profilePath = path.join(this.config.vergeConfigDir, 'profiles', `${currentProfile.uid}.yaml`);
|
|
276
|
+
if (!fs.existsSync(profilePath))
|
|
277
|
+
return [];
|
|
278
|
+
const content = fs.readFileSync(profilePath, 'utf-8');
|
|
279
|
+
// 找到 proxy-groups: 部分
|
|
280
|
+
const groupsMatch = content.match(/^proxy-groups:\s*\n([\s\S]*?)(?=^[a-z-]+:|$)/m);
|
|
281
|
+
if (!groupsMatch)
|
|
282
|
+
return [];
|
|
283
|
+
const groupsSection = groupsMatch[1];
|
|
284
|
+
const order = [];
|
|
285
|
+
// 匹配内联格式: - { name: xxx, ... } 或 - {name: xxx, ...}
|
|
286
|
+
const inlineRegex = /^\s*-\s*\{\s*name:\s*([^,}]+)/gm;
|
|
287
|
+
// 匹配多行格式: - name: xxx
|
|
288
|
+
const multilineRegex = /^\s*-\s+name:\s*(.+)$/gm;
|
|
289
|
+
let match;
|
|
290
|
+
while ((match = inlineRegex.exec(groupsSection)) !== null) {
|
|
291
|
+
// 去掉引号
|
|
292
|
+
const name = match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
293
|
+
order.push(name);
|
|
294
|
+
}
|
|
295
|
+
// 如果内联格式没匹配到,尝试多行格式
|
|
296
|
+
if (order.length === 0) {
|
|
297
|
+
while ((match = multilineRegex.exec(groupsSection)) !== null) {
|
|
298
|
+
const name = match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
299
|
+
order.push(name);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return order;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 解析代理组(支持模糊匹配和索引)
|
|
310
|
+
*/
|
|
311
|
+
async resolveProxyGroup(groupArg) {
|
|
312
|
+
const groups = await this.getGroups();
|
|
313
|
+
if (groups.length === 0) {
|
|
314
|
+
throw new Error('未找到可用的代理组');
|
|
315
|
+
}
|
|
316
|
+
if (!groupArg) {
|
|
317
|
+
return groups[0];
|
|
318
|
+
}
|
|
319
|
+
if (groupArg.startsWith('#')) {
|
|
320
|
+
const index = parseInt(groupArg.slice(1), 10) - 1;
|
|
321
|
+
if (isNaN(index) || index < 0 || index >= groups.length) {
|
|
322
|
+
throw new Error(`无效的索引: ${groupArg},可用范围: #1 - #${groups.length}`);
|
|
323
|
+
}
|
|
324
|
+
return groups[index];
|
|
325
|
+
}
|
|
326
|
+
const keyword = groupArg.toLowerCase();
|
|
327
|
+
const matched = groups.find(g => g.name.toLowerCase().includes(keyword));
|
|
328
|
+
if (!matched) {
|
|
329
|
+
throw new Error(`未找到匹配的代理组: ${groupArg}`);
|
|
330
|
+
}
|
|
331
|
+
return matched;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 解析节点(支持模糊匹配和索引)
|
|
335
|
+
*/
|
|
336
|
+
resolveProxy(group, proxyArg) {
|
|
337
|
+
const proxies = group.all;
|
|
338
|
+
if (proxies.length === 0) {
|
|
339
|
+
throw new Error(`代理组 ${group.name} 没有可用节点`);
|
|
340
|
+
}
|
|
341
|
+
if (proxyArg.startsWith('#')) {
|
|
342
|
+
const index = parseInt(proxyArg.slice(1), 10) - 1;
|
|
343
|
+
if (isNaN(index) || index < 0 || index >= proxies.length) {
|
|
344
|
+
throw new Error(`无效的索引: ${proxyArg},可用范围: #1 - #${proxies.length}`);
|
|
345
|
+
}
|
|
346
|
+
return proxies[index];
|
|
347
|
+
}
|
|
348
|
+
const keyword = proxyArg.toLowerCase();
|
|
349
|
+
const matched = proxies.find(p => p.toLowerCase().includes(keyword));
|
|
350
|
+
if (!matched) {
|
|
351
|
+
throw new Error(`未找到匹配的节点: ${proxyArg}`);
|
|
352
|
+
}
|
|
353
|
+
return matched;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 切换节点
|
|
357
|
+
* @param group - 代理组筛选:索引(number)或名称模糊匹配(string)
|
|
358
|
+
* @param node - 目标节点名称,支持模糊匹配
|
|
359
|
+
*/
|
|
360
|
+
async setNode(group, node) {
|
|
361
|
+
const resolvedGroup = await this.getGroup(undefined, group);
|
|
362
|
+
if (!resolvedGroup) {
|
|
363
|
+
throw new Error(`未找到代理组: ${group}`);
|
|
364
|
+
}
|
|
365
|
+
const keyword = node.toLowerCase();
|
|
366
|
+
const resolvedNode = resolvedGroup.all.find(n => n.toLowerCase().includes(keyword));
|
|
367
|
+
if (!resolvedNode) {
|
|
368
|
+
throw new Error(`未找到节点: ${node}`);
|
|
369
|
+
}
|
|
370
|
+
await this.client.put(`/proxies/${encodeURIComponent(resolvedGroup.name)}`, { name: resolvedNode });
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 获取代理组内的所有节点
|
|
374
|
+
* @param filter - 代理组筛选:不传返回主代理组,索引(number)或名称模糊匹配(string)
|
|
375
|
+
* @returns 节点名称列表
|
|
376
|
+
*/
|
|
377
|
+
async getNodes(filter) {
|
|
378
|
+
const group = await this.getGroup(undefined, filter);
|
|
379
|
+
return group?.all || [];
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* 测试单个节点延迟
|
|
383
|
+
* @param node - 节点名称,支持模糊匹配
|
|
384
|
+
* @param url - 测试 URL,不传使用默认配置
|
|
385
|
+
* @param timeout - 超时时间(毫秒),不传使用默认配置
|
|
386
|
+
* @returns 延迟测试结果 { name: 完整节点名, delay: 延迟毫秒数 }
|
|
387
|
+
*/
|
|
388
|
+
async testNode(node, url, timeout) {
|
|
389
|
+
// 从主代理组中模糊匹配节点名
|
|
390
|
+
const group = await this.getGroup();
|
|
391
|
+
if (!group) {
|
|
392
|
+
throw new Error('未找到代理组');
|
|
393
|
+
}
|
|
394
|
+
const keyword = node.toLowerCase();
|
|
395
|
+
const resolvedNode = group.all.find(n => n.toLowerCase().includes(keyword)) || node;
|
|
396
|
+
const testUrl = url || this.config.testUrl;
|
|
397
|
+
const { data } = await this.client.get(`/proxies/${encodeURIComponent(resolvedNode)}/delay`, { params: { url: testUrl, timeout: timeout || this.config.timeout } });
|
|
398
|
+
return { name: resolvedNode, delay: data.delay };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 测试多个节点延迟
|
|
402
|
+
* @param filter - 筛选参数
|
|
403
|
+
* - 不传: 测试主代理组的所有节点
|
|
404
|
+
* - number: 按索引指定代理组
|
|
405
|
+
* - string: 按名称模糊匹配代理组
|
|
406
|
+
* - string[]: 指定节点名称列表(可配合 getNodes 使用)
|
|
407
|
+
* @param url - 测试 URL,不传使用默认配置
|
|
408
|
+
* @param timeout - 超时时间(毫秒),不传使用默认配置
|
|
409
|
+
* @returns 延迟测试结果列表,delay 为 -1 表示超时
|
|
410
|
+
*/
|
|
411
|
+
async testNodes(filter, url, timeout) {
|
|
412
|
+
const testUrl = url || this.config.testUrl;
|
|
413
|
+
const testTimeout = timeout || this.config.timeout;
|
|
414
|
+
// 如果传入节点列表,逐个测试
|
|
415
|
+
if (Array.isArray(filter)) {
|
|
416
|
+
const results = [];
|
|
417
|
+
for (const node of filter) {
|
|
418
|
+
try {
|
|
419
|
+
const { data } = await this.client.get(`/proxies/${encodeURIComponent(node)}/delay`, { params: { url: testUrl, timeout: testTimeout } });
|
|
420
|
+
results.push({ name: node, delay: data.delay });
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
results.push({ name: node, delay: -1 });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return results;
|
|
427
|
+
}
|
|
428
|
+
// 否则按代理组测试
|
|
429
|
+
const group = await this.getGroup(undefined, filter);
|
|
430
|
+
if (!group) {
|
|
431
|
+
throw new Error(`未找到代理组: ${filter}`);
|
|
432
|
+
}
|
|
433
|
+
const { data } = await this.client.get(`/group/${encodeURIComponent(group.name)}/delay`, { params: { url: testUrl, timeout: testTimeout } });
|
|
434
|
+
return Object.entries(data).map(([name, delay]) => ({
|
|
435
|
+
name,
|
|
436
|
+
delay,
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* 自动选择最快节点
|
|
441
|
+
* @param filter - 代理组筛选:不传使用主代理组,索引(number)或名称模糊匹配(string)
|
|
442
|
+
* @param url - 测试 URL,不传使用默认配置
|
|
443
|
+
* @param timeout - 超时时间(毫秒),不传使用默认配置
|
|
444
|
+
* @returns 切换到的节点信息,无可用节点返回 null
|
|
445
|
+
*/
|
|
446
|
+
async autoNode(filter, url, timeout) {
|
|
447
|
+
const group = await this.getGroup(undefined, filter);
|
|
448
|
+
if (!group) {
|
|
449
|
+
throw new Error(`未找到代理组: ${filter}`);
|
|
450
|
+
}
|
|
451
|
+
const results = await this.testNodes(filter, url, timeout);
|
|
452
|
+
const available = results
|
|
453
|
+
.filter(r => r.delay > 0)
|
|
454
|
+
.sort((a, b) => a.delay - b.delay);
|
|
455
|
+
if (available.length === 0) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const best = available[0];
|
|
459
|
+
await this.setNode(group.name, best.name);
|
|
460
|
+
return best;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 获取所有订阅列表
|
|
464
|
+
* @returns 远程订阅列表(type 为 'remote' 的订阅)
|
|
465
|
+
*/
|
|
466
|
+
getSubs() {
|
|
467
|
+
const profilesPath = path.join(this.config.vergeConfigDir, 'profiles.yaml');
|
|
468
|
+
if (!fs.existsSync(profilesPath)) {
|
|
469
|
+
throw new Error(`未找到配置文件: ${profilesPath}`);
|
|
470
|
+
}
|
|
471
|
+
const content = fs.readFileSync(profilesPath, 'utf-8');
|
|
472
|
+
const profiles = this.parseProfilesYaml(content);
|
|
473
|
+
return profiles.items.filter(p => p.type === 'remote');
|
|
474
|
+
}
|
|
475
|
+
parseProfilesYaml(content) {
|
|
476
|
+
const result = { current: [], items: [] };
|
|
477
|
+
const lines = content.split('\n');
|
|
478
|
+
let inItems = false;
|
|
479
|
+
let currentItem = null;
|
|
480
|
+
for (const line of lines) {
|
|
481
|
+
if (line.startsWith('current:')) {
|
|
482
|
+
const value = line.slice(8).trim();
|
|
483
|
+
if (value)
|
|
484
|
+
result.current = [value];
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (line.trim() === 'items:') {
|
|
488
|
+
inItems = true;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (inItems) {
|
|
492
|
+
if (line.startsWith('- ')) {
|
|
493
|
+
if (currentItem?.uid)
|
|
494
|
+
result.items.push(currentItem);
|
|
495
|
+
currentItem = {};
|
|
496
|
+
const match = line.slice(2).match(/^(\w+):\s*(.*)$/);
|
|
497
|
+
if (match && match[2] && match[2] !== 'null') {
|
|
498
|
+
currentItem[match[1]] = match[2];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else if (line.startsWith(' ') && !line.startsWith(' ') && currentItem) {
|
|
502
|
+
const match = line.trim().match(/^(\w+):\s*(.*)$/);
|
|
503
|
+
if (match) {
|
|
504
|
+
let value = match[2].trim();
|
|
505
|
+
if (value === 'null' || value === '')
|
|
506
|
+
value = undefined;
|
|
507
|
+
else if (/^\d+$/.test(value))
|
|
508
|
+
value = parseInt(value);
|
|
509
|
+
if (value !== undefined)
|
|
510
|
+
currentItem[match[1]] = value;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (currentItem?.uid)
|
|
516
|
+
result.items.push(currentItem);
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* 获取订阅
|
|
521
|
+
* @param filter - 筛选参数:不传返回当前激活订阅,索引(number)或名称/UID模糊匹配(string)
|
|
522
|
+
* @returns 订阅对象,未找到返回 null
|
|
523
|
+
*/
|
|
524
|
+
getSub(filter) {
|
|
525
|
+
const subs = this.getSubs();
|
|
526
|
+
if (filter === undefined) {
|
|
527
|
+
// 返回当前激活的订阅
|
|
528
|
+
const profilesPath = path.join(this.config.vergeConfigDir, 'profiles.yaml');
|
|
529
|
+
if (!fs.existsSync(profilesPath))
|
|
530
|
+
return null;
|
|
531
|
+
const content = fs.readFileSync(profilesPath, 'utf-8');
|
|
532
|
+
const { current } = this.parseProfilesYaml(content);
|
|
533
|
+
if (current.length === 0)
|
|
534
|
+
return null;
|
|
535
|
+
return subs.find(p => p.uid === current[0]) || null;
|
|
536
|
+
}
|
|
537
|
+
if (typeof filter === 'number') {
|
|
538
|
+
if (filter < 0 || filter >= subs.length)
|
|
539
|
+
return null;
|
|
540
|
+
return subs[filter];
|
|
541
|
+
}
|
|
542
|
+
// 按名称模糊匹配
|
|
543
|
+
const keyword = filter.toLowerCase();
|
|
544
|
+
return subs.find(p => p.uid === filter || p.name?.toLowerCase().includes(keyword)) || null;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* 切换订阅
|
|
548
|
+
* @param filter - 订阅筛选:索引(number)或名称/UID模糊匹配(string)
|
|
549
|
+
* @param restart - 是否重启 Clash Verge,默认 true
|
|
550
|
+
* @returns 切换到的订阅对象
|
|
551
|
+
*/
|
|
552
|
+
async setSub(filter, restart = true) {
|
|
553
|
+
const sub = this.getSub(filter);
|
|
554
|
+
if (!sub) {
|
|
555
|
+
throw new Error(`未找到订阅: ${filter}`);
|
|
556
|
+
}
|
|
557
|
+
const profilesPath = path.join(this.config.vergeConfigDir, 'profiles.yaml');
|
|
558
|
+
let content = fs.readFileSync(profilesPath, 'utf-8');
|
|
559
|
+
content = content.replace(/^current:.*$/m, `current: ${sub.uid}`);
|
|
560
|
+
fs.writeFileSync(profilesPath, content, 'utf-8');
|
|
561
|
+
if (restart) {
|
|
562
|
+
await this.restartClashVerge();
|
|
563
|
+
await this.waitReady(10000, 200, true);
|
|
564
|
+
}
|
|
565
|
+
return sub;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* 等待 API 就绪
|
|
569
|
+
* @param timeout - 超时时间(毫秒),默认 10000
|
|
570
|
+
* @param interval - 检测间隔(毫秒),默认 200
|
|
571
|
+
* @param waitDisconnect - 是否先等待 API 断开,默认 false
|
|
572
|
+
*/
|
|
573
|
+
async waitReady(timeout = 10000, interval = 200, waitDisconnect = false) {
|
|
574
|
+
const start = Date.now();
|
|
575
|
+
// 先等待 API 断开
|
|
576
|
+
if (waitDisconnect) {
|
|
577
|
+
while (Date.now() - start < timeout) {
|
|
578
|
+
try {
|
|
579
|
+
await this.client.get('/configs');
|
|
580
|
+
// 还能连接,继续等待断开
|
|
581
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// 断开了,跳出循环
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// 超时也继续执行,不报错
|
|
589
|
+
}
|
|
590
|
+
// 再等待 API 恢复
|
|
591
|
+
while (Date.now() - start < timeout) {
|
|
592
|
+
try {
|
|
593
|
+
const { data: proxies } = await this.client.get('/proxies');
|
|
594
|
+
const groups = Object.values(proxies.proxies || {}).filter((p) => 'all' in p && Array.isArray(p.all));
|
|
595
|
+
if (groups.length > 0 && groups.some((g) => g.all?.length > 0)) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// 忽略错误,继续等待
|
|
601
|
+
}
|
|
602
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
603
|
+
}
|
|
604
|
+
throw new Error('等待 API 就绪超时');
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* 检测进程是否存在
|
|
608
|
+
*/
|
|
609
|
+
async isProcessRunning(processName) {
|
|
610
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
611
|
+
const platform = os.platform();
|
|
612
|
+
try {
|
|
613
|
+
if (platform === 'win32') {
|
|
614
|
+
const { stdout } = await execAsync(`tasklist /FI "IMAGENAME eq ${processName}" /NH`);
|
|
615
|
+
return stdout.toLowerCase().includes(processName.toLowerCase());
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
await execAsync(`pgrep -x "${processName}"`);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* 等待进程退出
|
|
628
|
+
*/
|
|
629
|
+
async waitForProcessExit(processName, timeout = 5000, interval = 100) {
|
|
630
|
+
const start = Date.now();
|
|
631
|
+
while (Date.now() - start < timeout) {
|
|
632
|
+
if (!(await this.isProcessRunning(processName))) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* 重启 Clash Verge 应用
|
|
640
|
+
*/
|
|
641
|
+
async restartClashVerge() {
|
|
642
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
643
|
+
const platform = os.platform();
|
|
644
|
+
if (platform === 'win32') {
|
|
645
|
+
const { stdout } = await execAsync('powershell -Command "Get-Process clash-verge -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Path"');
|
|
646
|
+
const appPath = stdout.trim();
|
|
647
|
+
if (!appPath)
|
|
648
|
+
throw new Error('未找到运行中的 Clash Verge 进程');
|
|
649
|
+
await execAsync('taskkill /IM "clash-verge.exe" /F').catch(() => { });
|
|
650
|
+
await this.waitForProcessExit('clash-verge.exe');
|
|
651
|
+
const child = (0, child_process_1.spawn)('cmd', ['/c', 'start', '""', appPath], {
|
|
652
|
+
detached: true,
|
|
653
|
+
stdio: 'ignore',
|
|
654
|
+
});
|
|
655
|
+
child.unref();
|
|
656
|
+
}
|
|
657
|
+
else if (platform === 'darwin') {
|
|
658
|
+
await execAsync('pkill -x "Clash Verge"').catch(() => { });
|
|
659
|
+
await this.waitForProcessExit('Clash Verge');
|
|
660
|
+
const child = (0, child_process_1.spawn)('open', ['-a', 'Clash Verge'], {
|
|
661
|
+
detached: true,
|
|
662
|
+
stdio: 'ignore',
|
|
663
|
+
});
|
|
664
|
+
child.unref();
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
await execAsync('pkill -x clash-verge').catch(() => { });
|
|
668
|
+
await this.waitForProcessExit('clash-verge');
|
|
669
|
+
const child = (0, child_process_1.spawn)('clash-verge', [], {
|
|
670
|
+
detached: true,
|
|
671
|
+
stdio: 'ignore',
|
|
672
|
+
});
|
|
673
|
+
child.unref();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
exports.ClashSwitcher = ClashSwitcher;
|
package/dist/index.d.ts
ADDED