/* 批量下载测速:两阶段流程 —— 先对全部节点并发延迟预检(不切换节点),再仅对合格节点按延迟升序串行真实下载测速。 */
let batchRunPromise = null
/** 历史批量出口检测的当前停止函数(模块级,供 modal onCancel 调用);未运行时为 null。 */
let egressStop = null
/** 历史测速结果保存文件路径(仅保存纯数据 JSON)。 */
const historyFilePath = () => 'data/plugin-data/batch-download-speed/results.json'
/** 串行化历史结果写入队列,避免并发写盘导致文件内容顺序错乱。 */
let historySaveTask = Promise.resolve()
/** 将一轮测速结果持久化保存;写入失败静默忽略,不打断测速流程。 */
const saveHistory = (payload) => {
historySaveTask = historySaveTask.then(async () => {
try {
await Plugins.MakeDir('data/plugin-data/batch-download-speed')
await Plugins.WriteFile(historyFilePath(), JSON.stringify(payload, null, 2))
} catch {
/* 保存失败不影响测速流程 */
}
})
return historySaveTask
}
/** 读取上一次保存的测速历史;文件不存在或解析失败时返回 null(不抛错)。
* 兼容旧版本结果:缺少 delay/groupName/testUrl/testedAt 时补齐默认值。 */
const loadHistory = async () => {
try {
const raw = await Plugins.ReadFile(historyFilePath())
if (!raw) return null
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
if (!data || !Array.isArray(data.results)) return null
data.results = data.results.map((row) => ({
...row,
delay: Number.isFinite(Number(row.delay)) && Number(row.delay) > 0 ? Number(row.delay) : null,
groupName: row.groupName || data.groupName || '',
testUrl: row.testUrl || data.testUrl || '',
testedAt: row.testedAt || data.savedAt || ''
}))
return data
} catch {
return null
}
}
const onRun = async () => {
if (batchRunPromise) {
Plugins.message.warn('批量测速正在进行中,请先关闭当前面板或停止测试')
return
}
const modal = Plugins.modal({
title: Plugin.name || '批量真实下载测速',
width: '80',
height: '85',
submit: false,
cancelText: '关闭',
onCancel: async () => {
egressStop?.()
if (runBatchTest.active) {
runBatchTest.stop?.()
await runBatchTest.active
}
return true
}
})
const content = {
template: `
{{ statusText }}{{ progress }}/{{ total }}
当前节点:{{ currentNode || '—' }}
| 节点 | 延迟 | 状态 | MB/s | Mbps | 下载量 | 有效时间 | 错误原因 | 国外出口 | 操作 |
| {{ row.name }} | {{ formatDelay(row.delay) }} | {{ row.status }} | {{ row.mb }} | {{ row.mbps }} | {{ row.bytesText }} | {{ row.time }} | {{ row.error || '—' }} | {{ overseasText(row) }} | |
选择策略组后开始测试
历史结果 · 保存于 {{ formatTime(history.savedAt) }} · 策略组 {{ history.groupName || '—' }} · 测试 URL {{ history.testUrl || '—' }}
暂无历史记录
| 节点 | 延迟 | 状态 | MB/s | Mbps | 下载量 | 有效时间 | 错误原因 | 国外出口 | 操作 |
| {{ row.name }} | {{ formatDelay(row.delay) }} | {{ row.status }} | {{ row.mb }} | {{ row.mbps }} | {{ row.bytesText }} | {{ row.time }} | {{ row.error || '—' }} | {{ overseasText(row) }} | |
`,
setup() {
const { ref, computed, onMounted, watch } = Vue
const api = Plugins.useKernelApiStore()
/** 获取当前可供测试的 Selector 策略组。 */
const selectors = () => Object.values(api.proxies || {}).filter((p) => p?.type === 'Selector' && Array.isArray(p.all) && p.all.length)
const initial = selectors()
const form = ref({
group:
Plugin.GroupName && initial.some((p) => p.name === Plugin.GroupName || p.tag === Plugin.GroupName)
? Plugin.GroupName
: initial[0]?.name || initial[0]?.tag || '',
url: Plugin.TestUrl || 'http://hkg.download.datapacket.com/100mb.bin',
presetUrl: '',
pingUrl: Plugin.PingUrl || 'https://www.gstatic.com/generate_204',
maxDelay: Number(Plugin.MaxDelayMs) || 3000,
seconds: Number(Plugin.TimeoutSeconds) || 5,
concurrency: Number(Plugin.PrecheckConcurrency) || 10,
limit: Number(Plugin.NodeCount) || 0,
downloadLimit: Number(Plugin.DownloadLimit) || 0
})
const results = ref([])
const running = ref(false)
const progress = ref(0)
const total = ref(0)
const currentNode = ref('')
const statusText = ref('准备就绪')
const groupOptions = computed(() => selectors().map((p) => ({ label: p.name || p.tag, value: p.name || p.tag })))
/** 下载测速 URL 的预设下拉选项:自定义 + 5 个内置测速地址。 */
const presetUrlOptions = computed(() => [{ label: '自定义', value: '' }, ...PRESET_URLS])
/** 选择预设地址时填入 URL 输入框;仅当选择的是实际地址时生效。 */
watch(
() => form.value.presetUrl,
(value) => {
if (value) form.value.url = value
}
)
/** 手动修改 URL 且不再是当前所选预设时,将下拉复位为“自定义”,避免显示误导。 */
watch(
() => form.value.url,
(value) => {
if (value && !PRESET_URLS.some((p) => p.value === value)) form.value.presetUrl = ''
}
)
const progressPercent = computed(() => (total.value ? Math.round((progress.value * 100) / total.value) : 0))
const history = ref(null)
/** 将时间戳格式化为本地可读文本。 */
const formatTime = (value) => (value ? new Date(value).toLocaleString() : '未知')
/** 将有效延迟格式化为 "123 ms",无效或缺失时显示占位符。 */
const formatDelay = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? `${Math.round(Number(value))} ms` : '—')
/** 生成“国外出口”列文本:错误原因 > 归属地 · IP > 占位符。 */
const overseasText = (row) => (row.overseasError ? row.overseasError : row.overseasIp ? `${row.overseasPlace} · ${row.overseasIp}` : '—')
/** 将一条成功测速结果对应的节点,直接切换为当前 Selector 的选择;不做任何测速恢复。 */
const useResult = async (row) => {
if (running.value) return Plugins.message.warn('批量测速进行中,请先停止测试再使用节点')
if (row.status !== '成功') return Plugins.message.warn('仅成功测速的节点可以被使用')
if (!api.running) return Plugins.message.error('内核未运行,请先启动内核')
const groupName = row.groupName || form.value.group
if (groupName !== form.value.group) return Plugins.message.error('该结果所属策略组与当前选择的策略组不一致')
const group = (api.proxies || {})[groupName]
const proxy = (api.proxies || {})[row.name]
if (!group || group.type !== 'Selector' || !Array.isArray(group.all) || !group.all.includes(row.name) || !proxy)
return Plugins.message.error('节点可能因订阅更新已不存在,请重新测速')
await Plugins.handleUseProxy(group, proxy)
Plugins.message.success(`已切换到 ${row.name}`)
}
/** 用户确认后删除已保存的历史结果文件,并清空历史展示。 */
const deleteHistory = async () => {
if (!history.value || egressRunning.value) return
const ok = await Plugins.confirm('删除历史记录', '确定删除已保存的测速历史结果吗?删除后不可恢复。').catch(() => false)
if (!ok) return
history.value = null
await historySaveTask.catch(() => {})
try {
await Plugins.RemoveFile(historyFilePath())
} catch {}
}
/** 历史批量出口检测运行中标记;egressStop(模块级)为当前检测流程的停止函数。 */
const egressRunning = ref(false)
/** 对历史中 status='成功' 的节点串行执行:切换节点 → 查询国外出口 → 实时更新历史行;完成后写回历史文件。
* 节点不存在或不在当前策略组时该行标记原因并跳过;运行中可停止;结束后恢复原 Selector 节点。 */
const runEgress = async () => {
if (running.value || egressRunning.value) return
if (!history.value) return
if (!api.running) return Plugins.message.error('内核未运行,请先启动内核')
const groupName = form.value.group
const group = (api.proxies || {})[groupName]
if (!group || group.type !== 'Selector' || !Array.isArray(group.all)) return Plugins.message.error('策略组不存在或不是可用的 Selector')
const endpoint = createProxyUrl(api.getProxyEndpoint())
const rows = history.value.results.filter((r) => r.status === '成功')
if (!rows.length) return Plugins.message.warn('历史中没有已成功测速的节点')
let stopped = false
const cancelIds = new Set()
const isStopped = () => stopped
const stop = () => {
stopped = true
for (const id of cancelIds) {
try {
Plugins.HttpCancel(id)
} catch {}
}
}
egressStop = stop
egressRunning.value = true
statusText.value = '正在检测国外出口…'
const original = group.now
try {
for (let i = 0; i < rows.length; i++) {
if (stopped) break
const row = rows[i]
const proxy = (api.proxies || {})[row.name]
currentNode.value = row.name
statusText.value = `国外出口检测 ${i + 1}/${rows.length}`
if (!proxy || !group.all.includes(row.name)) {
row.overseasIp = ''
row.overseasPlace = ''
row.overseasSource = ''
row.overseasError = '节点不存在或不在当前策略组'
continue
}
await Plugins.handleUseProxy(group, proxy)
try {
const result = await overseasInfo({ proxy: endpoint, cancelIds, isStopped })
row.overseasIp = result.ip
row.overseasPlace = result.place
row.overseasSource = result.source
row.overseasError = ''
} catch (error) {
if (isStopped()) break
row.overseasIp = ''
row.overseasPlace = ''
row.overseasSource = ''
row.overseasError = errorText(error)
}
}
} finally {
egressStop = null
egressRunning.value = false
currentNode.value = ''
statusText.value = stopped ? '出口检测已停止' : '出口检测完成'
try {
const fresh = original && (api.proxies || {})[original]
if (fresh) await Plugins.handleUseProxy(group, fresh)
} catch {}
if (history.value) saveHistory(history.value)
}
}
/** 停止正在进行的批量出口检测。 */
const stopEgress = () => {
egressStop?.()
}
/** 打开面板时异步加载上一次保存的历史结果;失败时仅显示“暂无历史记录”。 */
onMounted(async () => {
history.value = await loadHistory()
})
/** 请求停止当前两阶段测试:取消预检请求或当前下载,之后不再启动新任务。 */
const stop = () => {
runBatchTest.stop?.()
}
/** 清除当前面板中的测速结果。 */
const clear = () => {
if (!running.value) results.value = []
}
/** 按稳定唯一键(优先 groupName+name)将一条结果写入 results:
* 已有该节点的行则原地替换(不新增行、progress 不加一),首次插入才 progress += 1;
* 写入时不做排序,仅在测速完成/停止时统一排序一次(见 start 的 finally),避免大表频繁重排。 */
const upsertResult = (row) => {
const key = resultKeyOf(row)
const list = results.value
const index = list.findIndex((r) => resultKeyOf(r) === key)
if (index === -1) {
results.value = [...list, { ...row, key }]
progress.value += 1
} else results.value = list.map((r, i) => (i === index ? { ...r, ...row, key } : r))
}
/** 校验表单并启动一轮两阶段批量测试。 */
const start = async () => {
if (running.value || egressRunning.value) return
const values = { ...form.value }
const seconds = Number(values.seconds)
const maxDelay = Number(values.maxDelay)
const concurrency = Number(values.concurrency)
const limit = Number(values.limit)
const downloadLimit = Number(values.downloadLimit)
if (!values.group || !/^https?:\/\//i.test(String(values.url).trim()) || !/^https?:\/\//i.test(String(values.pingUrl).trim()))
return Plugins.message.error('请填写有效的策略组和 HTTP/HTTPS 下载/延迟测试 URL')
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 60) return Plugins.message.error('下载时长必须是 1-60 之间的数字(秒)')
if (!Number.isFinite(maxDelay) || maxDelay < 100 || maxDelay > 30000) return Plugins.message.error('最大延迟必须在 100-30000 毫秒之间')
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 30) return Plugins.message.error('预检并发数必须是 1-30 的整数')
if (!Number.isInteger(limit) || limit < 0) return Plugins.message.error('节点数量限制必须是非负整数')
if (!Number.isInteger(downloadLimit) || downloadLimit < 0) return Plugins.message.error('下载数量限制必须是非负整数')
const group = (api.proxies || {})[values.group]
if (!group || group.type !== 'Selector' || !Array.isArray(group.all)) return Plugins.message.error('策略组不存在或不是可用的 Selector')
running.value = true
results.value = []
progress.value = 0
statusText.value = '正在读取 Clash API 配置…'
runBatchTest.active = runBatchTest({
api,
groupName: values.group,
url: String(values.url).trim(),
pingUrl: String(values.pingUrl).trim(),
maxDelay,
seconds,
concurrency,
limit,
downloadLimit,
onTotal: (v) => {
total.value = v
},
onPrecheck: (done, count) => {
statusText.value = `延迟预检 ${done}/${count}`
},
onNode: (v) => {
currentNode.value = v
},
onDownload: (done, target) => {
statusText.value = `下载测速 ${done}/${target}`
},
onResult: (v) => {
upsertResult(v)
}
})
.then((summary) => {
if (summary?.stopped) statusText.value = '已停止'
else if (!summary || summary.qualifiedCount === 0) statusText.value = '预检完成,无合格节点'
else statusText.value = `测试完成:合格 ${summary.qualifiedCount} 个,已下载 ${summary.downloadedCount} 个`
})
.catch((error) => {
Plugins.message.error(errorText(error))
statusText.value = '无法开始测试'
})
.finally(() => {
running.value = false
currentNode.value = ''
const rows = results.value
if (rows.length) {
rows.sort(compareResultRows)
const payload = {
schema: 'batch-download-speed',
version: 2,
savedAt: new Date().toISOString(),
groupName: form.value.group,
testUrl: String(form.value.url).trim(),
results: rows
}
saveHistory(payload)
history.value = payload
}
runBatchTest.active = null
batchRunPromise = null
})
batchRunPromise = runBatchTest.active
await batchRunPromise
}
return {
form,
results,
history,
running,
progress,
total,
currentNode,
statusText,
groupOptions,
presetUrlOptions,
progressPercent,
formatTime,
formatDelay,
overseasText,
start,
stop,
clear,
useResult,
deleteHistory,
egressRunning,
runEgress,
stopEgress
}
}
}
modal.setContent(content)
modal.open()
}
/** 构造当前 GFS 内核代理入站 URL,兼容认证和 IPv6 地址。 */
const createProxyUrl = (endpoint) => {
if (!endpoint || !endpoint.host || !endpoint.port) throw new Error('没有可用的本地代理入站')
const host = String(endpoint.host).includes(':') && !String(endpoint.host).startsWith('[') ? `[${endpoint.host}]` : endpoint.host
const auth = endpoint.username ? `${encodeURIComponent(endpoint.username)}:${encodeURIComponent(endpoint.password || '')}@` : ''
return `${endpoint.schema || endpoint.proxyType || 'http'}://${auth}${host}:${endpoint.port}`
}
/** 将不同类型的异常转换成适合表格显示的中文文本。 */
const errorText = (error) =>
String(error?.message || error || '未知错误')
.replace(/^Error:\s*/, '')
.slice(0, 240)
/** 计算测速结果行的稳定唯一键:优先 groupName+name;无 groupName(单组/历史数据)时退化为 name。
* 两阶段共用同一 key,保证预检行与下载行按同一节点原地替换,不因阶段 id 不同产生重复行。 */
const resultKeyOf = (row) => (row.groupName ? `${row.groupName}::${row.name}` : row.name)
/** 测速结果行的固定排序规则:成功在前按速度降序 → 有效延迟升序 → 名称升序。
* 仅在测速完成或停止后调用一次(start 的 finally),运行期间不排序,避免大表频繁重排。 */
const compareResultRows = (a, b) => {
const aOk = a.status === '成功' ? 1 : 0
const bOk = b.status === '成功' ? 1 : 0
if (aOk !== bOk) return bOk - aOk
const aSpeed = Number.isFinite(Number(a.speed)) ? Number(a.speed) : -1
const bSpeed = Number.isFinite(Number(b.speed)) ? Number(b.speed) : -1
if (aSpeed !== bSpeed) return bSpeed - aSpeed
const aDelay = Number.isFinite(Number(a.delay)) && Number(a.delay) > 0 ? Number(a.delay) : Number.MAX_SAFE_INTEGER
const bDelay = Number.isFinite(Number(b.delay)) && Number(b.delay) > 0 ? Number(b.delay) : Number.MAX_SAFE_INTEGER
if (aDelay !== bDelay) return aDelay - bDelay
return String(a.name).localeCompare(String(b.name))
}
/** 读取当前 profile 的 Clash API 监听地址与 secret,返回可直接请求的 baseUrl。
* controller 为空时默认 127.0.0.1:20123;兼容带 http:// 前缀、普通 host:port 与 IPv6 括号写法。 */
const getClashApiConfig = () => {
const profilesStore = Plugins.useProfilesStore?.()
const appSettingsStore = Plugins.useAppSettingsStore?.()
const profileId = appSettingsStore?.app?.kernel?.profile
const profile = profileId && profilesStore?.getProfileById ? profilesStore.getProfileById(profileId) : null
if (!profile) throw new Error('未找到当前 profile,无法读取 Clash API 配置')
let controller = ''
let secret = ''
if (String(Plugins.APP_TITLE || '').includes('SingBox')) {
controller = profile?.experimental?.clash_api?.external_controller || '127.0.0.1:20123'
secret = profile?.experimental?.clash_api?.secret || ''
} else {
controller = profile?.advancedConfig?.['external-controller'] || '127.0.0.1:20113'
secret = profile?.advancedConfig?.secret || ''
}
const hostPort = String(controller || '127.0.0.1:20123')
.trim()
.replace(/^https?:\/\//i, '')
const bracket = hostPort.match(/^\[([^\]]+)\](?::(\d+))?$/)
let host
let port
if (bracket) {
host = bracket[1]
port = bracket[2] || '20123'
} else {
const sep = hostPort.lastIndexOf(':')
if (sep === -1) {
host = hostPort
port = '20123'
} else {
host = hostPort.slice(0, sep)
port = hostPort.slice(sep + 1)
}
}
if (!host || !/^\d{1,5}$/.test(port)) throw new Error(`Clash API 监听地址无效:${controller}`)
const hostPart = String(host).includes(':') ? `[${host}]` : host
return { baseUrl: `http://${hostPart}:${port}`, secret }
}
/** 从策略组中收集可测速节点:去重、排除 DIRECT/REJECT 与嵌套策略组,limit 作用于过滤后列表。 */
const collectTestNodes = (api, group, limit) => {
const GROUP_TYPES = ['SELECTOR', 'URLTEST', 'FALLBACK', 'LOADBALANCE', 'DIRECT', 'REJECT']
const seen = new Set()
const nodes = []
for (const name of group.all || []) {
if (seen.has(name)) continue
seen.add(name)
const upper = String(name).toUpperCase()
if (upper === 'DIRECT' || upper === 'REJECT') continue
const proxy = (api.proxies || {})[name]
if (!proxy) continue
if (Array.isArray(proxy.all) && proxy.all.length) continue
if (GROUP_TYPES.includes(String(proxy.type || '').toUpperCase())) continue
nodes.push(name)
}
return limit > 0 ? nodes.slice(0, limit) : nodes
}
/** 校验 Clash API 可达性;不可达时抛出明确错误,避免降级为无预检直接下载。 */
const checkClashApi = async ({ baseUrl, secret, cancelIds }) => {
const cancelId = `clash-version-${Plugins.sampleID()}`
cancelIds.add(cancelId)
try {
const response = await Plugins.Requests({
method: 'GET',
url: `${baseUrl}/version`,
autoTransformBody: true,
headers: { Authorization: `Bearer ${secret}` },
options: { Proxy: '', Timeout: 3, CancelId: cancelId }
})
if (response.status < 200 || response.status >= 300) throw new Error(`HTTP ${response.status}`)
} finally {
cancelIds.delete(cancelId)
}
}
/** 通过 Clash API 延迟接口测试单个节点;失败只重试一次,返回延迟或失败原因。
* 每次请求使用唯一 CancelId,停止时由调用方统一 HttpCancel。 */
const testProxyDelay = async ({ baseUrl, secret, node, url, maxDelay, cancelIds, isStopped }) => {
const attempt = async (timeoutMs) => {
const cancelId = `precheck-${Plugins.sampleID()}`
cancelIds.add(cancelId)
try {
const target = `${baseUrl}/proxies/${encodeURIComponent(node)}/delay?url=${encodeURIComponent(url)}&timeout=${maxDelay}`
const response = await Plugins.Requests({
method: 'GET',
url: target,
autoTransformBody: true,
headers: { Authorization: `Bearer ${secret}` },
options: { Proxy: '', Timeout: Math.ceil(timeoutMs / 1000) + 2, CancelId: cancelId }
})
if (response.status < 200 || response.status >= 300) return { delay: 0, error: `HTTP ${response.status}` }
const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body || {}
const delay = Number(body.delay)
return { delay: Number.isFinite(delay) && delay > 0 ? delay : 0, error: Number.isFinite(delay) ? '' : '响应缺少有效 delay 字段' }
} catch (error) {
return { delay: 0, error: errorText(error) }
} finally {
cancelIds.delete(cancelId)
}
}
let result = await attempt(maxDelay)
if (result.delay <= 0 && !isStopped()) result = await attempt(maxDelay)
return result
}
/** 小型并发 worker pool:达到最大并发后不再启动新任务,支持外部停止信号。 */
const runConcurrent = async ({ items, concurrency, isStopped, task }) => {
let index = 0
const workerCount = Math.max(1, Math.min(concurrency, items.length || 1))
const workers = []
for (let w = 0; w < workerCount; w++) {
workers.push(
(async () => {
while (!isStopped()) {
const i = index
index += 1
if (i >= items.length) return
await task(items[i], i)
}
})()
)
}
await Promise.all(workers)
}
/** 通过内核代理入站下载测速文件,到时长即取消;返回实际字节数与纯下载耗时。
* Timeout 单位为秒,故取 seconds + 5;用返回的 elapsed 计算速度,不把节点切换时间算进去。 */
const downloadForDuration = async ({ url, path, proxy, seconds, cancelId }) => {
let bytes = 0
let cancelled = false
const started = Date.now()
const timer = setTimeout(() => {
cancelled = true
try {
Plugins.HttpCancel(cancelId)
} catch {}
}, seconds * 1000)
try {
await Plugins.Download(
url,
path,
{},
(progress) => {
if (Number.isFinite(Number(progress))) bytes = Math.max(bytes, Number(progress))
},
{ Proxy: proxy, CancelId: cancelId, Timeout: seconds + 5 }
)
} catch (error) {
if (!(cancelled && bytes > 0)) throw error
} finally {
clearTimeout(timer)
}
return { bytes, elapsed: Math.max(0.001, (Date.now() - started) / 1000) }
}
/** 内置的下载测速预设地址,可在面板中一键选择。 */
const PRESET_URLS = [
{ label: '🇭🇰 香港', value: 'http://hkg.download.datapacket.com/100mb.bin' },
{ label: '🇸🇬 新加坡', value: 'http://sgp.download.datapacket.com/100mb.bin' },
{ label: '🇯🇵 日本', value: 'http://tyo.download.datapacket.com/100mb.bin' },
{ label: '🇺🇸 美西', value: 'http://lax.download.datapacket.com/100mb.bin' },
{ label: '🇺🇸 美东', value: 'http://ash.download.datapacket.com/100mb.bin' }
]
/** 国外出口检测的 3 个回退接口与解析规则(与网络信息插件一致)。 */
const OVERSEAS_SOURCES = [
[
'cmliussss API',
'https://api.cmliussss.net/api/ipinfo',
(body) => {
if (!body?.ip) throw new Error('返回格式异常')
return { ip: body.ip, place: `${body.country_code || ''} AS${body.asn || ''} ${body.as_name || ''}` }
}
],
[
'ipinfo.io',
'https://ipinfo.io/json',
(body) => {
if (!body?.ip) throw new Error('返回格式异常')
return { ip: body.ip, place: `${body.country || ''} ${body.org || ''}` }
}
],
[
'ipapi.co',
'https://ipapi.co/json/',
(body) => {
if (!body?.ip) throw new Error('返回格式异常')
return { ip: body.ip, place: `${body.country_code || body.country_name || ''} ${body.org || (body.asn ? `AS${body.asn}` : '')}` }
}
]
]
/** 通过本地代理入站查询国外出口 IP 信息;按 3 个接口依次回退,全部失败抛错。
* 每个请求带唯一 CancelId 纳入 cancelIds(停止时统一取消)、超时 5 秒、追加时间戳防缓存。 */
const overseasInfo = async ({ proxy, cancelIds, isStopped }) => {
let last
for (const [source, url, parse] of OVERSEAS_SOURCES) {
if (isStopped()) throw new Error('检测已停止')
const cancelId = `overseas-${Plugins.sampleID()}`
cancelIds.add(cancelId)
try {
const response = await Plugins.Requests({
method: 'GET',
url: `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`,
autoTransformBody: true,
options: { Proxy: proxy, Timeout: 5, CancelId: cancelId }
})
if (response.status < 200 || response.status >= 400) throw new Error(`HTTP ${response.status}`)
const result = parse(response.body)
return { ...result, source }
} catch (error) {
last = error
} finally {
cancelIds.delete(cancelId)
}
}
throw last || new Error('国外出口检测失败')
}
/** 批量测速主流程:阶段一并发延迟预检(不切换节点),阶段二对合格节点按延迟升序串行下载测速。
* 全部节点最终都有一行结果;停止/完成/异常均尽力恢复原 Selector 节点。 */
const runBatchTest = async ({
api,
groupName,
url,
pingUrl,
maxDelay,
seconds,
concurrency,
limit,
downloadLimit,
onTotal,
onPrecheck,
onNode,
onDownload,
onResult
}) => {
if (!api.running) throw new Error('内核未运行,请先启动内核')
const clashApi = getClashApiConfig()
const endpoint = createProxyUrl(api.getProxyEndpoint())
const group = (api.proxies || {})[groupName]
if (!group || group.type !== 'Selector' || !Array.isArray(group.all)) throw new Error('策略组不存在或不是 Selector')
const nodes = collectTestNodes(api, group, limit)
if (!nodes.length) throw new Error('策略组中没有有效可测速节点')
onTotal(nodes.length)
const original = group.now
let stopped = false
let downloaded = 0
const cancelIds = new Set()
let currentCancelId = null
const isStopped = () => stopped
const cancelAll = () => {
for (const id of cancelIds) {
try {
Plugins.HttpCancel(id)
} catch {}
}
cancelIds.clear()
if (currentCancelId) {
try {
Plugins.HttpCancel(currentCancelId)
} catch {}
}
}
runBatchTest.stop = () => {
stopped = true
cancelAll()
}
const qualified = []
try {
/* 阶段一:并发延迟预检,不切换任何节点 */
await checkClashApi({ baseUrl: clashApi.baseUrl, secret: clashApi.secret, cancelIds })
if (stopped) return { stopped, qualifiedCount: 0, downloadedCount: 0 }
let done = 0
await runConcurrent({
items: nodes,
concurrency,
isStopped,
task: async (node) => {
const result = await testProxyDelay({ baseUrl: clashApi.baseUrl, secret: clashApi.secret, node, url: pingUrl, maxDelay, cancelIds, isStopped })
if (stopped) return
done += 1
onPrecheck(done, nodes.length)
const base = {
key: resultKeyOf({ name: node, groupName }),
name: node,
groupName,
testUrl: url,
testedAt: new Date().toISOString(),
mb: '—',
mbps: '—',
bytesText: '—',
time: '—'
}
if (result.delay <= 0) {
onResult({ ...base, status: '延迟失败', delay: null, speed: null, error: result.error || '延迟测试失败' })
return
}
if (result.delay > maxDelay) {
onResult({ ...base, status: '延迟过高', delay: result.delay, speed: null, error: `超过阈值 ${maxDelay}ms` })
return
}
onResult({ ...base, status: '预检通过', delay: result.delay, speed: null, error: '' })
qualified.push({ name: node, delay: result.delay })
}
})
if (stopped) return { stopped, qualifiedCount: qualified.length, downloadedCount: 0 }
/* 阶段二:合格节点按延迟升序,仅下载前 N 个,其余生成“未下载(数量限制)”结果 */
qualified.sort((a, b) => a.delay - b.delay)
const selected = downloadLimit > 0 ? qualified.slice(0, downloadLimit) : qualified
for (const item of qualified.slice(selected.length)) {
onResult({
key: resultKeyOf({ name: item.name, groupName }),
name: item.name,
status: '未下载(数量限制)',
delay: item.delay,
speed: null,
mb: '—',
mbps: '—',
bytesText: '—',
time: '—',
error: '',
groupName,
testUrl: url,
testedAt: new Date().toISOString()
})
}
const downloadTarget = selected.length
let downloadIndex = 0
for (; downloadIndex < downloadTarget; downloadIndex++) {
if (stopped) break
const { name, delay } = selected[downloadIndex]
const rowKey = resultKeyOf({ name, groupName })
onNode(name)
const path = `data/.cache/batch-download-${Plugins.sampleID()}.bin`
const cancelId = `batch-speed-${Plugins.sampleID()}`
currentCancelId = cancelId
let row
try {
const proxy = (api.proxies || {})[name]
if (!proxy) throw new Error('找不到节点代理对象')
await Plugins.handleUseProxy((api.proxies || {})[groupName] || group, proxy)
const data = await downloadForDuration({ url, path, proxy: endpoint, seconds, cancelId })
/* 下载是否成功在下载结束时定格(success),之后停止不再把已完成的行改成“已停止” */
const success = !stopped
let overseas = null
if (success) {
try {
overseas = await overseasInfo({ proxy: endpoint, cancelIds, isStopped })
} catch (error) {
if (!isStopped()) overseas = { error: errorText(error) }
}
}
const mb = data.bytes / 1000000 / data.elapsed
row = success
? {
key: rowKey,
name,
status: '成功',
delay,
speed: mb,
mb: mb.toFixed(2),
mbps: (mb * 8).toFixed(2),
bytesText: `${(data.bytes / 1000000).toFixed(2)} MB`,
time: `${data.elapsed.toFixed(2)} s`,
error: '',
groupName,
testUrl: url,
testedAt: new Date().toISOString(),
overseasIp: overseas?.ip || '',
overseasPlace: overseas?.place || '',
overseasSource: overseas?.source || '',
overseasError: overseas?.error || ''
}
: {
key: rowKey,
name,
status: '已停止',
delay,
speed: null,
mb: '—',
mbps: '—',
bytesText: '—',
time: '—',
error: '',
groupName,
testUrl: url,
testedAt: new Date().toISOString()
}
} catch (error) {
row = {
key: rowKey,
name,
status: stopped ? '已停止' : '下载失败',
delay,
speed: null,
mb: '—',
mbps: '—',
bytesText: '—',
time: '—',
error: stopped ? '' : errorText(error),
groupName,
testUrl: url,
testedAt: new Date().toISOString()
}
} finally {
try {
Plugins.RemoveFile(path)
} catch {}
}
currentCancelId = null
downloaded += 1
onResult(row)
onDownload(downloaded, downloadTarget)
}
/* 停止下载时,剩余尚未下载的合格节点原地更新为“已停止”,避免停留在“预检通过”造成误解 */
if (stopped) {
for (let j = downloadIndex; j < downloadTarget; j++) {
const rest = selected[j]
onResult({
key: resultKeyOf({ name: rest.name, groupName }),
name: rest.name,
status: '已停止',
delay: rest.delay,
speed: null,
mb: '—',
mbps: '—',
bytesText: '—',
time: '—',
error: '',
groupName,
testUrl: url,
testedAt: new Date().toISOString()
})
}
}
return { stopped, qualifiedCount: qualified.length, downloadedCount: downloaded }
} finally {
runBatchTest.stop = null
try {
const freshGroup = (api.proxies || {})[groupName] || group
const fresh = original && (api.proxies || {})[original]
if (fresh) await Plugins.handleUseProxy(freshGroup, fresh)
} catch {}
}
}