clash verge:复杂链式代理的实现

wilsons 2026-08-18 15:22 1

需求


大多数人的链式代理可能是这样的


但这有个缺点,入口和出口任意一个挂了,那么你的整个访问就挂掉了。


我想要的是,入口是一个集合,选择最优的路径,出口也是一个集合,一个故障切换另一个。如下:


                  客户端访问


┌────────── 自动选择 ──────────┐
│ │
香港节点 日本节点 ...
│ │
└──────── 自动择优 ────────────┘

【当前最佳入口】

┌──────── 出口自动选择 ────────┐
│ │
代理ip地址1... 代理ip地址2...
│ │
└────────── 故障转移 ──────────┘

【最终出口】

服务器

这样的好处时,无论入口和出口都是多节点集合,防止单点故障引起不稳定。这里推荐入口用自动选择,可以优先选择最佳路径,而出口建议用故障转移,这样防止IP频繁变动。


原理


怎么实现呢?其实很简单,因为clash verge支持给出口节点指定入口节点,因此,只需要给你的节点设置如下配置即可


#proxies:
- type: socks5
name: SOCKS5 x.x.x.x:3000
server: x.x.x.x
port: 3000
username: yourname
password: yourpwd
dialer-proxy: 自动选择

关键就是 “dialer-proxy: 自动选择” 这个,这个自动选择就是你机场的代理分组,自动选择这个一般都有。设置了dialer-proxy后则代表当你访问的出口是SOCKS5 x.x.x.x:3000时,则入口指向自动选择的代理分组。这就解决了入口集合问题。


但,出口集合怎么设置呢?没错就是代理分组。可以新建一个代理分组,节点就是你的出口节点。这样当你在代理中选择了这个分组后,就会根据你设置的要求选择一个节点访问,然后又会触发上一步的dialer-proxy,从而选择最佳入口。这样就完成了从入口到出口的集合化。


出口配置示例如下:


proxies:
- type: socks5
name: SOCKS5 111.111.111.111:1111
server: 111.111.111.111
port: 1111
username: xxxx
password: yyyy
dialer-proxy: 自动选择

- type: socks5
name: SOCKS5 222.222.222.222:2222
server: 222.222.222.222
port: 2222
username: xxxx
password: yyyy
dialer-proxy: 自动选择

proxy-groups:
- name: 出口自动选择
type: fallback
proxies:
- SOCKS5 111.111.111.111:1111
- SOCKS5 222.222.222.222:2222
url: http://www.gstatic.com/generate_204
interval: 300
tolerance: 50

实现


那么怎么和现有的配置融合呢?1是直接改配置文件,但有个确定刷新可能会重新被覆盖;2是通过扩展覆写配置改,但较为麻烦,你有时可能仅需覆盖某个子项,但必须连根节点一起复制再修改,非常不方便。


如果用扩展脚本呢?可以动态加载,但一个个适配也不方便。


于是让AI写了个智能覆盖与合并通用脚本,原理是,相同的叶子节点覆盖,不同则新增,也可以通过MERGE_MODES设置指定路径强制覆盖(无论是叶子或是非叶子),这样,你只需要把你关心的节点重写就好了。


通用脚本如下(需要自己修改配置):


// ============================================================
// ① 你只需要修改这里:直接填写要合并进去的 YAML
// ============================================================
const PATCH_YAML = `
ipv6: false

# 添加你的节点,并通过dialer-proxy指定入口集合
proxies:
- type: socks5
name: SOCKS5 111.111.111.111:1111
server: 111.111.111.111
port: 1111
username: xxxx
password: yyyy
dialer-proxy: 自动选择

- type: socks5
name: SOCKS5 222.222.222.222:2222
server: 222.222.222.222
port: 2222
username: xxxx
password: yyyy
dialer-proxy: 自动选择

# 配置代理分组,type:fallback代表故障转移
proxy-groups:
- name: 出口自动选择
type: fallback
proxies:
- SOCKS5 111.111.111.111:1111
- SOCKS5 222.222.222.222:2222
url: http://www.gstatic.com/generate_204
interval: 300
tolerance: 50

# 把 出口自动选择 代理组加入到主代理组
- name: <这里是你主代理组名,通常是订阅节点名>
proxies:
- 出口自动选择
`;

// ============================================================
// ② 高级设置
// 一般不需要修改
// ============================================================

// 数组中的对象,用哪些字段判断“这是同一个子节点”。
// Clash/Mihomo 最常用的是 name。
const IDENTITY_KEYS = ["name", "id", "tag"];

// 默认合并行为:
// 普通对象 -> 递归合并
// 对象数组 -> 相同 name/id/tag 合并,不同则追加
// 普通数组 -> 去重后追加
// 叶子值 -> PATCH 覆盖原值
//
// 如果某个路径希望完全替换,可在这里指定 "replace"。
// 路径可以是叶子、对象或数组。
// 数组如果想把新增内容插到原数组前面,可使用 "prepend"。
const MERGE_MODES = {
"proxy-groups[name=出口自动选择].proxies": "replace"
};

// ============================================================
// ③ 通用深度合并引擎
// 以下通常不需要修改
// ============================================================
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function clone(value) {
if (Array.isArray(value)) return value.map(clone);
if (isObject(value)) {
const out = {};
Object.keys(value).forEach(key => { out[key] = clone(value[key]); });
return out;
}
return value;
}
function deepEqual(a, b) {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
}
if (isObject(a) && isObject(b)) {
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (let i = 0; i < ka.length; i++) {
const key = ka[i];
if (!hasOwn(b, key) || !deepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
function getMergeMode(path) {
return hasOwn(MERGE_MODES, path) ? MERGE_MODES[path] : "merge";
}
function getIdentity(obj) {
if (!isObject(obj)) return null;
for (let i = 0; i < IDENTITY_KEYS.length; i++) {
const key = IDENTITY_KEYS[i];
if (hasOwn(obj, key)) return { key: key, value: obj[key] };
}
return null;
}
function buildIdentityPath(parentPath, identity) {
return parentPath + "[" + identity.key + "=" + String(identity.value) + "]";
}
function arrayContains(array, value) {
for (let i = 0; i < array.length; i++) if (deepEqual(array[i], value)) return true;
return false;
}
function mergeArray(baseArray, patchArray, path) {
const mode = getMergeMode(path);
if (mode === "replace") return clone(patchArray);

let result = Array.isArray(baseArray) ? clone(baseArray) : [];

for (let p = 0; p < patchArray.length; p++) {
const patchItem = patchArray[p];

if (isObject(patchItem)) {
const identity = getIdentity(patchItem);
if (identity !== null) {
let foundIndex = -1;
for (let i = 0; i < result.length; i++) {
const oldItem = result[i];
if (!isObject(oldItem)) continue;
if (hasOwn(oldItem, identity.key) && oldItem[identity.key] === identity.value) {
foundIndex = i;
break;
}
}
const itemPath = buildIdentityPath(path, identity);
if (foundIndex >= 0) {
result[foundIndex] = deepMerge(result[foundIndex], patchItem, itemPath);
} else {
result.push(clone(patchItem));
}
continue;
}
if (!arrayContains(result, patchItem)) result.push(clone(patchItem));
continue;
}

if (!arrayContains(result, patchItem)) result.push(clone(patchItem));
}

return result;
}
function deepMerge(base, patch, path) {
path = path || "";
const mode = getMergeMode(path);
if (mode === "replace") return clone(patch);

if (Array.isArray(patch)) {
return mergeArray(Array.isArray(base) ? base : [], patch, path);
}

if (isObject(patch)) {
const result = isObject(base) ? clone(base) : {};
const keys = Object.keys(patch);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const childPath = path ? path + "." + key : key;
if (hasOwn(result, key)) result[key] = deepMerge(result[key], patch[key], childPath);
else result[key] = clone(patch[key]);
}
return result;
}

return clone(patch);
}

function stripComment(line) {
let quote = null;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote === '"') {
if (c === "\\") { i++; continue; }
if (c === '"') quote = null;
continue;
}
if (quote === "'") {
if (c === "'" && line[i + 1] === "'") { i++; continue; }
if (c === "'") quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
}
return line;
}
function splitKeyValue(text, lineNo) {
let quote = null, depth = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote === '"') {
if (c === "\\") { i++; continue; }
if (c === '"') quote = null;
continue;
}
if (quote === "'") {
if (c === "'" && text[i + 1] === "'") { i++; continue; }
if (c === "'") quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === "[" || c === "{") depth++;
else if (c === "]" || c === "}") depth--;
else if (c === ":" && depth === 0) return [text.slice(0, i).trim(), text.slice(i + 1).trim()];
}
throw new Error("YAML line " + lineNo + ": expected key: value -> " + text);
}
function hasBlockMappingPair(text) {
let quote = null, depth = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote === '"') {
if (c === "\\") { i++; continue; }
if (c === '"') quote = null;
continue;
}
if (quote === "'") {
if (c === "'" && text[i + 1] === "'") { i++; continue; }
if (c === "'") quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === "[" || c === "{") depth++;
else if (c === "]" || c === "}") depth--;
else if (c === ":" && depth === 0 && (i + 1 === text.length || /\s/.test(text[i + 1]))) return true;
}
return false;
}
function splitInline(text) {
const out = [];
let start = 0, quote = null, depth = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote === '"') {
if (c === "\\") { i++; continue; }
if (c === '"') quote = null;
continue;
}
if (quote === "'") {
if (c === "'" && text[i + 1] === "'") { i++; continue; }
if (c === "'") quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === "[" || c === "{") depth++;
else if (c === "]" || c === "}") depth--;
else if (c === "," && depth === 0) {
out.push(text.slice(start, i).trim());
start = i + 1;
}
}
out.push(text.slice(start).trim());
return out;
}
function parseScalar(text) {
const s = text.trim();
if (s === "") return null;
if (s === "null" || s === "Null" || s === "NULL" || s === "~") return null;
if (/^(true|True|TRUE)$/.test(s)) return true;
if (/^(false|False|FALSE)$/.test(s)) return false;
if (/^[-+]?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(s)) return Number(s);
if (s[0] === '"' && s[s.length - 1] === '"') {
try { return JSON.parse(s); } catch (_) { return s.slice(1, -1); }
}
if (s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1).replace(/''/g, "'");
if (s[0] === "[" && s[s.length - 1] === "]") {
const inner = s.slice(1, -1).trim();
return inner ? splitInline(inner).map(parseScalar) : [];
}
if (s[0] === "{" && s[s.length - 1] === "}") {
const inner = s.slice(1, -1).trim();
const obj = {};
if (!inner) return obj;
const parts = splitInline(inner);
for (let i = 0; i < parts.length; i++) {
const kv = splitKeyValue(parts[i], "inline");
obj[parseKey(kv[0])] = parseScalar(kv[1]);
}
return obj;
}
return s;
}
function parseKey(s) {
s = s.trim();
if ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'")) {
return String(parseScalar(s));
}
return s;
}
function parseYamlSubset(yamlText) {
const raw = yamlText.replace(/\r\n?/g, "\n").split("\n");
const lines = [];
for (let i = 0; i < raw.length; i++) {
let line = raw[i];
if (/\t/.test(line.match(/^\s*/)[0])) throw new Error("YAML line " + (i + 1) + ": tabs are not supported");
line = stripComment(line).replace(/\s+$/, "");
if (!line.trim() || line.trim() === "---" || line.trim() === "...") continue;
const indent = line.match(/^ */)[0].length;
lines.push({ indent: indent, text: line.slice(indent), lineNo: i + 1 });
}
if (!lines.length) return {};

function parseBlock(index, indent) {
if (lines[index].indent !== indent) throw new Error("YAML line " + lines[index].lineNo + ": unexpected indentation");
return /^-($|\s)/.test(lines[index].text) ? parseSeq(index, indent) : parseMap(index, indent);
}

function parseMap(index, indent) {
const obj = {};
while (index < lines.length && lines[index].indent === indent && !/^-($|\s)/.test(lines[index].text)) {
const line = lines[index];
const kv = splitKeyValue(line.text, line.lineNo);
const key = parseKey(kv[0]);
const valText = kv[1];
index++;
if (valText !== "") {
obj[key] = parseScalar(valText);
} else if (index < lines.length && lines[index].indent > indent) {
const child = parseBlock(index, lines[index].indent);
obj[key] = child[0];
index = child[1];
} else {
obj[key] = {};
}
}
return [obj, index];
}

function parseSeq(index, indent) {
const arr = [];
while (index < lines.length && lines[index].indent === indent && /^-($|\s)/.test(lines[index].text)) {
const line = lines[index];
const content = line.text.replace(/^-\s?/, "");
index++;

if (content === "") {
if (index < lines.length && lines[index].indent > indent) {
const child = parseBlock(index, lines[index].indent);
arr.push(child[0]);
index = child[1];
} else {
arr.push(null);
}
continue;
}

// "- key: value" 形式的对象数组成员
let mappingPair = null;
if (hasBlockMappingPair(content)) {
mappingPair = splitKeyValue(content, line.lineNo);
}

if (mappingPair !== null) {
const obj = {};
const key = parseKey(mappingPair[0]);
const valText = mappingPair[1];

if (valText !== "") {
obj[key] = parseScalar(valText);
} else if (index < lines.length && lines[index].indent > indent) {
const child = parseBlock(index, lines[index].indent);
obj[key] = child[0];
index = child[1];
} else {
obj[key] = {};
}

// 同一个 sequence item 后续的映射字段
if (index < lines.length && lines[index].indent > indent) {
const continuationIndent = lines[index].indent;
if (!/^-($|\s)/.test(lines[index].text)) {
const tail = parseMap(index, continuationIndent);
const tailObj = tail[0];
Object.keys(tailObj).forEach(k => { obj[k] = tailObj[k]; });
index = tail[1];
}
}

arr.push(obj);
} else {
arr.push(parseScalar(content));
}
}
return [arr, index];
}

const parsed = parseBlock(0, lines[0].indent);
if (parsed[1] !== lines.length) {
throw new Error("YAML line " + lines[parsed[1]].lineNo + ": could not parse remaining content");
}
return parsed[0];
}

function main(config, profileName) {
const patch = parseYamlSubset(PATCH_YAML);
return deepMerge(config, patch, "");
}

把你待修改的配置放到PATCH_YAML变量中即可。


使用


然后在订阅的代理列表中选中“出口自动选择”即可。完美!


最新回复 (3)
  • liuqiang 08-18 15:31
    1

    我找GPT写了一份跟你这个类似的,入口机场自动选择,出口也可以根据自定义选择组。。

  • hardy 08-18 15:42
    2

    怎么觉得有点像快连?这不就是节点池吗?自动轮询 坏了自动换 这样就永远不会挂了

  • yangningbo 08-21 12:26
    3

    感谢L友的分享,学习到了,这就回去尝试一下

* 帖子来源Linux.do
返回