Skip to content

Commit 5f4249a

Browse files
adaexclaude
andcommitted
feat: add local bench command to benchmark all free subscription sources
Downloads all sources via gh-proxy, starts an isolated mihomo instance (port 29090), tests proxy latency in batches of 100, and ranks sources by alive rate and median delay. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent df31245 commit 5f4249a

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"fetch": "tsx src/fetch.ts",
77
"check": "tsx src/check.ts",
88
"output": "tsx src/output.ts",
9+
"bench": "tsx src/bench.ts",
910
"run": "tsx src/fetch.ts && tsx src/check.ts && tsx src/output.ts"
1011
},
1112
"devDependencies": {

src/bench.ts

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { spawn, type ChildProcess } from 'node:child_process';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
import yaml from 'js-yaml';
6+
7+
import { sources } from './sources.js';
8+
import type { Proxy, ParsedConfig } from './types.js';
9+
import { ROOT } from './utils.js';
10+
11+
const GH_PROXY = 'https://v6.gh-proxy.org';
12+
const GH_RAW = 'https://raw.githubusercontent.com';
13+
14+
const TEMP_DIR = path.join(ROOT, '.bench-tmp');
15+
const TIMEOUT = 1500;
16+
const CONCURRENCY = 100;
17+
const API_PORT = 29090;
18+
const API_BASE = `http://127.0.0.1:${API_PORT}`;
19+
const TEST_URL = 'http://www.gstatic.com/generate_204';
20+
21+
const c = {
22+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
23+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
24+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
25+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
26+
gray: (s: string) => `\x1b[90m${s}\x1b[0m`,
27+
};
28+
29+
interface DownloadedSource {
30+
name: string;
31+
proxies: Proxy[];
32+
proxyGroups: number;
33+
error?: string;
34+
}
35+
36+
interface SourceResult {
37+
name: string;
38+
downloadOk: boolean;
39+
downloadError?: string;
40+
totalProxies: number;
41+
proxyGroups: number;
42+
alive: number;
43+
dead: number;
44+
avgDelay: number;
45+
minDelay: number;
46+
medianDelay: number;
47+
}
48+
49+
function proxyUrl(url: string): string {
50+
return url.replace(GH_RAW, `${GH_PROXY}/raw.githubusercontent.com`);
51+
}
52+
53+
async function downloadSource(source: { name: string; url: string }): Promise<DownloadedSource> {
54+
const entry: DownloadedSource = { name: source.name, proxies: [], proxyGroups: 0 };
55+
try {
56+
const res = await fetch(proxyUrl(source.url), {
57+
signal: AbortSignal.timeout(30_000),
58+
headers: { 'User-Agent': 'clash.meta' },
59+
});
60+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
61+
const text = await res.text();
62+
if (!text.trim()) throw new Error('内容为空');
63+
64+
const config = yaml.load(text) as ParsedConfig;
65+
if (!config?.proxies?.length) throw new Error('无节点');
66+
67+
if (config['proxy-groups']) {
68+
entry.proxyGroups = (config['proxy-groups'] as unknown[]).length;
69+
}
70+
entry.proxies = config.proxies.map(p => ({ ...p, name: `[${source.name}] ${p.name}` }));
71+
} catch (e) {
72+
entry.error = (e as Error).message;
73+
}
74+
return entry;
75+
}
76+
77+
function findMihomoBinary(): string {
78+
const candidates = [
79+
path.join(ROOT, 'mihomo'),
80+
path.join(process.env.HOME || '~', '.mihomo-cli', 'kernel', 'mihomo'),
81+
];
82+
for (const p of candidates) {
83+
if (fs.existsSync(p)) return p;
84+
}
85+
throw new Error('未找到 mihomo 二进制,请先下载内核');
86+
}
87+
88+
function deduplicateNames(proxies: Proxy[]): Proxy[] {
89+
const nameCount = new Map<string, number>();
90+
return proxies.map(p => {
91+
const count = (nameCount.get(p.name) || 0) + 1;
92+
nameCount.set(p.name, count);
93+
return count > 1 ? { ...p, name: `${p.name} #${count}` } : p;
94+
});
95+
}
96+
97+
function buildConfig(proxies: Proxy[]): string {
98+
const config = {
99+
'mixed-port': 17890,
100+
'allow-lan': false,
101+
'external-controller': `127.0.0.1:${API_PORT}`,
102+
'log-level': 'error',
103+
'geodata-mode': true,
104+
proxies,
105+
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: proxies.map(p => p.name) }],
106+
rules: ['MATCH,PROXY'],
107+
};
108+
return yaml.dump(config, { lineWidth: -1, noRefs: true });
109+
}
110+
111+
async function startMihomo(configPath: string, binary: string): Promise<ChildProcess> {
112+
const logPath = path.join(TEMP_DIR, 'mihomo.log');
113+
const logFd = fs.openSync(logPath, 'w');
114+
const child = spawn(binary, ['-f', configPath], {
115+
detached: true,
116+
stdio: ['ignore', logFd, logFd],
117+
});
118+
fs.closeSync(logFd);
119+
child.unref();
120+
121+
for (let i = 0; i < 60; i++) {
122+
await new Promise(r => setTimeout(r, 500));
123+
try {
124+
const res = await fetch(`${API_BASE}/version`, { signal: AbortSignal.timeout(2000) });
125+
if (res.ok) return child;
126+
} catch { /* not ready */ }
127+
if (child.exitCode !== null) {
128+
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8').slice(-500) : '';
129+
throw new Error(`mihomo 启动失败\n${log}`);
130+
}
131+
}
132+
throw new Error('mihomo 启动超时');
133+
}
134+
135+
function stopMihomo(child: ChildProcess): void {
136+
try { if (child.pid) process.kill(child.pid, 'SIGTERM'); } catch { /* already dead */ }
137+
}
138+
139+
async function testProxy(name: string): Promise<{ name: string; delay: number | null }> {
140+
const url = `${API_BASE}/proxies/${encodeURIComponent(name)}/delay?timeout=${TIMEOUT}&url=${encodeURIComponent(TEST_URL)}`;
141+
try {
142+
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT + 3000) });
143+
const data = (await res.json()) as { delay?: number; message?: string };
144+
return { name, delay: data.delay && data.delay > 0 ? data.delay : null };
145+
} catch {
146+
return { name, delay: null };
147+
}
148+
}
149+
150+
function computeResult(
151+
source: DownloadedSource,
152+
resultsByName: Map<string, number | null>,
153+
originalToDeduped: Map<Proxy, string>,
154+
): SourceResult {
155+
const delays: number[] = [];
156+
for (const p of source.proxies) {
157+
const dedupedName = originalToDeduped.get(p);
158+
if (!dedupedName) continue;
159+
const d = resultsByName.get(dedupedName);
160+
if (d !== undefined && d !== null) delays.push(d);
161+
}
162+
const alive = delays.length;
163+
const dead = source.proxies.length - alive;
164+
165+
if (alive === 0) {
166+
return {
167+
name: source.name, downloadOk: !source.error, downloadError: source.error,
168+
totalProxies: source.proxies.length, proxyGroups: source.proxyGroups,
169+
alive: 0, dead, avgDelay: 0, minDelay: 0, medianDelay: 0,
170+
};
171+
}
172+
173+
delays.sort((a, b) => a - b);
174+
return {
175+
name: source.name, downloadOk: true,
176+
totalProxies: source.proxies.length, proxyGroups: source.proxyGroups,
177+
alive, dead,
178+
avgDelay: Math.round(delays.reduce((s, d) => s + d, 0) / alive),
179+
minDelay: delays[0],
180+
medianDelay: delays[Math.floor(delays.length / 2)],
181+
};
182+
}
183+
184+
function formatSourceName(name: string, sourceOrder: Map<string, number>): string {
185+
return `${String((sourceOrder.get(name) ?? 0) + 1).padStart(2, '0')}-${name}`;
186+
}
187+
188+
function printRanking(results: SourceResult[], sourceOrder: Map<string, number>): void {
189+
const valid = results
190+
.filter(r => r.downloadOk && r.alive > 0)
191+
.sort((a, b) => {
192+
const rateA = a.alive / a.totalProxies;
193+
const rateB = b.alive / b.totalProxies;
194+
if (Math.abs(rateA - rateB) > 0.1) return rateB - rateA;
195+
return a.medianDelay - b.medianDelay;
196+
});
197+
198+
if (valid.length === 0) {
199+
console.log(c.yellow('没有可用的订阅源'));
200+
return;
201+
}
202+
203+
console.log(c.cyan('排名:\n'));
204+
205+
const namedResults = valid.map(r => ({
206+
...r, displayName: formatSourceName(r.name, sourceOrder),
207+
}));
208+
209+
const nameWidth = Math.max(12, ...namedResults.map(r => r.displayName.length));
210+
const pad = (s: string, w: number) => s + ' '.repeat(Math.max(0, w - s.length));
211+
212+
console.log(
213+
` ${'#'.padStart(3)} ${pad('名称', nameWidth)} ${pad('存活率', 8)} ${pad('存活', 6)} ${pad('总数', 6)} ${pad('分组', 6)} ${pad('中位', 7)} ${pad('平均', 7)}`,
214+
);
215+
console.log(
216+
` ${'─'.repeat(3)} ${'─'.repeat(nameWidth)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(7)}`,
217+
);
218+
219+
for (let i = 0; i < namedResults.length; i++) {
220+
const r = namedResults[i];
221+
const rate = ((r.alive / r.totalProxies) * 100).toFixed(1);
222+
const rateColor = r.alive / r.totalProxies > 0.3 ? c.green : c.yellow;
223+
const groups = r.proxyGroups > 0 ? String(r.proxyGroups) : '-';
224+
console.log(
225+
` ${String(i + 1).padStart(3)} ${r.displayName.padEnd(nameWidth)} ${rateColor(pad(`${rate}%`, 8))} ${String(r.alive).padEnd(6)} ${String(r.totalProxies).padEnd(6)} ${groups.padEnd(6)} ${pad(`${r.medianDelay}ms`, 7)} ${pad(`${r.avgDelay}ms`, 7)}`,
226+
);
227+
}
228+
229+
console.log('');
230+
231+
const failed = results.filter(r => !r.downloadOk);
232+
const noAlive = results.filter(r => r.downloadOk && r.alive === 0);
233+
if (failed.length > 0) {
234+
const names = failed.map(r => formatSourceName(r.name, sourceOrder));
235+
console.log(c.gray(`下载失败: ${names.join(', ')}`));
236+
}
237+
if (noAlive.length > 0) {
238+
const names = noAlive.map(r => formatSourceName(r.name, sourceOrder));
239+
console.log(c.gray(`无存活节点: ${names.join(', ')}`));
240+
}
241+
}
242+
243+
async function main() {
244+
const binary = findMihomoBinary();
245+
console.log(`使用内核: ${binary}`);
246+
247+
const sourceOrder = new Map(sources.map((s, i) => [s.name, i]));
248+
249+
console.log(c.cyan(`\n基准测试 ${sources.length} 个免费订阅源`));
250+
console.log(`超时: ${TIMEOUT}ms 并发: ${CONCURRENCY}\n`);
251+
252+
fs.mkdirSync(TEMP_DIR, { recursive: true });
253+
let child: ChildProcess | null = null;
254+
255+
try {
256+
console.log(c.cyan('下载订阅...'));
257+
const downloaded = await Promise.all(sources.map(s => downloadSource(s)));
258+
for (const d of downloaded) {
259+
const label = formatSourceName(d.name, sourceOrder);
260+
if (d.error) {
261+
console.log(` ${c.red('✗')} ${label}: ${c.gray(d.error)}`);
262+
} else {
263+
const groupsInfo = d.proxyGroups > 0 ? ` ${d.proxyGroups}组` : '';
264+
console.log(` ${c.green('✓')} ${label}: ${d.proxies.length} 个节点${groupsInfo}`);
265+
}
266+
}
267+
268+
const allProxies = downloaded.flatMap(d => d.proxies);
269+
const successCount = downloaded.filter(d => d.proxies.length > 0).length;
270+
271+
if (allProxies.length === 0) {
272+
console.log(c.red('\n所有订阅源下载失败或无节点'));
273+
return;
274+
}
275+
276+
console.log(`\n共 ${allProxies.length} 个节点,来自 ${successCount} 个源\n`);
277+
278+
const dedupedProxies = deduplicateNames(allProxies);
279+
const originalToDeduped = new Map<Proxy, string>();
280+
for (let i = 0; i < allProxies.length; i++) {
281+
originalToDeduped.set(allProxies[i], dedupedProxies[i].name);
282+
}
283+
const configContent = buildConfig(dedupedProxies);
284+
const configPath = path.join(TEMP_DIR, 'config.yaml');
285+
fs.writeFileSync(configPath, configContent, 'utf-8');
286+
287+
console.log(c.cyan('启动测试实例...'));
288+
child = await startMihomo(configPath, binary);
289+
console.log(`${c.green('已启动')} (端口 17890/${API_PORT})\n`);
290+
291+
console.log(c.cyan('测试节点延迟...'));
292+
const allNames = dedupedProxies.map(p => p.name);
293+
const resultsByName = new Map<string, number | null>();
294+
let tested = 0;
295+
let aliveCount = 0;
296+
297+
for (let i = 0; i < allNames.length; i += CONCURRENCY) {
298+
const batch = allNames.slice(i, i + CONCURRENCY);
299+
const batchResults = await Promise.all(batch.map(n => testProxy(n)));
300+
for (const r of batchResults) {
301+
resultsByName.set(r.name, r.delay);
302+
if (r.delay !== null) aliveCount++;
303+
}
304+
tested += batch.length;
305+
const pct = ((tested / allNames.length) * 100).toFixed(0);
306+
process.stdout.write(`\r 进度 ${pct}% 已测 ${tested}/${allNames.length} 存活 ${c.green(String(aliveCount))}`);
307+
}
308+
process.stdout.write(`\r${' '.repeat(80)}\r`);
309+
console.log(`测试完成: ${c.green(String(aliveCount))} 存活 / ${allNames.length - aliveCount} 超时 / ${allNames.length} 总计\n`);
310+
311+
const results = downloaded.map(d => computeResult(d, resultsByName, originalToDeduped));
312+
printRanking(results, sourceOrder);
313+
} finally {
314+
if (child) stopMihomo(child);
315+
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
316+
}
317+
}
318+
319+
main();

0 commit comments

Comments
 (0)