|
| 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