合并规则页(task/rules-page)

# Conflicts:
#	web/api.ts
This commit is contained in:
2026-08-11 20:08:44 +08:00
7 changed files with 2397 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
import { describe, expect, it } from 'bun:test'
import {
buildRule,
findRuleIssues,
firstMatchIndex,
inferRuleFormat,
insertBeforeMatch,
isStructuredEditable,
missingLanRules,
moveRule,
parseIniRulesets,
parseRule,
planIniImport,
ruleProviderPath,
ruleSetReferences,
setRuleProviders,
setRules,
} from './rules.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
describe('规则字符串的拆分与拼装', () => {
it('常见的三段规则', () => {
expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({
type: 'DOMAIN-SUFFIX',
payload: 'youtube.com',
target: 'PROXY',
extras: [],
})
})
it('MATCH 没有载荷', () => {
expect(parseRule('MATCH,PROXY')).toEqual({
type: 'MATCH',
payload: null,
target: 'PROXY',
extras: [],
})
})
it('选项尾部原样保留', () => {
const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve')
expect(parsed.extras).toEqual(['no-resolve'])
// 改目标不能丢掉选项
expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve')
})
it('拆开再拼回原样', () => {
for (const rule of [
'DOMAIN-SUFFIX,youtube.com,PROXY',
'MATCH,PROXY',
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
'RULE-SET,ads,REJECT',
'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY',
]) {
expect(buildRule(parseRule(rule))).toBe(rule)
}
})
it('逻辑规则的载荷里有逗号也不被拆开', () => {
const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')
expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))')
expect(parsed.target).toBe('PROXY')
})
it('结构化编辑只给常见形状,逻辑规则和子规则按原文编辑', () => {
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
expect(isStructuredEditable('孤零零一段')).toBe(false)
})
})
describe('规则表的顺序', () => {
const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT']
it('第一条 MATCH 之后的规则不可达', () => {
expect(firstMatchIndex(rules)).toBe(1)
expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1)
})
it('新规则插在第一条 MATCH 之前', () => {
expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([
'DOMAIN-SUFFIX,a.com,DIRECT',
'GEOIP,CN,DIRECT',
'MATCH,PROXY',
'DOMAIN-SUFFIX,b.com,REJECT',
])
})
it('没有 MATCH 时加在末尾', () => {
expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([
'GEOIP,CN,DIRECT',
'MATCH,PROXY',
])
})
it('上移下移只交换相邻两条,其余不动', () => {
expect(moveRule(rules, 0, 1)).toEqual([
'MATCH,PROXY',
'DOMAIN-SUFFIX,a.com,DIRECT',
'DOMAIN-SUFFIX,b.com,REJECT',
])
expect(moveRule(rules, 0, -1)).toBe(rules)
expect(moveRule(rules, 2, 1)).toBe(rules)
})
})
describe('断引用', () => {
it('目标出站不存在和规则组不存在都标出来', () => {
const rules = [
'DOMAIN-SUFFIX,a.com,NOPE',
'RULE-SET,ads,REJECT',
'RULE-SET,gone,DIRECT',
'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1',
'MATCH,PROXY',
]
const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads']))
expect(issues).toEqual([
{ index: 0, kind: 'target', name: 'NOPE' },
{ index: 2, kind: 'rule-set', name: 'gone' },
])
})
})
describe('内网直连预设', () => {
it('空表全缺', () => {
expect(missingLanRules([])).toHaveLength(8)
})
it('已管住的网段不重复加,哪怕写法不完全一样', () => {
const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY']
const missing = missingLanRules(existing)
expect(missing).toHaveLength(7)
expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false)
})
})
describe('删除规则组前找引用', () => {
it('列出 rules 里引用它的规则下标', () => {
const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY']
expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2])
expect(ruleSetReferences(rules, 'other')).toEqual([])
})
})
describe('配置形状', () => {
it('rules 空了删掉整个键', () => {
const data: ConfigObject = { rules: ['MATCH,PROXY'] }
setRules(data, ['A,B,C'])
expect(data.rules).toEqual(['A,B,C'])
setRules(data, [])
expect('rules' in data).toBe(false)
})
it('rule-providers 空了删掉整个键', () => {
const data: ConfigObject = {}
setRuleProviders(data, { ads: { type: 'http' } })
expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads'])
setRuleProviders(data, {})
expect('rule-providers' in data).toBe(false)
})
})
describe('ini 的 ruleset= 行解析', () => {
const ini = `
[custom]
; 注释行不算
;ruleset=🎯 全球直连,[]GEOIP,LAN
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
ruleset=🍎 苹果服务,Apple.list
ruleset=🎯 全球直连,[]GEOIP,CN
ruleset=🐟 漏网之鱼,[]FINAL
custom_proxy_group=🚀 节点选择\`select\`[]AUTO
`
it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => {
expect(parseIniRulesets(ini)).toEqual([
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' },
{ target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' },
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' },
{ target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' },
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN' },
{ target: '🐟 漏网之鱼', kind: 'final', source: '' },
])
})
it('custom_proxy_group 和残缺行不产生条目', () => {
expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([])
})
})
describe('ini 导入计划', () => {
const entries = parseIniRulesets(`
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
ruleset=🎯 全球直连,[]GEOIP,CN
ruleset=🐟 漏网之鱼,[]FINAL
`)
it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => {
const plan = planIniImport(
entries,
{ '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' },
new Set(),
)
expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN'])
expect(plan.providers.LocalAreaNetwork).toEqual({
type: 'http',
behavior: 'classical',
url: 'https://example.com/LocalAreaNetwork.list',
path: './providers/rules/LocalAreaNetwork.txt',
format: 'text',
interval: 86400,
})
expect(plan.rules).toEqual([
'RULE-SET,LocalAreaNetwork,DIRECT',
'RULE-SET,GoogleCN,DIRECT',
'GEOIP,CN,DIRECT',
])
expect(plan.finals).toEqual(['MATCH,PROXY'])
})
it('同一个来源出现多次只声明一个规则组', () => {
const plan = planIniImport(
[
{ target: '甲', kind: 'url', source: 'https://example.com/a.list' },
{ target: '乙', kind: 'url', source: 'https://example.com/a.list' },
],
{ 甲: 'DIRECT', 乙: 'REJECT' },
new Set(),
)
expect(Object.keys(plan.providers)).toEqual(['a'])
expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT'])
})
it('和已有规则组撞名时加序号', () => {
const plan = planIniImport(
[{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }],
{ 甲: 'REJECT' },
new Set(['ads']),
)
expect(Object.keys(plan.providers)).toEqual(['ads-2'])
expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT'])
})
it('本地路径生成 file 类型,path 就是来源本身', () => {
const plan = planIniImport(
[{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }],
{ 甲: 'REJECT' },
new Set(),
)
expect(plan.providers['ads']).toEqual({
type: 'file',
behavior: 'classical',
path: 'D:\\lists\\ads.list',
format: 'text',
})
})
it('没映射目标组的行跳过', () => {
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
expect(plan.finals).toEqual([])
expect(plan.rules).toHaveLength(3)
})
})
describe('清单格式与缓存路径', () => {
it('格式按扩展名推断,拿不准回 yaml', () => {
expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text')
expect(inferRuleFormat('https://example.com/a.txt')).toBe('text')
expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs')
expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml')
expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml')
})
it('缓存路径的扩展名跟着格式走', () => {
expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt')
expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs')
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
})
})
+340
View File
@@ -0,0 +1,340 @@
/**
* 分流规则与规则组(rule-providers)的纯逻辑。
*
* 拆分规则字符串只为显示和编辑常见字段,不为每种规则类型建模——内核认识的类型
* 比界面需要的多得多,拆不明白的(逻辑规则、子规则)由界面按原文整行编辑。
* 拆分用 vendor 的 splitRuleFields:它认得引号和嵌套括号,`AND`/`OR` 这类载荷里
* 的逗号不会被当成字段分隔。
*/
import { splitRuleFields } from '../vendor/config-editor/index.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
/** 一条分流规则拆开的样子。extras 是目标之后的选项段(如 no-resolve),原样保留。 */
export interface ParsedRule {
type: string
/** MATCH 没有载荷,为 null */
payload: string | null
/** 目标出站。拆不出来(字段不够)时为 null */
target: string | null
extras: string[]
}
export function parseRule(rule: string): ParsedRule {
const fields = splitRuleFields(rule)
const type = fields[0] ?? ''
if (type.toUpperCase() === 'MATCH') {
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
}
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
}
/** 拼回一行规则。空字段不写,所以 MATCH 不会多出一个空载荷段。 */
export function buildRule(parsed: ParsedRule): string {
return [parsed.type, parsed.payload, parsed.target, ...parsed.extras]
.filter((part): part is string => part !== null && part !== '')
.join(',')
}
/**
* 界面能按「类型,载荷,目标」结构化编辑的规则。
*
* 逻辑规则和子规则的结构不是这个形状,让它们进表单只会把原文拆坏,按整行编辑。
*/
const RAW_ONLY_TYPES = new Set(['AND', 'OR', 'NOT', 'SUB-RULE'])
export function isStructuredEditable(rule: string): boolean {
const parsed = parseRule(rule)
if (RAW_ONLY_TYPES.has(parsed.type.toUpperCase())) return false
return parsed.target !== null
}
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
export function firstMatchIndex(rules: string[]): number {
return rules.findIndex((rule) => parseRule(rule).type.toUpperCase() === 'MATCH')
}
/** 把新规则插到第一条 MATCH 之前;没有 MATCH 就加在末尾。 */
export function insertBeforeMatch(rules: string[], additions: string[]): string[] {
const at = firstMatchIndex(rules)
const index = at < 0 ? rules.length : at
return [...rules.slice(0, index), ...additions, ...rules.slice(index)]
}
/** 上移或下移一条规则。越界时原样返回。 */
export function moveRule(rules: string[], index: number, delta: -1 | 1): string[] {
const to = index + delta
if (index < 0 || index >= rules.length || to < 0 || to >= rules.length) return rules
const next = [...rules]
next[index] = rules[to]!
next[to] = rules[index]!
return next
}
/** 一条规则的断引用:目标出站不存在,或者 RULE-SET 引用的规则组不存在。 */
export interface RuleIssue {
index: number
kind: 'target' | 'rule-set'
name: string
}
/**
* 找出引用了不存在的出站或规则组的规则。
*
* 内核自带的校验命令不检查这类引用,只在有流量经过时才报错,所以保存前的自检
* (vendor 的 validateDocument)和界面上的标注都要自己查。SUB-RULE 的目标段是
* 子规则集合名而不是出站,跳过——那是路由页的段落,这里不认。
*/
export function findRuleIssues(
rules: string[],
outboundNames: ReadonlySet<string>,
providerNames: ReadonlySet<string>,
): RuleIssue[] {
const issues: RuleIssue[] = []
rules.forEach((rule, index) => {
const parsed = parseRule(rule)
const type = parsed.type.toUpperCase()
if (type === 'RULE-SET' && parsed.payload !== null && !providerNames.has(parsed.payload)) {
issues.push({ index, kind: 'rule-set', name: parsed.payload })
}
if (type !== 'SUB-RULE' && parsed.target !== null && !outboundNames.has(parsed.target)) {
issues.push({ index, kind: 'target', name: parsed.target })
}
})
return issues
}
/**
* 「内网直连」预设:回环、私网和链路本地地址一律 DIRECT。
*
* no-resolve 是这类规则的固定尾巴:它们按目标 IP 匹配,让内核先做一次 DNS 解析
* 才能匹配是白忙一场,还会拖慢纯 IP 的连接。
*/
export const LAN_DIRECT_RULES = [
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve',
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
'IP-CIDR,169.254.0.0/16,DIRECT,no-resolve',
'IP-CIDR6,::1/128,DIRECT,no-resolve',
'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
'IP-CIDR6,fe80::/10,DIRECT,no-resolve',
] as const
/**
* 预设里还没出现在规则表里的那几条。
*
* 按「类型,载荷」判重而不是整行比对:用户手写的 `IP-CIDR,192.168.0.0/16,DIRECT`
* 没有 no-resolve 尾巴,语义上已经管住了这个网段,再塞一条带尾巴的是重复。
*/
export function missingLanRules(rules: string[]): string[] {
const covered = new Set(
rules.map((rule) => {
const parsed = parseRule(rule)
return `${parsed.type.toUpperCase()},${parsed.payload ?? ''}`
}),
)
return LAN_DIRECT_RULES.filter((preset) => {
const parsed = parseRule(preset)
return !covered.has(`${parsed.type},${parsed.payload ?? ''}`)
})
}
/** rules: 里引用了这个规则组的规则下标。删除规则组前拿它拦人。 */
export function ruleSetReferences(rules: string[], providerName: string): number[] {
const result: number[] = []
rules.forEach((rule, index) => {
const parsed = parseRule(rule)
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
result.push(index)
}
})
return result
}
/** 配置里的 rule-providers 段。不是映射就当没有。 */
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
const value = data['rule-providers']
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
return value as Record<string, ConfigObject>
}
/** 直接改传入的对象——调用方先 clone。providers 空了就把整个键去掉,不留空映射。 */
export function setRuleProviders(data: ConfigObject, providers: Record<string, ConfigObject>): void {
if (Object.keys(providers).length === 0) delete data['rule-providers']
else data['rule-providers'] = providers
}
/** 配置里的分流规则表。不是数组就当没有;非字符串的条目自检会报,这里不呈现。 */
export function rulesOf(data: ConfigObject): string[] {
if (!Array.isArray(data.rules)) return []
return data.rules.filter((rule): rule is string => typeof rule === 'string')
}
/** 同上。规则表空了就删掉 rules 键,不留空数组。 */
export function setRules(data: ConfigObject, rules: string[]): void {
if (rules.length === 0) delete data.rules
else data.rules = rules
}
/** 给文件名和规则组名去重用。 */
function safeName(text: string): string {
return text.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '')
}
/** FNV-1a。只用来给名字去重,不是安全用途。 */
function shortHash(text: string): string {
let h = 0x811c9dc5
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i)
h = Math.imul(h, 0x01000193) >>> 0
}
return h.toString(16).padStart(8, '0')
}
/**
* 一个 http 规则组的缓存文件落在哪。
*
* 不写 path 时内核按 url 哈希自己生成一个(rules/provider/parse.go:51),那个路径
* 这里算不出来,所以补一个确定的。扩展名跟着格式走,翻目录时看得出里面是什么。
*/
export function ruleProviderPath(name: string, format: string): string {
const ext = format === 'mrs' ? 'mrs' : format === 'text' ? 'txt' : 'yaml'
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
}
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
if (clean.endsWith('.mrs')) return 'mrs'
if (clean.endsWith('.list') || clean.endsWith('.txt')) return 'text'
return 'yaml'
}
/** ini 里一行 ruleset= 解析出来的东西。 */
export interface IniRuleset {
/** ini 里的目标组名(「🎯 全球直连」这类),导入时由用户映射到实际出站 */
target: string
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
source: string
}
/**
* 解析 subconverter ini 里的 ruleset= 行。
*
* 以 ; 和 # 开头的是 ini 的注释,跳过。custom_proxy_group= 是 subconverter 自己的
* 建组指令,和本项目的专属组体系冲突,不在解析范围内。
* 来源以 [] 开头的是内联规则而不是清单:[]GEOIP,CN 这类直接生成规则,不建规则组;
* []FINAL(以及 []MATCH)是兜底规则。
*/
export function parseIniRulesets(text: string): IniRuleset[] {
const result: IniRuleset[] = []
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
const match = /^ruleset\s*=\s*(.+)$/i.exec(trimmed)
if (!match) continue
const body = match[1]!
const comma = body.indexOf(',')
if (comma < 0) continue
const target = body.slice(0, comma).trim()
const source = body.slice(comma + 1).trim()
if (target === '' || source === '') continue
if (source.startsWith('[]')) {
const inline = source.slice(2).trim()
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
if (geo) {
result.push({
target,
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
source: geo[2]!.trim(),
})
} else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') {
result.push({ target, kind: 'final', source: '' })
}
// 其它 [] 写法([]REJECT 这类)认不出来,跳过比生成一条错规则好
continue
}
result.push({ target, kind: /^https?:\/\//i.test(source) ? 'url' : 'file', source })
}
return result
}
/** ini 导入的产物:一批规则组声明,加一批新规则。 */
export interface IniImportPlan {
providers: Record<string, ConfigObject>
/** 普通规则,插在第一条 MATCH 之前 */
rules: string[]
/** ini 里 []FINAL 生成的兜底规则,加在整张表末尾 */
finals: string[]
}
/**
* 把 ini 的 ruleset 行变成规则组声明和规则。
*
* 每个来源生成一个规则组,名字取来源的文件名(去扩展名),和已有的、以及本次
* 已生成的撞名时加序号。ACL4SSR 的清单是 classical 行为的 text 格式,行为按
* classical 声明,格式按扩展名推断。
* 目标组名由 mapping 逐个映射到实际出站,没映射的行跳过。
*/
export function planIniImport(
entries: IniRuleset[],
mapping: Readonly<Record<string, string>>,
existingProviders: ReadonlySet<string>,
): IniImportPlan {
const providers: Record<string, ConfigObject> = {}
const rules: string[] = []
const finals: string[] = []
const taken = new Set(existingProviders)
/** 同一行 ruleset 重复出现时共用一个名字,不重复声明。 */
const nameBySource = new Map<string, string>()
const nameFor = (source: string): string => {
const known = nameBySource.get(source)
if (known) return known
const base = (source.split(/[?#]/)[0] ?? '').split(/[\\/]/).pop() ?? ''
const stem = safeName(base.replace(/\.(list|txt|yaml|yml|mrs)$/i, '')) || 'rules'
let name = stem
for (let i = 2; taken.has(name); i += 1) name = `${stem}-${String(i)}`
taken.add(name)
nameBySource.set(source, name)
return name
}
for (const entry of entries) {
const target = mapping[entry.target]
if (!target) continue
if (entry.kind === 'geoip') {
rules.push(`GEOIP,${entry.source},${target}`)
continue
}
if (entry.kind === 'geosite') {
rules.push(`GEOSITE,${entry.source},${target}`)
continue
}
if (entry.kind === 'final') {
finals.push(`MATCH,${target}`)
continue
}
const format = inferRuleFormat(entry.source)
const name = nameFor(`${entry.kind}:${entry.source}`)
if (!providers[name]) {
providers[name] =
entry.kind === 'url'
? {
type: 'http',
behavior: 'classical',
url: entry.source,
path: ruleProviderPath(name, format),
format,
interval: 86400,
}
: { type: 'file', behavior: 'classical', path: entry.source, format }
}
rules.push(`RULE-SET,${name},${target}`)
}
return { providers, rules, finals }
}