Files
mihomo-patchbay/shared/filter-groups.ts
T
im0o cb8effe05b 把入站固定到某条订阅线路
订阅线路不在内核的全局名字表里,入站的 proxy 指不到它(决策记录 0005)。做法是自动建
一个只装它的策略组再指过去,用户看到的只是「这个口固定走这条线」,组是实现细节:
不出现在策略组列表里,列表和详情里都显示成线路本身的名字,没人再引用时自动删掉。

筛选条件必须做对两件事,都写了测试:转义正则元字符(线路名形如 [vmess]DMIT.LAX.Pro
里的方括号和点不转义会匹配错),以及前后加锚点(内核是部分匹配,不加锚点时「香港01」
会把「香港011」也捞进来)。线路名含反引号时表达不了——那是内核切分多个筛选条件的
分隔符——这种情况明说做不到而不是生成一个会静默匹配错的组。

实测:生成的配置 mihomo -t 通过;起一个临时内核问 /proxies,这个组解析出来的成员
恰好只有那一条线路,且确实进了名字表所以入站指得到。
2026-08-10 19:33:44 +08:00

119 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把一路入站固定到某一条订阅线路。
*
* 内核不允许直接这么写:入站的 `proxy:` 只认全局名字表里的名字,而订阅线路不在表上
* (见决策记录 0005)。绕法是给那条线路单独建一个只装它的策略组,入站指向这个组。
*
* 这些组是 Patchbay 自己造的,用户不该看到也不该管。名字带前缀是唯一可靠的标记——
* 注释靠不住,被碰到的段落重写时会丢。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
/** 自动生成的筛选组的名字前缀。改它会让已有配置里的组失去标记,别改。 */
export const GENERATED_PREFIX = 'patchbay:'
export function isGeneratedGroup(name: string): boolean {
return name.startsWith(GENERATED_PREFIX)
}
/** 固定到某条线路时,生成的组叫什么。名字里带上线路名,翻文件时一眼看得懂。 */
export function generatedGroupName(line: string): string {
return `${GENERATED_PREFIX}${line}`
}
/** 这个组固定的是哪条线路。不是生成的组返回 null。 */
export function pinnedLineOf(groupName: string): string | null {
return isGeneratedGroup(groupName) ? groupName.slice(GENERATED_PREFIX.length) : null
}
/**
* 出站在界面上怎么念。
*
* 生成的组显示成它固定的那条线路——用户从没建过这个组,让他在界面上读到
* `patchbay:[vless]Gomami` 只会莫名其妙。
*/
export function displayOutboundName(name: string): string {
return pinnedLineOf(name) ?? name
}
/**
* 把线路名转成只匹配它自己的筛选条件。
*
* 两件事必须做对:
* 转义正则元字符——线路名形如 `[vmess]DMIT.LAX.Pro.MTYQ`,`[`、`]`、`.` 不转义会匹配错
* 前后加锚点——内核的筛选是部分匹配,不加锚点时 `香港01` 会把 `香港011` 也匹配进来
*
* 反引号在内核里是多个筛选条件的分隔符(adapter/outboundgroup/groupbase.go:72-76),
* 线路名里含它就没法用一条正则表达,这种情况返回 null,调用方据此告诉用户做不到。
*/
export function lineFilter(line: string): string | null {
if (line.includes('`')) return null
const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return `^${escaped}$`
}
/**
* 造一个只装指定线路的策略组。
*
* 用 `select` 而不是 `url-test`:组里只有一条线路,没什么可测的,而 `select` 的语义
* 就是「用这一个」。
*/
export function makeFilterGroup(line: string, provider: string): ConfigObject | null {
const filter = lineFilter(line)
if (filter === null) return null
return {
name: generatedGroupName(line),
type: 'select',
use: [provider],
filter,
}
}
/** 配置里所有指向某个名字的位置。用来判断一个生成的组还有没有人在用。 */
function referencesTo(data: ConfigObject, name: string): number {
let count = 0
for (const entry of Array.isArray(data.listeners) ? data.listeners : []) {
if (typeof entry === 'object' && entry !== null) {
if ((entry as Record<string, unknown>).proxy === name) count += 1
}
}
for (const group of Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []) {
if (typeof group !== 'object' || group === null) continue
const members = (group as Record<string, unknown>).proxies
if (Array.isArray(members) && members.includes(name)) count += 1
}
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
// 规则的目标是逗号分隔的最后一段之前那个位置,形如 MATCH,目标 或 DOMAIN,x,目标
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
}
return count
}
/**
* 删掉没人再引用的生成组。
*
* 每次改完配置都跑一遍。不清理的话,改几次出站文件里就攒下一堆没用的组,而用户
* 并不知道它们是干嘛的——他从来没主动建过它们。
*
* 只动带前缀的组,用户自己建的一律不碰,哪怕它确实没人引用。
*/
export function pruneGeneratedGroups(data: ConfigObject): ConfigObject {
const groups = Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []
const kept = groups.filter((group) => {
if (typeof group !== 'object' || group === null) return true
const name = (group as Record<string, unknown>).name
if (typeof name !== 'string' || !isGeneratedGroup(name)) return true
return referencesTo(data, name) > 0
})
if (kept.length === groups.length) return data
const next = { ...data }
if (kept.length === 0) delete next['proxy-groups']
else next['proxy-groups'] = kept
return next
}