From 05e0355e142457a7700f6a1142102053f3d2169c Mon Sep 17 00:00:00 2001 From: riglen Date: Tue, 21 Apr 2026 15:47:09 +0800 Subject: [PATCH] step1 --- app/conf_models.py | 69 +++ app/config.py | 1 + app/services/conf_loader.py | 222 ++++++++++ config/conf/base.conf | 29 ++ docs/ini改造方案.md | 809 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1130 insertions(+) create mode 100644 app/conf_models.py create mode 100644 app/services/conf_loader.py create mode 100644 config/conf/base.conf create mode 100644 docs/ini改造方案.md diff --git a/app/conf_models.py b/app/conf_models.py new file mode 100644 index 0000000..df6e0d4 --- /dev/null +++ b/app/conf_models.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + + +class ConfSource(BaseModel): + key: str + source_type: Literal["url", "file", "inline", "base64"] = "url" + value: str + enabled: bool = True + cache_ttl: int | None = None + options: dict[str, str] = Field(default_factory=dict) + line_no: int + + +class ConfSelector(BaseModel): + key: str + regex: str + line_no: int + + +class ConfGroup(BaseModel): + name: str + group_type: str + tokens: list[str] = Field(default_factory=list) + raw: str + line_no: int + + +class ConfModule(BaseModel): + key: str + path: str + policy: str + order: int + enabled: bool = True + line_no: int + + def resolved_path(self, config_dir: Path) -> Path: + return (config_dir / self.path).resolve() + + +class ConfBuiltin(BaseModel): + key: str + builtin_type: Literal["GEOIP", "FINAL"] + value: str = "" + policy: str + order: int + enabled: bool = True + line_no: int + + +class ConfBaseConfig(BaseModel): + listen: str | None = None + output_dir: str | None = None + cache_dir: str | None = None + mode: str | None = None + allow_lan: bool | None = None + log_level: str | None = None + ipv6: bool | None = None + append_userinfo_header: bool | None = None + userinfo_source_policy: str | None = None + sources: list[ConfSource] = Field(default_factory=list) + selectors: list[ConfSelector] = Field(default_factory=list) + groups: list[ConfGroup] = Field(default_factory=list) + modules: list[ConfModule] = Field(default_factory=list) + builtins: list[ConfBuiltin] = Field(default_factory=list) diff --git a/app/config.py b/app/config.py index 5ef33b6..2266b92 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,7 @@ class Settings(BaseSettings): default_user_agent: str = "sub-provider/0.2" sources_file: Path = CONFIG_DIR / "sources.yaml" + conf_base_file: Path = CONFIG_DIR / "conf" / "base.conf" rules_dir: Path = CONFIG_DIR / "rules" bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache" fetch_cache_dir: Path = DATA_DIR / "fetch-cache" diff --git a/app/services/conf_loader.py b/app/services/conf_loader.py new file mode 100644 index 0000000..56f91fc --- /dev/null +++ b/app/services/conf_loader.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import csv +import os +import re +from pathlib import Path +from typing import Any + +from app.conf_models import ConfBaseConfig, ConfBuiltin, ConfGroup, ConfModule, ConfSelector, ConfSource + +_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}") +_SCALAR_KEYS = { + "listen", + "output_dir", + "cache_dir", + "mode", + "allow_lan", + "log_level", + "ipv6", + "append_userinfo_header", + "userinfo_source_policy", +} +_BOOL_KEYS = {"allow_lan", "ipv6", "append_userinfo_header"} +_MULTI_KEYS = {"source", "selector", "group", "module", "builtin"} +_SOURCE_TYPES = {"url", "file", "inline", "base64"} + + +class ConfConfigError(ValueError): + def __init__(self, path: Path, line_no: int, message: str) -> None: + self.path = path + self.line_no = line_no + self.message = message + super().__init__(f"{path}:{line_no}: {message}") + + +def _expand_env(value: str) -> str: + return _ENV_PATTERN.sub(lambda match: os.getenv(match.group(1), ""), value) + + +def _parse_bool(value: str, *, path: Path, line_no: int, key: str) -> bool: + normalized = value.strip().lower() + mapping = { + "true": True, + "yes": True, + "1": True, + "false": False, + "no": False, + "0": False, + } + if normalized not in mapping: + raise ConfConfigError(path, line_no, f"invalid boolean for {key}: {value}") + return mapping[normalized] + + +def _parse_csv_fields(value: str, *, path: Path, line_no: int) -> list[str]: + try: + row = next(csv.reader([value], skipinitialspace=True)) + except Exception as exc: # noqa: BLE001 + raise ConfConfigError(path, line_no, f"invalid csv payload: {exc}") from exc + return [field.strip() for field in row] + + +def _ensure_named_records(records: list[Any], *, key: str, path: Path) -> None: + seen: dict[str, int] = {} + for record in records: + name = getattr(record, key) + line_no = getattr(record, "line_no") + previous_line = seen.get(name) + if previous_line is not None: + raise ConfConfigError(path, line_no, f"duplicate {record.__class__.__name__} key '{name}', first defined at line {previous_line}") + seen[name] = line_no + + +def _parse_source(value: str, *, path: Path, line_no: int) -> ConfSource: + fields = _parse_csv_fields(_expand_env(value), path=path, line_no=line_no) + if len(fields) < 3: + raise ConfConfigError(path, line_no, "source requires at least 3 fields: key,type,value") + key, source_type, source_value, *extras = fields + if not key: + raise ConfConfigError(path, line_no, "source key cannot be empty") + if source_type not in _SOURCE_TYPES: + raise ConfConfigError(path, line_no, f"unsupported source type: {source_type}") + options: dict[str, str] = {} + enabled = True + cache_ttl: int | None = None + for item in extras: + if "=" not in item: + raise ConfConfigError(path, line_no, f"source option must be k=v: {item}") + option_key, option_value = [part.strip() for part in item.split("=", 1)] + if not option_key: + raise ConfConfigError(path, line_no, "source option key cannot be empty") + option_value = _expand_env(option_value) + if option_key == "enabled": + enabled = _parse_bool(option_value, path=path, line_no=line_no, key="source.enabled") + elif option_key == "cache_ttl": + try: + cache_ttl = int(option_value) + except ValueError as exc: + raise ConfConfigError(path, line_no, f"invalid integer for source.cache_ttl: {option_value}") from exc + else: + options[option_key] = option_value + return ConfSource( + key=key, + source_type=source_type, + value=source_value, + enabled=enabled, + cache_ttl=cache_ttl, + options=options, + line_no=line_no, + ) + + +def _parse_selector(value: str, *, path: Path, line_no: int) -> ConfSelector: + fields = _parse_csv_fields(value, path=path, line_no=line_no) + if len(fields) != 2: + raise ConfConfigError(path, line_no, "selector requires exactly 2 fields: key,regex") + key, regex = fields + if not key or not regex: + raise ConfConfigError(path, line_no, "selector key and regex cannot be empty") + return ConfSelector(key=key, regex=regex, line_no=line_no) + + +def _parse_group(value: str, *, path: Path, line_no: int) -> ConfGroup: + segments = [segment.strip() for segment in value.split("`")] + if len(segments) < 2: + raise ConfConfigError(path, line_no, "group requires at least name`type") + name, group_type, *tokens = segments + if not name or not group_type: + raise ConfConfigError(path, line_no, "group name and type cannot be empty") + return ConfGroup(name=name, group_type=group_type, tokens=tokens, raw=value.strip(), line_no=line_no) + + +def _parse_module(value: str, *, path: Path, line_no: int, config_dir: Path) -> ConfModule: + fields = _parse_csv_fields(value, path=path, line_no=line_no) + if len(fields) != 5: + raise ConfConfigError(path, line_no, "module requires exactly 5 fields: key,path,policy,order,enabled") + key, module_path, policy, order_text, enabled_text = fields + if not key or not module_path or not policy: + raise ConfConfigError(path, line_no, "module key, path, and policy cannot be empty") + try: + order = int(order_text) + except ValueError as exc: + raise ConfConfigError(path, line_no, f"invalid integer for module.order: {order_text}") from exc + enabled = _parse_bool(enabled_text, path=path, line_no=line_no, key="module.enabled") + resolved_path = (config_dir / module_path).resolve() + if not resolved_path.is_file(): + raise ConfConfigError(path, line_no, f"module path does not exist: {module_path}") + return ConfModule(key=key, path=module_path, policy=policy, order=order, enabled=enabled, line_no=line_no) + + +def _parse_builtin(value: str, *, path: Path, line_no: int) -> ConfBuiltin: + fields = _parse_csv_fields(value, path=path, line_no=line_no) + if len(fields) != 6: + raise ConfConfigError(path, line_no, "builtin requires exactly 6 fields: key,type,value,policy,order,enabled") + key, builtin_type, builtin_value, policy, order_text, enabled_text = fields + if builtin_type not in {"GEOIP", "FINAL"}: + raise ConfConfigError(path, line_no, f"unsupported builtin type: {builtin_type}") + if not key or not policy: + raise ConfConfigError(path, line_no, "builtin key and policy cannot be empty") + if builtin_type == "GEOIP" and not builtin_value: + raise ConfConfigError(path, line_no, "builtin GEOIP requires a value") + try: + order = int(order_text) + except ValueError as exc: + raise ConfConfigError(path, line_no, f"invalid integer for builtin.order: {order_text}") from exc + enabled = _parse_bool(enabled_text, path=path, line_no=line_no, key="builtin.enabled") + return ConfBuiltin( + key=key, + builtin_type=builtin_type, + value=builtin_value, + policy=policy, + order=order, + enabled=enabled, + line_no=line_no, + ) + + +def load_conf_base(path: Path) -> ConfBaseConfig: + config = ConfBaseConfig() + scalar_values: dict[str, Any] = {} + config_dir = path.resolve().parent + + for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if "=" not in raw_line: + raise ConfConfigError(path, line_no, "expected key = value") + key, value = [part.strip() for part in raw_line.split("=", 1)] + if not key: + raise ConfConfigError(path, line_no, "key cannot be empty") + if key in _SCALAR_KEYS: + if key in scalar_values: + raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}") + expanded_value = _expand_env(value) + if key in _BOOL_KEYS: + scalar_values[key] = _parse_bool(expanded_value, path=path, line_no=line_no, key=key) + else: + scalar_values[key] = expanded_value + continue + if key not in _MULTI_KEYS: + raise ConfConfigError(path, line_no, f"unknown directive: {key}") + if key == "source": + config.sources.append(_parse_source(value, path=path, line_no=line_no)) + elif key == "selector": + config.selectors.append(_parse_selector(value, path=path, line_no=line_no)) + elif key == "group": + config.groups.append(_parse_group(value, path=path, line_no=line_no)) + elif key == "module": + config.modules.append(_parse_module(value, path=path, line_no=line_no, config_dir=config_dir)) + elif key == "builtin": + config.builtins.append(_parse_builtin(value, path=path, line_no=line_no)) + + for key, parsed_value in scalar_values.items(): + setattr(config, key, parsed_value) + + _ensure_named_records(config.sources, key="key", path=path) + _ensure_named_records(config.selectors, key="key", path=path) + _ensure_named_records(config.groups, key="name", path=path) + _ensure_named_records(config.modules, key="key", path=path) + _ensure_named_records(config.builtins, key="key", path=path) + return config diff --git a/config/conf/base.conf b/config/conf/base.conf new file mode 100644 index 0000000..60bfd45 --- /dev/null +++ b/config/conf/base.conf @@ -0,0 +1,29 @@ +listen = 0.0.0.0:3000 +output_dir = ./output +cache_dir = ./data/cache +mode = rule +allow_lan = true +log_level = info +ipv6 = true +append_userinfo_header = true +userinfo_source_policy = first_enabled_source + +source = airport-a,url,${AIRPORT_A_URL},enabled=true,cache_ttl=1800 +source = airport-b,url,${AIRPORT_B_URL},enabled=true,cache_ttl=1800 + +selector = all,.* +selector = hk,(?i)(港|hk|hong kong|hongkong) +selector = us,(?i)(美|us|united states) + +group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇺🇲 美国节点`[]DIRECT +group = ♻️ 自动选择`url-test`@all`https://www.gstatic.com/generate_204`300,,50 +group = 🇭🇰 香港节点`url-test`@hk`https://www.gstatic.com/generate_204`300,,50 +group = 🇺🇲 美国节点`url-test`@us`https://www.gstatic.com/generate_204`300,,150 +group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择 + +module = local-network,../rules/acl4ssr/LocalAreaNetwork.yaml,🎯 全球直连,10,true +module = apple,../rules/acl4ssr/Apple.yaml,🍎 苹果服务,80,true +module = openai,../rules/acl4ssr/OpenAi.yaml,💬 Ai平台,100,true + +builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true +builtin = final,FINAL,,🐟 漏网之鱼,9999,true diff --git a/docs/ini改造方案.md b/docs/ini改造方案.md new file mode 100644 index 0000000..acd336a --- /dev/null +++ b/docs/ini改造方案.md @@ -0,0 +1,809 @@ +可以。下面这份就是一版 **“类 ACL4SSR 风格 INI/CONF 配置整体说明”**,你可以直接丢给 Codex。 +它刻意 **不引入 YAML**,只保留高密度、行导向、声明式的写法。你现在贴的配置本身已经在用 `[custom]`、`ruleset=`、`custom_proxy_group=`、`enable_rule_generator=true`、`overwrite_original_rules=true` 这套思路;subconverter 现有 ACL4SSR 相关配置和外部配置 gist 里也能看到同类结构,甚至有把公共规则和公共分组拆出去再导入的写法。 ([GitHub][1]) + +--- + +# sub-provider 类 ACL4SSR 风格 INI 配置说明(草案) + +## 1. 目标 + +将 sub-provider 的配置设计为一种 **ACL4SSR-like 的高密度 INI/CONF DSL**,用于: + +* 管理订阅源 +* 定义节点选择器 +* 定义代理组 +* 注册规则模块 +* 选择输出 profile +* 生成完整 Clash/Mihomo YAML + +这里的重点不是兼容标准 INI 解析器,而是: + +> **保留 INI 的可读感 + ACL4SSR 的高密度声明风格 + 方便 Python 自定义解析** + +--- + +## 2. 设计原则 + +### 2.1 保留 ACL4SSR 的使用习惯 + +沿用这些核心心智模型: + +* `ruleset = 规则模块 -> 目标策略组` +* `group = 策略组定义` +* `selector = 节点筛选器` +* `source = 订阅源` +* `profile = 输出方案` + +### 2.2 高密度、行导向 + +每一条配置尽量一行表达一个对象,方便: + +* 快速编辑 +* 批量复制 +* diff +* 注释开关 +* 手工维护 + +### 2.3 规则正文与配置分离 + +* `*.list` 文件只保存规则主体 +* 目标策略组由配置指定 +* 生成器负责最终拼接 + +### 2.4 不追求严格标准 INI + +原因: + +* 标准 INI 不擅长重复 key +* 不擅长表达列表对象 +* 不擅长表达高密度分组语法 + +因此这里定义的是: + +> **INI-like / CONF-like 自定义配置格式** + +--- + +## 3. 文件组织 + +推荐结构: + +```text +config/ + base.conf + profiles/ + default.conf + lite.conf + media.conf + +rules/ + modules/ + Riglen.list + LocalAreaNetwork.list + UnBan.list + BanAD.list + BanProgramAD.list + Google.list + GoogleCN.list + SteamCN.list + Bing.list + OneDrive.list + Microsoft.list + Apple.list + Telegram.list + AI.list + OpenAi.list + YouTube.list + Netflix.list + ProxyMedia.list + ProxyGFWlist.list + Pt.list + ChinaDomain.list + ChinaCompanyIp.list + Download.list +``` + +### 说明 + +* `base.conf`:全局配置、源、选择器、组定义、模块注册、内置规则 +* `profiles/*.conf`:不同输出方案 +* `rules/modules/*.list`:规则正文 + +--- + +## 4. 基础语法 + +## 4.1 注释 + +支持两种注释: + +```ini +; 这是注释 +# 这也是注释 +``` + +## 4.2 空行 + +空行忽略。 + +## 4.3 键值形式 + +基本格式: + +```ini +key = value +``` + +左右空格允许存在,解析时应 trim。 + +## 4.4 重复 key + +允许重复 key。 +例如: + +```ini +source = ... +source = ... +selector = ... +selector = ... +group = ... +group = ... +module = ... +module = ... +``` + +解析器应将这类 key 视为“多条记录”,而不是覆盖。 + +## 4.5 大小写 + +建议: + +* 指令名小写 +* 值区分大小写 +* 策略组名、规则内容、正则表达式保持原样 + +--- + +## 5. 支持的指令 + +本方案定义以下核心指令: + +* `listen` +* `output_dir` +* `cache_dir` +* `mode` +* `allow_lan` +* `log_level` +* `ipv6` +* `append_userinfo_header` +* `userinfo_source_policy` + +以及对象型指令: + +* `source =` +* `selector =` +* `group =` +* `module =` +* `builtin =` + +profile 内支持: + +* `name =` +* `enabled =` +* `sources =` +* `include_modules =` +* `exclude_modules =` +* `include_builtins =` +* `override_policy =` +* `prepend_rule =` +* `append_rule =` + +--- + +## 6. base.conf 说明 + +## 6.1 全局标量配置 + +### 示例 + +```ini +listen = 0.0.0.0:3000 +output_dir = ./output +cache_dir = ./data/cache +mode = rule +allow_lan = true +log_level = info +ipv6 = false +append_userinfo_header = true +userinfo_source_policy = first_enabled_source +``` + +### 含义 + +* `listen`:监听地址 +* `output_dir`:生成结果输出目录 +* `cache_dir`:缓存目录 +* `mode`:Clash 模式,通常为 `rule` +* `allow_lan`:是否允许局域网访问 +* `log_level`:日志级别 +* `ipv6`:是否开启 IPv6 +* `append_userinfo_header`:是否透传订阅流量头 +* `userinfo_source_policy`:多源时如何选取订阅信息头 + +--- + +## 6.2 source 指令 + +### 目标 + +定义订阅输入源。 + +### 格式 + +```ini +source = key,type,value,enabled=true,cache_ttl=1800 +``` + +### 示例 + +```ini +source = airport_main,url,https://example.com/sub?token=xxxx,enabled=true,cache_ttl=1800 +source = local_links,file,./data/local_links.txt,enabled=false +``` + +### 字段说明 + +* 第 1 列:`key` +* 第 2 列:`type` +* 第 3 列:`value` +* 后续:可选命名参数 + +### 支持类型 + +* `url`:远程订阅链接 +* `file`:本地文件 +* `inline`:内联文本 +* `base64`:base64 内容 + +### 建议解析规则 + +前三列固定,后续按 `k=v` 解析。 + +--- + +## 6.3 selector 指令 + +### 目标 + +定义节点筛选器,供代理组复用。 + +### 格式 + +```ini +selector = key,regex +``` + +### 示例 + +```ini +selector = all,.* +selector = hk,(港|HK|hk|Hong Kong|HongKong|hongkong) +selector = jp,(日本|东京|大阪|JP|Japan) +selector = us,(美|洛杉矶|西雅图|US|United States) +selector = netflix,(NF|奈飞|解锁|Netflix|NETFLIX|Media) +``` + +### 含义 + +* `key`:选择器名 +* `regex`:匹配节点名的正则表达式 + +### 约定 + +后续在 `group` 指令中通过 `@selector_key` 引用。 + +--- + +## 6.4 group 指令 + +这是整个 DSL 的核心。 +它保留 ACL4SSR 风格的高密度分组写法。你的示例里已经大量使用 `custom_proxy_group=` 这种反引号分隔格式。 现有 subconverter 相关 ACL4SSR 配置里也是同类写法。([GitHub][1]) + +### 格式 + +```ini +group = 组名`类型`参数1`参数2`参数3... +``` + +### 成员约定 + +* `[]组名`:引用已有组 +* `[]DIRECT`:内置 DIRECT +* `[]REJECT`:内置 REJECT +* `@selector_key`:引用 selector 动态筛节点 +* `.*`:兼容保留写法,可映射为全部节点 + +### 示例 + +```ini +group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇯🇵 日本节点`[]🚀 手动切换`[]DIRECT +group = 🚀 手动切换`select`@all +group = ♻️ 自动选择`url-test`@all`http://www.gstatic.com/generate_204`300,,50 +group = 🇭🇰 香港节点`url-test`@hk`http://www.gstatic.com/generate_204`300,,50 +group = 🇯🇵 日本节点`url-test`@jp`http://www.gstatic.com/generate_204`300,,50 +group = 🎥 奈飞节点`select`@netflix +group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择 +``` + +### 解析规则建议 + +#### `select` + +```ini +group = 组名`select`成员1`成员2`成员3... +``` + +#### `url-test` + +```ini +group = 组名`url-test`@selector`测试URL`间隔,,容差 +``` + +#### `fallback` + +```ini +group = 组名`fallback`@selector`测试URL`间隔,,容差 +``` + +#### `load-balance` + +```ini +group = 组名`load-balance`@selector`测试URL`间隔,,容差 +``` + +### 设计建议 + +不要再继续沿用 `custom_proxy_group=` 这个旧名字。 +内部 DSL 里直接统一为: + +```ini +group = ... +``` + +这样更短,也更像你自己的项目语法。 + +--- + +## 6.5 module 指令 + +### 目标 + +注册规则模块。 + +### 格式 + +```ini +module = key,path,policy,order,enabled +``` + +### 示例 + +```ini +module = riglen,./rules/modules/Riglen.list,🚀 节点选择,10,true +module = lan,./rules/modules/LocalAreaNetwork.list,🎯 全球直连,20,true +module = banad,./rules/modules/BanAD.list,🛑 广告拦截,40,true +module = apple,./rules/modules/Apple.list,🍎 苹果服务,80,true +module = ai,./rules/modules/AI.list,💬 Ai平台,100,true +module = openai,./rules/modules/OpenAi.list,💬 Ai平台,101,true +``` + +### 字段说明 + +* `key`:模块唯一标识 +* `path`:规则文件路径 +* `policy`:默认策略组 +* `order`:排序 +* `enabled`:默认是否启用 + +### 规则文件约定 + +规则文件只保存规则主体,不含目标策略组。 + +例如 `OpenAi.list`: + +```ini +DOMAIN-SUFFIX,openai.com +DOMAIN-SUFFIX,chatgpt.com +DOMAIN-SUFFIX,oaistatic.com +``` + +生成时补成: + +```ini +DOMAIN-SUFFIX,openai.com,💬 Ai平台 +``` + +--- + +## 6.6 builtin 指令 + +### 目标 + +定义不来自 `.list` 文件的内置规则。 + +### 格式 + +```ini +builtin = key,type,value,policy,order,enabled +``` + +### 示例 + +```ini +builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true +builtin = final,FINAL,,🐟 漏网之鱼,9999,true +``` + +### 映射规则 + +* `GEOIP + CN + 🎯 全球直连` + -> `GEOIP,CN,🎯 全球直连` +* `FINAL + 🐟 漏网之鱼` + -> `MATCH,🐟 漏网之鱼` + +### 说明 + +这里保留了 ACL4SSR 配置里 `[]GEOIP,CN`、`[]FINAL` 这种内置规则思想,只是换成更适合你项目的数据化声明。你的现有配置里已经有这两类用法。 ([GitHub][1]) + +--- + +## 7. profile 配置说明 + +每个 profile 一个文件,放在 `config/profiles/` 下。 + +--- + +## 7.1 基本字段 + +### 示例 + +```ini +name = 默认完整配置 +enabled = true + +sources = airport_main +include_modules = riglen,lan,unban,banad,apple,telegram,ai,openai +exclude_modules = +include_builtins = geoip_cn,final +``` + +### 含义 + +* `name`:profile 名称 +* `enabled`:是否启用 +* `sources`:本 profile 使用哪些订阅源 +* `include_modules`:启用哪些模块 +* `exclude_modules`:从启用列表中排除哪些模块 +* `include_builtins`:启用哪些内置规则 + +--- + +## 7.2 override_policy 指令 + +### 目标 + +覆盖模块默认策略组。 + +### 格式 + +```ini +override_policy = module_key,new_policy +``` + +### 示例 + +```ini +override_policy = apple,🚀 节点选择 +override_policy = ai,💬 Ai平台 +``` + +### 含义 + +例如某模块默认是 `🍎 苹果服务`,某 profile 想改成 `🚀 节点选择`,就在 profile 里覆盖。 + +--- + +## 7.3 prepend_rule / append_rule 指令 + +### 目标 + +给特定 profile 注入自定义规则。 + +### 示例 + +```ini +prepend_rule = DOMAIN-SUFFIX,internal.example.com,DIRECT +append_rule = DOMAIN-SUFFIX,test.example.com,🚀 节点选择 +``` + +### 含义 + +* `prepend_rule`:插在 rules 最前 +* `append_rule`:插在 builtin 之前或之后,由实现约定 + +### 建议 + +`MATCH` / `FINAL` 仍应最后输出。 + +--- + +## 8. 解析器行为规范 + +## 8.1 base.conf + +解析器应支持: + +* 普通标量键值 +* 多条 `source` +* 多条 `selector` +* 多条 `group` +* 多条 `module` +* 多条 `builtin` + +## 8.2 profile.conf + +解析器应支持: + +* 普通标量键值 +* 多条 `override_policy` +* 多条 `prepend_rule` +* 多条 `append_rule` + +## 8.3 字段 trim + +建议默认: + +* 去掉左右空白 +* 不改动组名内部空格 +* 不改动正则原文 + +## 8.4 布尔值 + +支持: + +* `true/false` +* `yes/no` +* `1/0` + +内部统一成布尔值。 + +## 8.5 列表字段 + +例如: + +```ini +sources = airport_main,local_links +include_modules = apple,ai,openai +``` + +按逗号切分,trim 后转数组。 + +## 8.6 module/source 的 CSV 解析 + +建议用 Python `csv` 模块解析逗号分隔,而不是简单 `split(",")`,避免后续扩展时出问题。 + +## 8.7 group 的反引号解析 + +建议: + +1. 去掉前缀 `group =` +2. 按反引号 `` ` `` 分段 +3. 第一段为组名 +4. 第二段为类型 +5. 后续段按组类型解释 + +--- + +## 9. 生成流程规范 + +生成器处理顺序建议如下: + +### 第 1 步:加载 base.conf + +构建: + +* 源表 +* selector 表 +* group 定义表 +* module 注册表 +* builtin 注册表 + +### 第 2 步:加载指定 profile.conf + +得到: + +* 使用哪些源 +* 启用哪些模块 +* 启用哪些 builtin +* policy override +* prepend/append 规则 + +### 第 3 步:拉取并解析节点 + +* 拉 URL 订阅 +* 读本地文件 +* 统一解析为节点对象 +* 去重 / 重命名 / 过滤 + +### 第 4 步:按 selector 构建节点集合 + +例如: + +* `hk` -> 匹配香港节点 +* `us` -> 匹配美国节点 +* `all` -> 全部节点 + +### 第 5 步:构建 proxy-groups + +解析 `group = ...` 指令,生成最终组定义。 + +### 第 6 步:构建 rules + +1. 按模块顺序加载 `.list` +2. 对每行规则补上目标 policy +3. 应用 `override_policy` +4. 加入 `prepend_rule` +5. 加入 builtin +6. 加入 `append_rule` +7. 确保 `FINAL/MATCH` 最后 + +### 第 7 步:渲染完整 YAML + +这里是生成器内部输出,不需要在配置层暴露 YAML 结构给用户。 + +--- + +## 10. 错误处理建议 + +以下情况应报错并指出行号: + +* 未知指令 +* `group` 语法不合法 +* `module` 路径不存在 +* profile 引用了不存在的 `source` +* profile 引用了不存在的 `module` +* `override_policy` 指向未知模块 +* `@selector_key` 未定义 +* builtin 类型不支持 + +以下情况可仅 warning: + +* selector 匹配不到节点 +* group 最终成员为空 +* 模块文件为空 +* 规则重复 + +--- + +## 11. 兼容性策略建议 + +## 11.1 兼容旧 ACL4SSR 心智模型 + +虽然这是你自己的 DSL,但建议: + +* 注释继续支持 `;` +* 保留 `ruleset -> policy` 的思想 +* 保留高密度 `group` 行语法 +* 保留 `GEOIP` / `FINAL` 的 builtin 概念 + +## 11.2 不必兼容旧关键字原样 + +内部不建议继续沿用: + +* `ruleset=` +* `custom_proxy_group=` + +建议统一改成更短的: + +* `module =` +* `group =` + +这样语义更清晰: + +* `module` 是规则模块注册 +* `group` 是代理组定义 + +--- + +## 12. 最小示例 + +## 12.1 base.conf + +```ini +listen = 0.0.0.0:3000 +mode = rule +allow_lan = true +log_level = info + +source = airport_main,url,https://example.com/sub?token=xxxx,enabled=true,cache_ttl=1800 + +selector = all,.* +selector = hk,(港|HK|hk|Hong Kong|HongKong|hongkong) +selector = us,(美|US|United States) + +group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇺🇲 美国节点`[]DIRECT +group = ♻️ 自动选择`url-test`@all`http://www.gstatic.com/generate_204`300,,50 +group = 🇭🇰 香港节点`url-test`@hk`http://www.gstatic.com/generate_204`300,,50 +group = 🇺🇲 美国节点`url-test`@us`http://www.gstatic.com/generate_204`300,,150 +group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择 + +module = apple,./rules/modules/Apple.list,🍎 苹果服务,80,true +module = openai,./rules/modules/OpenAi.list,💬 Ai平台,100,true + +builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true +builtin = final,FINAL,,🐟 漏网之鱼,9999,true +``` + +## 12.2 profiles/default.conf + +```ini +name = 默认完整配置 +enabled = true + +sources = airport_main +include_modules = apple,openai +exclude_modules = +include_builtins = geoip_cn,final + +override_policy = apple,🚀 节点选择 +prepend_rule = DOMAIN-SUFFIX,internal.example.com,DIRECT +``` + +--- + +## 13. 给 Codex 的实现要求 + +你可以直接把下面这段交给 Codex: + +```md +请为 sub-provider 设计并实现一套类 ACL4SSR 风格的 INI/CONF 配置系统,不要引入 YAML 配置层。 + +目标: +- 使用高密度、行导向、声明式配置 +- 保留 ACL4SSR 的使用习惯,但不要求 100% 兼容其原始关键字 +- 规则正文继续使用 rules/modules/*.list +- 配置文件负责声明: + - source + - selector + - group + - module + - builtin + - profile + +要求: +1. base.conf 负责全局配置、source、selector、group、module、builtin。 +2. profiles/*.conf 负责具体输出 profile。 +3. group 使用反引号分隔的 ACL4SSR-like 语法。 +4. module 指向规则文件路径和默认策略组。 +5. builtin 用于表达 GEOIP、FINAL 这类内置规则。 +6. 生成器读取这些 conf 文件后,生成完整 Clash/Mihomo YAML。 +7. 不要接数据库,不要做面板。 +8. 解析器要能给出清晰的行号错误提示。 +9. profile 支持 override_policy、prepend_rule、append_rule。 +10. 先实现最小可用版本,再逐步扩展。 +``` + +--- + +## 14. 最终结论 + +你现在最适合的路线,就是: + +> **规则正文继续放 `.list`** +> **配置层改成类 ACL4SSR 风格的 `.conf`** +> **Python 写一个轻量 parser + generator** +> **最终输出完整 YAML** + +这条路最顺,也最符合你现在的使用习惯。 + +[1]: https://github.com/tindy2013/subconverter/blob/master/base/config/ACL4SSR_NoMicrosoft.ini "subconverter/base/config/ACL4SSR_NoMicrosoft.ini at master · tindy2013/subconverter · GitHub"