码桶
发现社区成员的开源项目
clash-yaml.js16.9 KB
'use strict';
// clash yaml 格式 -> 标准分享链接 转换(流量池自愈生态·批次三)。
//
// 背景:很多topic命中的候选仓库产出的是clash订阅(yaml格式,proxies是一个结构化
// 配置数组),不是标准的vless://这类分享链接。repo-fetch.js抓到内容后如果识别出
// 是clash yaml,就调用这里的 extractLinksFromClashYaml() 转换成标准链接,再交给
// 后面统一的 proxy-parse.js / pool-checker.js 流程处理——这样上层完全不用关心
// "这条节点原本是什么格式抓来的"。
//
// 如实说明这个模块的真实范围和限制:
// 1. 项目一贯极简、零依赖(package.json目前只有express和nodemailer),这里没有
// 引入js-yaml之类的第三方库,而是手写了一个"缩进层级解析器",只覆盖clash
// proxies列表这一种受限场景——顶层"proxies:"数组,每项是"- key: value"加
// 若干缩进更深的"key: value"/嵌套映射(比如ws-opts/grpc-opts/reality-opts)。
// 不是通用YAML实现,遇到锚点引用(&/*)、多行字符串(|、>)、flow style([]/{})
// 这些不常见于clash订阅但YAML规范里存在的写法会解析失败,统一按"这个proxy解析
// 不出来"处理,不会让整个文件崩溃。
// 2. 只覆盖项目需要的6种协议:vless/vmess/ss(shadowsocks)/trojan/hysteria2/tuic。
// clash proxy里的其他类型(如 socks5、http、snell、vmess的部分冷门字段)不转换,
// 计入skipped,不当错误。
// 3. 本模块只做过mock测试(手写的clash yaml样例字符串),没有用真实抓到的clash
// 订阅文件验证过字段命名是否完全对得上各家生成器的习惯(比如"congestion-controller"
// 还是"congestion-control",不同clash-meta分支写法有差异)——如实标注,不假装
// 已经拿真实数据验证过。批次三-第二步会有真机测试的空间,如果真实样本暴露出
// 字段名不匹配的问题,需要下一步来补。
// ---------- 缩进解析器 ----------
function stripComment(line) {
// 简单去掉行内注释(# 开头),但要避免把password/sni里可能带#的值误伤——
// clash配置字段值一般不带#,这里按"最前面不在引号内的#"处理,足够用。
let inQuote = null;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuote) {
if (ch === inQuote) inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '#') {
return line.slice(0, i);
}
}
return line;
}
function unquote(raw) {
if (raw === undefined) return raw;
let v = raw.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
return v;
}
function coerceScalar(raw) {
const v = unquote(raw);
if (v === '') return '';
if (v === 'true') return true;
if (v === 'false') return false;
if (v === '~' || v.toLowerCase() === 'null') return null;
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
return v;
}
// 找到顶层"proxies:"这一行,返回它下面属于这个列表的原始行(indent, text),
// 直到proxies列表真正结束为止。
//
// 修复记录(2026-08-02,真实频道数据核实后确认的真实bug,不是原方案就有的设计):
// 此前这里认定"缩进 <= proxies:本身缩进 = 列表结束",这个假设只对"列表项比
// proxies:缩进更深"这一种YAML写法成立,例如:
// proxies:
// - name: xxx
// 但YAML同样允许(而且不少真实clash订阅生成器就是这么写的,这次核实的clashv8、
// wxdy666两个真实频道都是这种写法)"列表项跟proxies:本身缩进对齐"的写法:
// proxies:
// - name: xxx
// 这种写法下,第一条列表项的缩进本来就等于proxies:的缩进,旧逻辑一读到第一行
// 就误判"已经跳出列表",直接return了空数组——两个真实频道之前"识别成unrecognized、
// 0个候选节点",根源就是这里,不是内容本身有问题。
// 修复方式:不再假定列表项缩进一定比proxies:更深,而是以"实际遇到的第一条列表项
// 的缩进"作为这个列表的基准缩进(listIndent),之后:
// - 缩进比listIndent更深 => 这是当前proxy内部的嵌套字段,继续收进当前proxy
// - 缩进等于listIndent且是"- "开头 => 新的一条列表项(新的proxy),继续收
// - 缩进等于listIndent但不是"- "开头 => 遇到了同级的下一个顶层key(flush-left
// 写法下,proxies列表结束的唯一可靠信号),列表结束
// - 缩进比listIndent更浅 => 无论如何都已经跳出这个列表(缩进写法下,proxies
// 列表结束)
function extractProxiesBlockLines(rawText) {
const allLines = rawText.split(/\r?\n/);
let proxiesIndent = null;
let startIdx = -1;
for (let i = 0; i < allLines.length; i++) {
const line = stripComment(allLines[i]);
if (/^\s*$/.test(line)) continue;
const m = line.match(/^(\s*)proxies:\s*(\[.*\])?\s*$/);
if (m && (m[2] === undefined || m[2] === '')) {
proxiesIndent = m[1].length;
startIdx = i + 1;
break;
}
}
if (startIdx === -1) return [];
const blockLines = [];
let listIndent = null; // 列表项的基准缩进,取实际遇到的第一条列表项为准,不假定比proxiesIndent深
for (let i = startIdx; i < allLines.length; i++) {
const raw = allLines[i];
const line = stripComment(raw);
if (/^\s*$/.test(line)) continue;
const indent = line.match(/^(\s*)/)[1].length;
const trimmed = line.trim();
const isListItem = trimmed.startsWith('- ') || trimmed === '-';
if (listIndent === null) {
// 第一条非空行必须是列表项、且缩进不能比proxies:本身还浅,否则proxies:
// 下面根本不是一个列表(比如空列表或格式异常),按"没有proxies内容"处理
if (!isListItem || indent < proxiesIndent) return [];
listIndent = indent;
}
if (indent < listIndent) break; // 缩进比列表项还浅,proxies列表结束
if (indent === listIndent && !isListItem) break; // 同级但不是新列表项,遇到了下一个顶层key,列表结束
blockLines.push({ indent, text: trimmed });
}
return blockLines;
}
// 把"proxies:"下面的原始行切分成一个个proxy(每个以"- "开头的行为起点),
// 每个proxy自己是一组(indent相对偏移, text)行,交给parseMappingLines解析成对象。
function splitIntoProxyBlocks(blockLines) {
const proxies = [];
let current = null;
let listIndent = null;
for (const { indent, text } of blockLines) {
const isListItem = text.startsWith('- ') || text === '-';
if (isListItem && (listIndent === null || indent === listIndent)) {
listIndent = indent;
if (current) proxies.push(current);
current = [];
const rest = text === '-' ? '' : text.slice(2);
if (rest.trim() !== '') {
// "- key: value" 这一行本身也是一条 key:value,把它当成缩进 = indent+2 的普通行
current.push({ indent: indent + 2, text: rest.trim() });
}
} else if (current) {
current.push({ indent, text });
}
// 如果还没遇到任何list item就出现别的行,忽略(容错,不应该发生)
}
if (current) proxies.push(current);
return proxies;
}
// 把一个proxy内部的(indent, text)行解析成嵌套对象。用缩进栈处理"key:"后面
// 跟着更深缩进的子映射(比如ws-opts:/grpc-opts:/reality-opts:/headers:)这种情况;
// 明确不支持行内list(比如alpn写成 "- h2\n- http/1.1"这种独立成行的),这类值
// 会被当成字符串"null"/跳过,不影响6个协议需要的核心字段。
function parseMappingLines(lines) {
if (!lines.length) return {};
const root = {};
const stack = [{ indent: -1, obj: root }];
for (const { indent, text } of lines) {
if (text.startsWith('- ')) continue; // 行内list项,本模块不需要,跳过不报错
const colonIdx = text.indexOf(':');
if (colonIdx === -1) continue;
const key = unquote(text.slice(0, colonIdx));
const valueRaw = text.slice(colonIdx + 1).trim();
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
const parent = stack[stack.length - 1].obj;
if (valueRaw === '') {
const nested = {};
parent[key] = nested;
stack.push({ indent, obj: nested });
} else {
parent[key] = coerceScalar(valueRaw);
}
}
return root;
}
function extractAllProxyObjects(rawText) {
const blockLines = extractProxiesBlockLines(rawText);
const blocks = splitIntoProxyBlocks(blockLines);
return blocks.map(parseMappingLines);
}
// ---------- 各协议:clash proxy对象 -> 标准分享链接 ----------
// 目标格式跟 proxy-parse.js 的解析逻辑对齐,保证转换出来的链接后面能被正常解析。
function pick(obj, ...keys) {
for (const k of keys) {
if (obj[k] !== undefined && obj[k] !== null && obj[k] !== '') return obj[k];
}
return undefined;
}
function buildQuery(params) {
const parts = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
parts.push(`${k}=${encodeURIComponent(v)}`);
}
return parts.join('&');
}
function convertShadowsocks(p) {
const server = p.server;
const port = p.port;
const method = pick(p, 'cipher', 'method');
const password = p.password;
if (!server || !port || !method || password === undefined) return null;
const userInfo = Buffer.from(`${method}:${password}`, 'utf-8').toString('base64');
const tag = encodeURIComponent(p.name || 'node');
return `ss://${userInfo}@${server}:${port}#${tag}`;
}
function convertVmess(p) {
const server = p.server;
const port = p.port;
const uuid = p.uuid;
if (!server || !port || !uuid) return null;
const network = (pick(p, 'network') || 'tcp').toLowerCase();
const tlsOn = p.tls === true || p.tls === 'true';
const sni = pick(p, 'servername', 'sni') || server;
let wsPath, wsHost;
if (network === 'ws' && p['ws-opts']) {
wsPath = p['ws-opts'].path;
wsHost = p['ws-opts'].headers && p['ws-opts'].headers.Host;
}
let grpcServiceName;
if (network === 'grpc' && p['grpc-opts']) {
grpcServiceName = p['grpc-opts']['grpc-service-name'];
}
const cfg = {
v: '2',
ps: p.name || '',
add: server,
port: String(port),
id: uuid,
aid: String(pick(p, 'alterId', 'alterID', 'alter_id') || 0),
net: network,
tls: tlsOn ? 'tls' : '',
sni: tlsOn ? sni : '',
// 对齐proxy-parse.js里 parseVmess 的实现细节:grpc场景服务名读的是cfg.path
path: network === 'grpc' ? (grpcServiceName || '') : (wsPath || ''),
host: wsHost || ''
};
const b64 = Buffer.from(JSON.stringify(cfg), 'utf-8').toString('base64');
return `vmess://${b64}`;
}
function convertTrojan(p) {
const server = p.server;
const port = p.port;
const password = p.password;
if (!server || !port || password === undefined) return null;
const network = (pick(p, 'network') || 'tcp').toLowerCase();
const sni = pick(p, 'sni', 'servername') || server;
const insecure = p['skip-cert-verify'] === true;
let wsPath, wsHost, grpcServiceName;
if (network === 'ws' && p['ws-opts']) {
wsPath = p['ws-opts'].path;
wsHost = p['ws-opts'].headers && p['ws-opts'].headers.Host;
}
if (network === 'grpc' && p['grpc-opts']) {
grpcServiceName = p['grpc-opts']['grpc-service-name'];
}
const query = buildQuery({
type: network !== 'tcp' ? network : undefined,
sni,
path: wsPath,
host: wsHost,
serviceName: grpcServiceName,
allowInsecure: insecure ? '1' : undefined,
alpn: Array.isArray(p.alpn) ? p.alpn.join(',') : p.alpn
});
const tag = encodeURIComponent(p.name || 'node');
return `trojan://${encodeURIComponent(password)}@${server}:${port}${query ? '?' + query : ''}#${tag}`;
}
function convertVless(p) {
const server = p.server;
const port = p.port;
const uuid = p.uuid;
if (!server || !port || !uuid) return null;
const network = (pick(p, 'network') || 'tcp').toLowerCase();
const tls = pick(p, 'tls'); // clash里可能是 true/false,或者用reality-opts的存在来判断
const realityOpts = p['reality-opts'];
const security = realityOpts ? 'reality' : (tls === true || tls === 'true' ? 'tls' : 'none');
const sni = pick(p, 'servername', 'sni') || server;
let wsPath, wsHost, grpcServiceName;
if (network === 'ws' && p['ws-opts']) {
wsPath = p['ws-opts'].path;
wsHost = p['ws-opts'].headers && p['ws-opts'].headers.Host;
}
if (network === 'grpc' && p['grpc-opts']) {
grpcServiceName = p['grpc-opts']['grpc-service-name'];
}
if (security === 'reality' && (!realityOpts || !realityOpts['public-key'])) return null; // reality缺pbk判定为解析失败,跟proxy-parse.js的口径一致
const query = buildQuery({
security: security !== 'none' ? security : undefined,
type: network !== 'tcp' ? network : undefined,
sni: security !== 'none' ? sni : undefined,
fp: pick(p, 'client-fingerprint'),
pbk: realityOpts ? realityOpts['public-key'] : undefined,
sid: realityOpts ? realityOpts['short-id'] : undefined,
flow: p.flow,
path: wsPath,
host: wsHost,
serviceName: grpcServiceName
});
const tag = encodeURIComponent(p.name || 'node');
return `vless://${uuid}@${server}:${port}${query ? '?' + query : ''}#${tag}`;
}
function convertHysteria2(p) {
const server = p.server;
const port = p.port;
const password = pick(p, 'password', 'auth', 'auth-str');
if (!server || !port || password === undefined) return null;
const sni = pick(p, 'sni', 'servername') || server;
const insecure = p['skip-cert-verify'] === true;
const query = buildQuery({
sni,
insecure: insecure ? '1' : undefined,
obfs: p.obfs,
'obfs-password': pick(p, 'obfs-password')
});
const tag = encodeURIComponent(p.name || 'node');
return `hysteria2://${encodeURIComponent(password)}@${server}:${port}${query ? '?' + query : ''}#${tag}`;
}
function convertTuic(p) {
const server = p.server;
const port = p.port;
const uuid = p.uuid;
const password = pick(p, 'password');
if (!server || !port || !uuid || password === undefined) return null;
const sni = pick(p, 'sni', 'servername') || server;
const congestion = pick(p, 'congestion-controller', 'congestion-control') || 'bbr';
const alpn = Array.isArray(p.alpn) ? p.alpn.join(',') : (p.alpn || 'h3');
const query = buildQuery({ sni, congestion_control: congestion, alpn });
const tag = encodeURIComponent(p.name || 'node');
return `tuic://${uuid}:${encodeURIComponent(password)}@${server}:${port}${query ? '?' + query : ''}#${tag}`;
}
const CONVERTERS = {
ss: convertShadowsocks,
shadowsocks: convertShadowsocks,
vmess: convertVmess,
trojan: convertTrojan,
vless: convertVless,
hysteria2: convertHysteria2,
tuic: convertTuic
// 修复记录(本轮核实时发现的真实问题,不是原方案就有的设计):此前这里把
// clash的"hysteria"(v1协议)也映射到convertHysteria2,注释写"尽量兼容解一次"——
// 但hysteria v1和hysteria2是两个不同协议,字段语义和链接格式都不一样(v1的
// 认证/带宽协商方式跟v2完全不同),硬套converter产出的链接不是"能凑合用",
// 是"必然错误",而且下游proxy-parse.js本身也只认hysteria2://这一种scheme
// (见该文件注释),v1链接送进去也只会在alive层必然失败——等于每次都白白
// 制造一批注定测不过的候选,浪费三层检测的资源。这里改成明确不支持:
// clash类型是"hysteria"(v1)的proxy条目会因为CONVERTERS里查不到对应转换器
// 直接跳过(计入total但不计入converted),不产生错误链接,交给以后如果真的
// 需要支持hysteria v1时再单独写一个转换器,不是现在硬凑。
};
// 主入口:输入原始文本,尝试当clash yaml解析,输出 { links, total, converted }。
// total = 识别到的proxy条目数(不管转不转得出来);converted = 成功转换成分享链接的数量。
// 找不到"proxies:"或者一条都解析不出来,返回 links: []——调用方(repo-fetch.js)
// 会把这种情况当成"这个文件不是clash订阅"处理,不当错误。
function extractLinksFromClashYaml(rawText) {
let proxyObjects = [];
try {
proxyObjects = extractAllProxyObjects(rawText);
} catch (err) {
return { links: [], total: 0, converted: 0 };
}
const links = [];
for (const p of proxyObjects) {
const type = (p.type || '').toLowerCase();
const converter = CONVERTERS[type];
if (!converter) continue;
try {
const link = converter(p);
if (link) links.push(link);
} catch (err) {
// 单条proxy转换失败不影响其他proxy,跳过即可
}
}
return {
links: Array.from(new Set(links)),
total: proxyObjects.length,
converted: links.length
};
}
module.exports = {
extractLinksFromClashYaml,
// 导出内部转换函数,方便下一步针对6协议单独写更细的单元测试
_internal: { convertShadowsocks, convertVmess, convertTrojan, convertVless, convertHysteria2, convertTuic }
};