From 09a9faa1bec000e35be087d12f4d167511924e19 Mon Sep 17 00:00:00 2001 From: riglen Date: Tue, 31 Mar 2026 16:39:23 +0800 Subject: [PATCH] opt --- app/main.py | 2 + app/models.py | 17 +- app/services/profiles.py | 180 ++++++++- app/services/rules.py | 41 +- config/rules/direct.yaml | 6 +- config/sources.yaml | 413 +++++++++++++++++++- sub-provider-project-context.md | 652 ++++++++++++++++++++++++++++++++ 7 files changed, 1274 insertions(+), 37 deletions(-) create mode 100644 sub-provider-project-context.md diff --git a/app/main.py b/app/main.py index 6294df8..005e075 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,8 @@ def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]: def _rule_path(rule: RuleConfig): + if not rule.file: + raise HTTPException(status_code=404, detail="rule file not available") path = (settings.rules_dir / rule.file).resolve() if not path.is_file() or settings.rules_dir.resolve() not in path.parents: raise HTTPException(status_code=404, detail="rule file missing") diff --git a/app/models.py b/app/models.py index 30dc0ce..774f446 100644 --- a/app/models.py +++ b/app/models.py @@ -19,12 +19,13 @@ class SourceConfig(BaseModel): class RuleConfig(BaseModel): - file: str + file: str | None = None behavior: Literal["domain", "ipcidr", "classical"] = "domain" format: Literal["yaml", "text", "mrs"] = "yaml" interval: int = 86400 policy: str no_resolve: bool = False + payload: list[str] = Field(default_factory=list) class RegionConfig(BaseModel): @@ -33,13 +34,23 @@ class RegionConfig(BaseModel): tolerance: int = 50 +class ProxyGroupConfig(BaseModel): + name: str + type: Literal["select", "url-test"] = "select" + proxies: list[str] = Field(default_factory=list) + filter: str | None = None + tolerance: int | None = None + url: HttpUrl | None = None + interval: int | None = None + + class ClientConfig(BaseModel): title: str provider_interval: int = 21600 rule_interval: int = 86400 test_url: HttpUrl = "https://www.gstatic.com/generate_204" test_interval: int = 300 - main_policy: str = "节点选择" + main_policy: str = "🚀 节点选择" source_policy: str = "☁️ 机场选择" mixed_auto_policy: str = "♻️ 自动选择" manual_policy: str = "🚀 手动切换" @@ -58,6 +69,8 @@ class AppConfig(BaseModel): rules: dict[str, RuleConfig] = Field(default_factory=dict) clients: dict[str, ClientConfig] = Field(default_factory=dict) regions: dict[str, RegionConfig] = Field(default_factory=dict) + selector_groups: list[ProxyGroupConfig] = Field(default_factory=list) + policy_groups: list[ProxyGroupConfig] = Field(default_factory=list) class FetchResult(BaseModel): diff --git a/app/services/profiles.py b/app/services/profiles.py index 46f89e2..790821a 100644 --- a/app/services/profiles.py +++ b/app/services/profiles.py @@ -5,7 +5,7 @@ from typing import Any import yaml -from app.models import AppConfig, ClientConfig, SourceSnapshot +from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references @@ -13,6 +13,107 @@ def dump_yaml(data: dict[str, Any]) -> str: return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False) +def _expand_proxy_tokens( + proxies: list[str], + *, + client: ClientConfig, + source_auto_names: list[str], + selector_names: list[str], +) -> list[str]: + expanded: list[str] = [] + tokens = { + "{{ main_policy }}": [client.main_policy], + "{{main_policy}}": [client.main_policy], + "{{ source_policy }}": [client.source_policy], + "{{source_policy}}": [client.source_policy], + "{{ mixed_auto_policy }}": [client.mixed_auto_policy], + "{{mixed_auto_policy}}": [client.mixed_auto_policy], + "{{ manual_policy }}": [client.manual_policy], + "{{manual_policy}}": [client.manual_policy], + "{{ direct_policy }}": [client.direct_policy], + "{{direct_policy}}": [client.direct_policy], + "{{ source_auto_groups }}": source_auto_names, + "{{source_auto_groups}}": source_auto_names, + "{{ selector_groups }}": selector_names, + "{{selector_groups}}": selector_names, + } + for item in proxies: + expanded.extend(tokens.get(item, [item])) + return expanded + + +def _build_thin_filter_group( + *, + client_type: str, + client: ClientConfig, + group: ProxyGroupConfig, + selected_source_names: list[str], +) -> dict[str, Any]: + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "filter": group.filter, + } + if group.type == "url-test": + built["url"] = str(group.url or client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + if client_type == "mihomo": + built["use"] = selected_source_names + else: + built["include-all"] = True + return built + + +def _build_bundle_filter_group( + *, + client: ClientConfig, + group: ProxyGroupConfig, + all_proxy_names: list[str], +) -> dict[str, Any]: + matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)] + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "proxies": matched or [client.direct_policy], + } + if group.type == "url-test": + built["url"] = str(group.url or client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + return built + + +def _build_custom_policy_groups( + *, + app_config: AppConfig, + client: ClientConfig, + source_auto_names: list[str], + selector_names: list[str], +) -> list[dict[str, Any]]: + groups: list[dict[str, Any]] = [] + for group in app_config.policy_groups: + built: dict[str, Any] = { + "name": group.name, + "type": group.type, + "proxies": _expand_proxy_tokens( + group.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + if group.type == "url-test": + built["url"] = str(group.url or client.test_url) + built["interval"] = group.interval or client.test_interval + if group.tolerance is not None: + built["tolerance"] = group.tolerance + groups.append(built) + return groups + + def build_thin_profile( *, client_type: str, @@ -156,7 +257,7 @@ def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientCo groups.append(mixed_auto) - region_names: list[str] = [] + selector_names: list[str] = [] for region in app_config.regions.values(): group = { "name": region.name, @@ -171,7 +272,32 @@ def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientCo else: group["include-all"] = True groups.append(group) - region_names.append(region.name) + selector_names.append(region.name) + + for selector in app_config.selector_groups: + if selector.filter: + groups.append( + _build_thin_filter_group( + client_type=client_type, + client=client, + group=selector, + selected_source_names=selected_source_names, + ) + ) + else: + groups.append( + { + "name": selector.name, + "type": selector.type, + "proxies": _expand_proxy_tokens( + selector.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + ) + selector_names.append(selector.name) groups.append( { @@ -181,6 +307,14 @@ def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientCo } ) groups.append(manual) + groups.extend( + _build_custom_policy_groups( + app_config=app_config, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ) + ) groups.append( { "name": client.main_policy, @@ -188,7 +322,7 @@ def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientCo "proxies": [ client.source_policy, client.mixed_auto_policy, - *region_names, + *selector_names, client.manual_policy, client.direct_policy, ], @@ -230,7 +364,7 @@ def _build_bundle_groups( } ) - region_names: list[str] = [] + selector_names: list[str] = [] for region in app_config.regions.values(): matched = [name for name in all_proxy_names if re.search(region.filter, name)] groups.append( @@ -243,7 +377,31 @@ def _build_bundle_groups( "proxies": matched or [client.direct_policy], } ) - region_names.append(region.name) + selector_names.append(region.name) + + for selector in app_config.selector_groups: + if selector.filter: + groups.append( + _build_bundle_filter_group( + client=client, + group=selector, + all_proxy_names=all_proxy_names, + ) + ) + else: + groups.append( + { + "name": selector.name, + "type": selector.type, + "proxies": _expand_proxy_tokens( + selector.proxies, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ), + } + ) + selector_names.append(selector.name) groups.append( { @@ -259,6 +417,14 @@ def _build_bundle_groups( "proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy], } ) + groups.extend( + _build_custom_policy_groups( + app_config=app_config, + client=client, + source_auto_names=source_auto_names, + selector_names=selector_names, + ) + ) groups.append( { "name": client.main_policy, @@ -266,7 +432,7 @@ def _build_bundle_groups( "proxies": [ client.source_policy, client.mixed_auto_policy, - *region_names, + *selector_names, client.manual_policy, client.direct_policy, ], diff --git a/app/services/rules.py b/app/services/rules.py index c2292b8..3441058 100644 --- a/app/services/rules.py +++ b/app/services/rules.py @@ -5,7 +5,7 @@ from pathlib import Path import yaml from app.config import get_settings -from app.models import AppConfig, ClientConfig, RuleConfig +from app.models import AppConfig, ClientConfig def resolve_policy(policy: str, client: ClientConfig) -> str: @@ -38,9 +38,31 @@ def load_rule_payload(path: Path) -> list[str]: return lines +def _resolve_rule_lines(rule_name: str, app_config: AppConfig, client: ClientConfig) -> list[str]: + rule = app_config.rules[rule_name] + target = resolve_policy(rule.policy, client) + lines: list[str] = [] + + for payload_line in rule.payload: + line = f"{payload_line},{target}" + if rule.no_resolve: + line += ",no-resolve" + lines.append(line) + + if rule.file: + line = f"RULE-SET,{rule_name},{target}" + if rule.no_resolve: + line += ",no-resolve" + lines.append(line) + + return lines + + def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str): providers: dict[str, dict] = {} for name, rule in app_config.rules.items(): + if not rule.file: + continue entry = { "behavior": rule.behavior, "format": rule.format, @@ -53,12 +75,8 @@ def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, bas def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]: refs: list[str] = [] - for name, rule in app_config.rules.items(): - target = resolve_policy(rule.policy, client) - line = f"RULE-SET,{name},{target}" - if rule.no_resolve: - line += ",no-resolve" - refs.append(line) + for name in app_config.rules: + refs.extend(_resolve_rule_lines(name, app_config, client)) refs.append(f"MATCH,{client.main_policy}") return refs @@ -66,7 +84,14 @@ def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> li def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]: settings = get_settings() lines: list[str] = [] - for rule in app_config.rules.values(): + for name, rule in app_config.rules.items(): + for payload_line in rule.payload: + line = f"{payload_line},{resolve_policy(rule.policy, client)}" + if rule.no_resolve: + line += ",no-resolve" + lines.append(line) + if not rule.file: + continue path = (settings.rules_dir / rule.file).resolve() if not path.is_file() or settings.rules_dir.resolve() not in path.parents: raise FileNotFoundError(f"Rule file missing: {rule.file}") diff --git a/config/rules/direct.yaml b/config/rules/direct.yaml index 807d0e7..ac436a5 100644 --- a/config/rules/direct.yaml +++ b/config/rules/direct.yaml @@ -1,5 +1,7 @@ payload: - DOMAIN-SUFFIX,lan - DOMAIN-SUFFIX,local - - DOMAIN-SUFFIX,apple.com - - DOMAIN-SUFFIX,icloud.com + - DOMAIN-SUFFIX,localdomain + - DOMAIN-SUFFIX,home.arpa + - DOMAIN-SUFFIX,msftconnecttest.com + - DOMAIN-SUFFIX,msftncsi.com diff --git a/config/sources.yaml b/config/sources.yaml index 4ad1771..431bded 100644 --- a/config/sources.yaml +++ b/config/sources.yaml @@ -21,23 +21,233 @@ sources: regions: hk: - name: "🇭🇰 香港自动" - filter: "(?i)(香港|hk|hong kong)" + name: "🇭🇰 香港节点" + filter: "(?i)(港|hk|hong kong|hongkong)" + tolerance: 50 + tw: + name: "🇨🇳 台湾节点" + filter: "(?i)(台|新北|彰化|tw|taiwan)" tolerance: 50 sg: - name: "🇸🇬 新加坡自动" - filter: "(?i)(新加坡|狮城|sg|singapore)" + name: "🇸🇬 狮城节点" + filter: "(?i)(新加坡|坡|狮城|sg|singapore)" tolerance: 50 jp: - name: "🇯🇵 日本自动" - filter: "(?i)(日本|jp|japan)" + name: "🇯🇵 日本节点" + filter: "(?i)(日本|东京|大阪|埼玉|jp|japan)" tolerance: 50 us: - name: "🇺🇸 美国自动" - filter: "(?i)(美国|美國|us|united states)" + name: "🇺🇲 美国节点" + filter: "(?i)(美|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|us|united states)" tolerance: 150 + kr: + name: "🇰🇷 韩国节点" + filter: "(?i)(kr|korea|kor|首尔|韩|韓)" + tolerance: 50 + +selector_groups: + - name: "🎥 奈飞节点" + type: select + filter: "(?i)(nf|奈飞|解锁|netflix|media)" + +policy_groups: + - name: "📲 电报消息" + type: select + proxies: + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "🇸🇬 狮城节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇯🇵 日本节点" + - "🇺🇲 美国节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - "{{ direct_policy }}" + - name: "💬 Ai平台" + type: select + proxies: + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "🇺🇲 美国节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - "{{ direct_policy }}" + - name: "📹 油管视频" + type: select + proxies: + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "🇸🇬 狮城节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇯🇵 日本节点" + - "🇺🇲 美国节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - "{{ direct_policy }}" + - name: "🎥 奈飞视频" + type: select + proxies: + - "🎥 奈飞节点" + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "🇸🇬 狮城节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇯🇵 日本节点" + - "🇺🇲 美国节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - "{{ direct_policy }}" + - name: "🌍 国外媒体" + type: select + proxies: + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇺🇲 美国节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - "{{ direct_policy }}" + - name: "📢 谷歌" + type: select + proxies: + - "{{ main_policy }}" + - "{{ direct_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "Ⓜ️ 微软Bing" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "Ⓜ️ 微软云盘" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "Ⓜ️ 微软服务" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "🍎 苹果服务" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "🎮 游戏平台" + type: select + proxies: + - "{{ main_policy }}" + - "{{ direct_policy }}" + - "🇺🇲 美国节点" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "🎮 PT平台" + type: select + proxies: + - "{{ main_policy }}" + - "{{ direct_policy }}" + - "🇭🇰 香港节点" + - "🇨🇳 台湾节点" + - "🇸🇬 狮城节点" + - "🇯🇵 日本节点" + - "🇺🇲 美国节点" + - "🇰🇷 韩国节点" + - "{{ manual_policy }}" + - name: "🎯 全球直连" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - name: "🛑 广告拦截" + type: select + proxies: + - REJECT + - "{{ direct_policy }}" + - name: "🍃 应用净化" + type: select + proxies: + - REJECT + - "{{ direct_policy }}" + - name: "🐟 漏网之鱼" + type: select + proxies: + - "{{ direct_policy }}" + - "{{ main_policy }}" + - "{{ mixed_auto_policy }}" + - "{{ selector_groups }}" + - "{{ manual_policy }}" rules: + custom-proxy: + behavior: classical + format: text + policy: "{{ main_policy }}" + payload: + - DOMAIN-KEYWORD,cloudflare + - DOMAIN-KEYWORD,hetzner + - DOMAIN-KEYWORD,github + - DOMAIN-KEYWORD,google + - DOMAIN-KEYWORD,vscode + - DOMAIN-KEYWORD,telegram + - DOMAIN-KEYWORD,youtube + - DOMAIN-KEYWORD,whatsapp + - DOMAIN-KEYWORD,dropbox + - DOMAIN-KEYWORD,facebook + - DOMAIN-KEYWORD,twitter + - DOMAIN-KEYWORD,instagram + - DOMAIN-KEYWORD,spotify + - DOMAIN-KEYWORD,sci-hub + - DOMAIN-KEYWORD,1e100 + reject: file: reject.yaml behavior: domain @@ -45,26 +255,193 @@ rules: interval: 86400 policy: REJECT + app-purify: + behavior: classical + format: text + policy: "🍃 应用净化" + payload: + - DOMAIN-KEYWORD,omgmtaw + direct: file: direct.yaml behavior: domain format: yaml interval: 86400 - policy: "{{ direct_policy }}" + policy: "🎯 全球直连" - proxy: - file: proxy.yaml - behavior: domain - format: yaml - interval: 86400 - policy: "{{ main_policy }}" + local-network: + behavior: classical + format: text + policy: "🎯 全球直连" + no_resolve: true + payload: + - IP-CIDR,10.0.0.0/8 + - IP-CIDR,172.16.0.0/12 + - IP-CIDR,192.168.0.0/16 + + google: + behavior: classical + format: text + policy: "📢 谷歌" + payload: + - DOMAIN-KEYWORD,gmail + - DOMAIN-KEYWORD,google + - DOMAIN-SUFFIX,googleapis.com + - DOMAIN-SUFFIX,gstatic.com + - DOMAIN-SUFFIX,googlevideo.com + + microsoft-bing: + behavior: classical + format: text + policy: "Ⓜ️ 微软Bing" + payload: + - DOMAIN-KEYWORD,bing + + microsoft-onedrive: + behavior: classical + format: text + policy: "Ⓜ️ 微软云盘" + payload: + - DOMAIN-KEYWORD,1drv + - DOMAIN-KEYWORD,onedrive + - DOMAIN-KEYWORD,skydrive + + microsoft: + behavior: classical + format: text + policy: "Ⓜ️ 微软服务" + payload: + - DOMAIN-KEYWORD,microsoft + - DOMAIN-SUFFIX,live.com + - DOMAIN-SUFFIX,windows.com + - DOMAIN-SUFFIX,microsoftonline.com + + apple: + behavior: classical + format: text + policy: "🍎 苹果服务" + payload: + - DOMAIN-KEYWORD,apple + - DOMAIN-SUFFIX,icloud.com + - DOMAIN-SUFFIX,apple.com + + telegram: + behavior: classical + format: text + policy: "📲 电报消息" + no_resolve: true + payload: + - DOMAIN-KEYWORD,telegram + - DOMAIN-SUFFIX,t.me + - DOMAIN-SUFFIX,telegram.me + - DOMAIN-SUFFIX,telegram.org + - IP-CIDR,91.108.4.0/22 + - IP-CIDR,91.108.8.0/21 + - IP-CIDR,91.108.16.0/22 + - IP-CIDR,91.108.56.0/22 + - IP-CIDR,149.154.160.0/20 + + ai: + behavior: classical + format: text + policy: "💬 Ai平台" + payload: + - DOMAIN-KEYWORD,openai + - DOMAIN-KEYWORD,anthropic + - DOMAIN-KEYWORD,claude + - DOMAIN-SUFFIX,chatgpt.com + - DOMAIN-SUFFIX,openai.com + - DOMAIN-SUFFIX,oaistatic.com + - DOMAIN-SUFFIX,oaiusercontent.com + - DOMAIN-SUFFIX,anthropic.com + - DOMAIN-SUFFIX,claude.ai + + youtube: + behavior: classical + format: text + policy: "📹 油管视频" + payload: + - DOMAIN-KEYWORD,youtube + - DOMAIN-SUFFIX,youtu.be + - DOMAIN-SUFFIX,youtube.com + - DOMAIN-SUFFIX,ytimg.com + - DOMAIN-SUFFIX,googlevideo.com + + netflix: + behavior: classical + format: text + policy: "🎥 奈飞视频" + no_resolve: true + payload: + - DOMAIN-KEYWORD,netflix + - DOMAIN-KEYWORD,netflixdnstest + - DOMAIN-KEYWORD,apiproxy-device-prod-nlb- + - DOMAIN-KEYWORD,dualstack.apiproxy- + - DOMAIN-SUFFIX,netflix.com + - DOMAIN-SUFFIX,nflxvideo.net + - DOMAIN-SUFFIX,nflximg.net + - IP-CIDR,23.246.0.0/18 + - IP-CIDR,37.77.184.0/21 + + proxy-media: + behavior: classical + format: text + policy: "🌍 国外媒体" + payload: + - DOMAIN-KEYWORD,spotify + - DOMAIN-KEYWORD,tiktokcdn + - DOMAIN-KEYWORD,ttvnw + - DOMAIN-KEYWORD,jooxweb-api + - DOMAIN-KEYWORD,hbogoasia + - DOMAIN-KEYWORD,nowtv100 + - DOMAIN-KEYWORD,rthklive + - DOMAIN-KEYWORD,bbc + - DOMAIN-KEYWORD,youtube + - DOMAIN-SUFFIX,open.spotify.com + + games: + behavior: classical + format: text + policy: "🎮 游戏平台" + payload: + - DOMAIN-KEYWORD,epicgames + - DOMAIN-KEYWORD,origin + - DOMAIN-KEYWORD,steam + - DOMAIN-KEYWORD,nintendo + - DOMAIN-SUFFIX,steampowered.com + - DOMAIN-SUFFIX,steamcontent.com + - DOMAIN-SUFFIX,steamusercontent.com + + pt: + behavior: classical + format: text + policy: "🎮 PT平台" + payload: + - DOMAIN-KEYWORD,btschool + - DOMAIN-KEYWORD,hdkylin + - DOMAIN-KEYWORD,cloudflare + - DOMAIN-KEYWORD,hetzner + + cn-domain: + behavior: classical + format: text + policy: "🎯 全球直连" + payload: + - DOMAIN-KEYWORD,360buy + - DOMAIN-KEYWORD,alicdn + - DOMAIN-KEYWORD,alipay + - DOMAIN-KEYWORD,baidu + - DOMAIN-KEYWORD,bilibili + - DOMAIN-KEYWORD,jd + - DOMAIN-KEYWORD,qhimg + - DOMAIN-KEYWORD,xiaomi cn-ip: file: cn-ip.yaml behavior: ipcidr format: yaml interval: 86400 - policy: "{{ direct_policy }}" + policy: "🎯 全球直连" no_resolve: true clients: @@ -74,7 +451,7 @@ clients: rule_interval: 86400 test_url: https://www.gstatic.com/generate_204 test_interval: 300 - main_policy: 节点选择 + main_policy: 🚀 节点选择 source_policy: ☁️ 机场选择 mixed_auto_policy: ♻️ 自动选择 manual_policy: 🚀 手动切换 @@ -92,7 +469,7 @@ clients: rule_interval: 86400 test_url: https://www.gstatic.com/generate_204 test_interval: 300 - main_policy: 节点选择 + main_policy: 🚀 节点选择 source_policy: ☁️ 机场选择 mixed_auto_policy: ♻️ 自动选择 manual_policy: 🚀 手动切换 diff --git a/sub-provider-project-context.md b/sub-provider-project-context.md new file mode 100644 index 0000000..c7de82d --- /dev/null +++ b/sub-provider-project-context.md @@ -0,0 +1,652 @@ +# sub-provider 项目上下文说明 + +本文档用于给后续接手的 Codex / 开发者快速建立上下文,理解这个项目为什么存在、当前已经做了什么、设计取舍是什么、下一步应该往哪里继续。 + +--- + +## 1. 项目背景 + +用户当前的代理使用场景比较复杂: + +- Windows 主力客户端:**Mihomo Party / Clash Party** +- iOS 客户端:**Stash** +- 路由侧:**OpenClash** +- 上游机场源不止一个,其中至少有一个已经变成了 **AnyTLS** +- 过去主要依赖 `subconverter` / `subconverter-extended` / ACLSSR 模板体系做转换 + +用户遇到的问题是: + +1. 上游协议变复杂后,传统模板式转换越来越脆弱。 +2. 想统一管理多个机场源,但不想把所有逻辑继续绑死在单个大 YAML 里。 +3. 希望同时兼容两种使用习惯: + - 客户端直接拉一个完整配置文件 + - 客户端拉一个轻量壳子,然后继续通过 provider 自动更新节点和规则 +4. 希望保留订阅使用量 / 到期时间展示能力,即把上游订阅返回的 `Subscription-Userinfo` 头继续传给客户端。 + +因此,这个项目不是单纯的“订阅转换器”,而是一个 **订阅聚合与配置组装后端**。 + +--- + +## 2. 本项目的核心目标 + +这个项目的目标分成两层: + +### 2.1 内部目标 + +服务端内部继续**解耦维护**,不要回退成“一坨最终 YAML”。 + +内部要拆开维护: + +- 机场源定义 +- 节点拉取与标准化 +- provider 输出 +- 规则文件 +- 客户端入口配置 +- bundle 单文件配置 +- 配额头处理 + +### 2.2 外部目标 + +对客户端同时提供两种模式: + +#### A. 薄壳模式 +客户端拉: + +- `/clients/mihomo.yaml` +- `/clients/stash.yaml` + +特点: + +- 配置很薄 +- 内部继续引用远程 `proxy-providers` / `rule-providers` +- 节点和规则按 `interval` 自动更新 +- 更适合 Mihomo / OpenClash 的长期维护 + +#### B. Bundle 模式 +客户端拉: + +- `/bundle/mihomo.yaml` +- `/bundle/stash.yaml` + +特点: + +- 服务端把 `proxies`、`proxy-groups`、`rules` 全部展开成一个完整 YAML +- 更适合想“一条链接直接导入”的场景 +- 响应头继续带上订阅配额信息,方便 Stash / Clash Party 展示流量和到期 + +--- + +## 3. 已确认的产品决策 + +下面这些不是临时实现细节,而是本项目当前的明确产品约定。 + +### 3.1 支持多机场源选择 +所有主要接口都支持: + +```text +?sources=airport-a,airport-b +``` + +行为: + +- 不传 `sources`:默认使用所有启用的源 +- 传 `sources`:只使用指定源,按参数顺序处理 +- 自动去重 +- 禁用或不存在的源返回 404 + +### 3.2 配额头策略 + +用户明确要求: + +- 只选一个机场源时:返回这个机场源的订阅配额信息 +- 选多个机场源时:**只取第一个源**的配额信息 + +因此当前项目统一采用: + +- `Subscription-Userinfo` 只看 `sources` 参数中的第一个源 +- 不对多个机场的总量做相加、估算或“智能合并” + +这么做的原因是避免语义混乱。不同机场的总量、周期、重置时间可能完全不同,强行合成会误导客户端。 + +### 3.3 GET / HEAD 都要支持 + +所有核心 YAML 接口同时支持: + +- `GET` +- `HEAD` + +原因: + +- 某些客户端会用 `HEAD` 先探测订阅信息或减少流量消耗 +- 即使不返回 body,也要保留正确的响应头 + +当前实现策略: + +- `GET` 返回完整内容 + 响应头 +- `HEAD` 返回空 body + 相同响应头 + +### 3.4 当前版本只支持上游已输出 Clash/Mihomo YAML `proxies:` + +这是一个有意为之的阶段性约束。 + +当前项目假定上游 URL 返回的是已经能被解析成: + +```yaml +proxies: + - name: ... + type: ... +``` + +的 YAML 文档。 + +这样可以先接用户现有的: + +- sub-wrapper +- sub-store +- subconverter +- subconverter-extended + +等输出,先把整体架构跑通。 + +**当前还没有做:** + +- 原始 URI 订阅解析 +- base64 Clash 订阅解析 +- sing-box / Surge / Loon 原生格式输入解析 + +这是后续扩展点,不是当前版本目标。 + +--- + +## 4. 本次主要做了哪些工作 + +### 4.1 重新定义项目结构 + +把项目拆成了这些核心层: + +```text +sub-provider/ + app/ + config.py + main.py + models.py + services/ + cache.py + headers.py + loader.py + profiles.py + rules.py + subscriptions.py + config/ + sources.yaml + rules/ + reject.yaml + direct.yaml + proxy.yaml + cn-ip.yaml + data/ + cache/ + .env.example + Dockerfile + docker-compose.yaml + requirements.txt +``` + +目的: + +- 避免所有逻辑都塞进 `main.py` +- 让后续 Codex 能单独替换抓取层、规则层、模板层、缓存层 +- 为以后增加 `regions.yaml` / `policies.yaml` / 解析器模块做准备 + +### 4.2 增加了配置装载与模型层 + +抽离了: + +- App 级配置 +- SourceConfig +- ClientConfig +- RuleConfig +- RegionConfig +- 配额结构 +- Provider 文档结构 +- SourceSnapshot + +目的: + +- 让服务内部的处理单位明确 +- 避免后续继续传裸 dict +- 方便 Codex 在 builder / parser / renderer 之间改造 + +### 4.3 增加了 provider 层输出能力 + +新增能力: + +- 单源 provider 输出:`/providers/{name}.yaml` +- 多源 merged provider 输出:`/providers/merged.yaml?sources=...` + +目的: + +- 支持薄壳模式的 `proxy-providers` +- 支持调试和人工检查节点池 +- 为后续做“按地区 provider / 按能力 provider”打基础 + +### 4.4 增加了 thin client 输出能力 + +新增接口: + +- `/clients/mihomo.yaml` +- `/clients/stash.yaml` + +thin 模式特点: + +- 不内联全部节点 +- 继续引用 `/providers/{name}.yaml` +- 继续引用 `/rules/{name}.yaml` +- 只生成基础策略组 + +目的: + +- 让 Mihomo / OpenClash 保持 provider 驱动的更新机制 +- 避免每次节点变化都要整份配置重拉 + +### 4.5 增加了 bundle 单文件输出能力 + +新增接口: + +- `/bundle/mihomo.yaml` +- `/bundle/stash.yaml` + +bundle 模式特点: + +- 服务端把所有源节点合并后写进 `proxies` +- 服务端生成最终 `proxy-groups` +- 服务端把规则集直接内联到 `rules` +- 客户端只需要一条最终配置 URL + +目的: + +- 满足用户“客户端直接拉一个完整 YAML”的需求 +- 保留订阅头展示能力 +- 方便 Clash Party / Stash 这类更偏向“配置订阅”的使用方式 + +### 4.6 增加了 Subscription-Userinfo 头处理 + +实现了: + +- 从上游响应头读取配额信息 +- 解析出 upload / download / total / expire +- 在输出接口上重新写回 `Subscription-Userinfo` +- 对多源情况只取第一个源 + +目的: + +- 让客户端继续显示订阅用量、到期时间 +- 不要求客户端必须直接访问机场原始订阅 + +### 4.7 补全 HEAD 支持 + +在以下接口上都支持 `HEAD`: + +- `/providers/{name}.yaml` +- `/providers/merged.yaml` +- `/rules/{name}.yaml` +- `/clients/{client}.yaml` +- `/bundle/{client}.yaml` + +目的: + +- 兼容会先做探测的客户端 +- 减少不必要的 body 传输 + +### 4.8 默认生成了一套基础策略组 + +当前基础策略结构包括: + +- `☁️ 机场选择` +- `♻️ 自动选择` +- `🚀 手动切换` +- `🇭🇰 香港自动` +- `🇸🇬 新加坡自动` +- `🇯🇵 日本自动` +- `🇺🇸 美国自动` +- `节点选择` + +当前逻辑: + +- 每个源会生成一个“{display_name} 自动”组 +- 一个混合自动组聚合所有源 +- 地区组根据节点名正则匹配 +- 主策略组最终统一指向 `节点选择` + +目的: + +- 先提供最小可用的选择层 +- 给后续做“provider 选择 → 地区节点选择 → 业务规则选择”留位置 + +--- + +## 5. 当前接口约定 + +### 5.1 健康检查 + +```text +GET /healthz +``` + +### 5.2 单 provider + +```text +GET //providers/{name}.yaml +HEAD //providers/{name}.yaml +``` + +用途:返回指定机场源的 provider 文件。 + +### 5.3 merged provider + +```text +GET //providers/merged.yaml?sources=airport-a,airport-b +HEAD //providers/merged.yaml?sources=airport-a,airport-b +``` + +用途:把多个源合成一个 provider 文件。 + +### 5.4 rule-provider + +```text +GET //rules/{name}.yaml +HEAD //rules/{name}.yaml +``` + +用途:返回单独规则文件。 + +### 5.5 thin client + +```text +GET //clients/mihomo.yaml?sources=... +HEAD //clients/mihomo.yaml?sources=... +GET //clients/stash.yaml?sources=... +HEAD //clients/stash.yaml?sources=... +``` + +### 5.6 bundle client + +```text +GET //bundle/mihomo.yaml?sources=... +HEAD //bundle/mihomo.yaml?sources=... +GET //bundle/stash.yaml?sources=... +HEAD //bundle/stash.yaml?sources=... +``` + +--- + +## 6. 当前默认配置文件含义 + +### 6.1 `config/sources.yaml` + +这是目前最核心的配置文件,已经承载了: + +- `public_path` +- `sources` +- `regions` +- `rules` +- `clients` + +其中: + +#### `sources` +定义机场源: + +- `enabled` +- `display_name` +- `kind` +- `url` +- `prefix` +- `include_regex` +- `exclude_regex` + +当前 `kind` 主要按 `clash_yaml` 用。 + +#### `regions` +定义按节点名正则筛选的地区自动组。 + +#### `rules` +定义规则文件与它最终映射到哪个 policy。 + +#### `clients` +定义每个客户端类型的: + +- provider 更新周期 +- rule 更新周期 +- test URL +- 主策略组名 +- 自动组名 +- 手动组名 +- DIRECT 策略名 +- 端口和 LAN 配置(Mihomo) + +--- + +## 7. 当前设计的主要思路 + +### 7.1 内部解耦,输出再组装 + +这是本项目最重要的原则。 + +不要直接维护最终配置文件;而是: + +- 内部拆成源、规则、策略模板、客户端模板 +- 对外按需求组合成 thin 或 bundle + +这样未来无论是要做: + +- 新客户端 +- 新地区组 +- 新业务组 +- 新能力标签 +- 新鉴权层 + +都不会把整个项目拖垮。 + +### 7.2 同时保留 thin 与 bundle + +这不是重复劳动,而是为了满足不同客户端和不同使用习惯。 + +- thin 更适合 Mihomo / OpenClash +- bundle 更适合想“一条链接导入”的用户 +- bundle 更方便把配额头暴露给客户端 UI + +### 7.3 不在当前阶段过度抽象业务组 + +用户后来贴出了一份更复杂的目标配置,里面已经有: + +- provider 选择 +- 地区节点选择 +- 业务策略组(Telegram / AI / YouTube / Netflix / Google / Microsoft / Apple / Game 等) +- 大量规则 + +但本次项目中并**没有**把那整套复杂业务组全部移植进来。 + +本次改造的重点不是“把规则抄完”,而是先把**后端架构**做对,避免继续堆在单文件 YAML 上。 + +换句话说,本次完成的是: + +- 框架和接口层 +- provider / bundle 双模式 +- 配额头链路 +- 基础策略组 + +而不是完整复刻用户那份业务规则大配置。 + +--- + +## 8. 当前版本的局限 + +下面这些是明确存在、且后续值得继续做的地方。 + +### 8.1 只支持 Clash/Mihomo YAML 输入 + +还不支持: + +- ss:// / vmess:// / vless:// / trojan:// 原始 URI 解析 +- base64 聚合订阅解析 +- sing-box 输入格式 +- Surge / Loon 原生格式输入 + +### 8.2 `sources.yaml` 还承担了过多职责 + +后续建议继续拆分: + +- `sources.yaml` +- `regions.yaml` +- `policies.yaml` +- `clients.yaml` + +### 8.3 业务组还没有模板化 + +当前只有基础组: + +- 机场选择 +- 混合自动 +- 手动切换 +- 地区自动 +- 节点选择 + +后续可以把用户想要的: + +- `📲 电报消息` +- `💬 Ai平台` +- `📹 油管视频` +- `🎥 奈飞视频` +- `🌍 国外媒体` +- `📢 谷歌` +- `Ⓜ️ 微软服务` +- `🍎 苹果服务` +- `🎮 游戏平台` + +抽象成 `policies.yaml` + 模板生成。 + +### 8.4 目前地区识别仅靠节点名正则 + +还没有做: + +- 更可靠的地区标签系统 +- 能力标签(Netflix / AI / Game / Native) +- 延迟或解锁能力探测 + +### 8.5 没有内置鉴权 + +当前项目更偏“后端核心能力”,安全性仍建议依赖前置层: + +- Caddy / Nginx Basic Auth +- 仅 Tailscale 可访问 +- 或其他反代鉴权方案 + +--- + +## 9. 后续最值得继续做的事 + +优先级建议如下。 + +### P1:把用户目标配置里的“业务组模板”抽出来 + +新增: + +- `policies.yaml` + +做法: + +- 把业务组可选项抽成模板 +- 让规则只引用稳定业务组名 +- 让机场组 / 地区组 / 自动组作为底层构件 + +目标: + +让项目真正实现“provider 选择 → 节点选择 → 业务规则指向”。 + +### P1:把地区配置独立出来 + +新增: + +- `regions.yaml` + +目标: + +- 减少 `sources.yaml` 复杂度 +- 地区过滤可以单独维护 + +### P1:支持原始订阅解析 + +新增: + +- parser 层 + +目标: + +- 直接吃机场原始 URI / base64 订阅 +- 降低对外部转换器的依赖 + +### P2:支持能力标签 + +例如: + +- `netflix: true` +- `ai: true` +- `game: true` +- `native: true` + +目标: + +- 自动生成 `🎥 奈飞节点`、`💬 AI可用节点` 之类的能力池 + +### P2:缓存和探测增强 + +目标: + +- provider 拉取加 TTL / ETag / 条件请求 +- 节点能力探测可选化 +- 避免每次请求都全量重拉上游 + +### P3:更强的模板体系 + +目标: + +- 区分 Mihomo / Stash / OpenClash 细节 +- 更好地控制 bundle 与 thin 差异 + +--- + +## 10. 给 Codex 的工作建议 + +如果 Codex 要继续接手,建议按下面顺序继续,不要直接推翻当前结构。 + +### 建议 1 +先保留当前 `services/` 分层,不要把逻辑重新打回 `main.py`。 + +### 建议 2 +优先做 `policies.yaml`,把用户贴出来的复杂业务组迁进模板体系。 + +### 建议 3 +不要急着做“多机场流量合并显示”,继续坚持“只取第一个源”的策略,除非产品需求明确变更。 + +### 建议 4 +原始订阅解析应作为单独模块实现,不要污染当前 provider / profile builder。 + +### 建议 5 +如果将来要支持更多客户端,优先抽象: + +- 输入解析层 +- 中间节点模型 +- 输出模板层 + +而不是在 builder 里堆条件分支。 + +--- + +## 11. 一句话总结 + +这个项目的本质不是“再写一个 subconverter”,而是: + +> **把多机场源、规则、策略组和客户端输出方式解耦维护,再按客户端需要动态组装成 thin 配置或 bundle 配置,并把第一个源的订阅配额头继续透传给客户端。** + +这就是当前这版优化的核心目标,也是后续继续演进时不应该偏离的方向。