feat: direct subscription fetching, URI parser, FLClashX compatibility

- Fetch subscriptions directly via httpx (mihomo UA → YAML, xray-checker → base64 URI list)
- Add uri_parser.py: vless/vmess/ss/trojan/hysteria2 URI → Mihomo proxy dicts
- Fix YAML quoting for Go parser (strings starting with special chars)
- Remove hidden:true and proxy-providers from delivered configs
- Inject all service groups into GLOBAL proxies for FLClashX group discovery
- Strip placeholder proxy-providers (e.g. "subscription") not in DB
- Fix Mihomo healthcheck: add Bearer auth header
- Add README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:04:03 +03:00
parent 5fb1782fa5
commit 2e1c488bb9
7 changed files with 604 additions and 24 deletions
+129 -10
View File
@@ -1,4 +1,23 @@
import yaml
from yaml.representer import SafeRepresenter
def _str_representer(dumper: yaml.Dumper, value: str) -> yaml.ScalarNode:
# Quote strings that start with special YAML characters so Go parser doesn't choke
if value and value[0] in ('-', ':', '{', '}', '[', ']', ',', '#', '&', '*', '?', '|', '<', '>', '=', '!', '%', '@', '`'):
return dumper.represent_scalar('tag:yaml.org,2002:str', value, style="'")
return SafeRepresenter.represent_str(dumper, value)
class _SafeDumper(yaml.SafeDumper):
pass
_SafeDumper.add_representer(str, _str_representer)
def _dump(data: dict) -> str:
return yaml.dump(data, Dumper=_SafeDumper, allow_unicode=True, sort_keys=False)
PROXY_FIELDS = {
"name", "type", "server", "port", "uuid", "password", "cipher",
@@ -8,9 +27,21 @@ PROXY_FIELDS = {
"client-fingerprint", "early-data-header-name", "smux",
}
# Types that are not real proxy protocols — Mihomo may include them in provider
# responses but they're not valid standalone proxy entries.
_SKIP_TYPES = {"direct", "dns", "reject", "pass", "compatible", "relay",
"selector", "fallback", "url-test", "load-balance"}
def filter_proxy(proxy: dict) -> dict:
return {k: v for k, v in proxy.items() if k in PROXY_FIELDS}
result = {k: v for k, v in proxy.items() if k in PROXY_FIELDS}
if "type" in result and isinstance(result["type"], str):
result["type"] = result["type"].lower()
return result
def _is_real_proxy(proxy: dict) -> bool:
return "name" in proxy and proxy.get("type", "").lower() not in _SKIP_TYPES
def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> str:
@@ -18,25 +49,113 @@ def expand_config(base_yaml: str, provider_proxies: dict[str, list[dict]]) -> st
all_proxies = list(cfg.get("proxies") or [])
provider_to_names: dict[str, list[str]] = {}
all_sub_names: list[str] = []
for provider_name, proxies in provider_proxies.items():
filtered = [filter_proxy(p) for p in proxies if "name" in p]
filtered = [filter_proxy(p) for p in proxies if _is_real_proxy(p)]
provider_to_names[provider_name] = [p["name"] for p in filtered]
all_proxies.extend(filtered)
all_sub_names.extend(p["name"] for p in filtered)
cfg["proxies"] = all_proxies
# Deduplicate subscription proxy names (preserve order)
seen: set[str] = set()
unique_sub_names: list[str] = []
for n in all_sub_names:
if n not in seen:
seen.add(n)
unique_sub_names.append(n)
cfg["proxy-groups"] = list(cfg.get("proxy-groups") or [])
sub_names_set = set(unique_sub_names)
expanded_group_names: list[str] = []
for group in cfg.get("proxy-groups") or []:
if "use" in group:
expanded: list[str] = []
for pname in group.pop("use"):
expanded.extend(provider_to_names.get(pname, []))
group.setdefault("proxies", [])
group["proxies"] = group["proxies"] + expanded
group.pop("hidden", None)
if "use" not in group:
continue
name = group.get("name", "")
expanded: list[str] = []
for pname in group.pop("use"):
expanded.extend(provider_to_names.get(pname, []))
original = group.get("proxies") or []
special = [p for p in original if p not in sub_names_set and p != "DIRECT"]
group["proxies"] = special + expanded + ["DIRECT"]
if not group["proxies"]:
group["proxies"] = ["DIRECT"]
if name and name != "GLOBAL":
expanded_group_names.append(name)
# Add all service groups to GLOBAL so FLClashX can discover them
if expanded_group_names:
global_group = next(
(g for g in cfg.get("proxy-groups") or [] if g.get("name") == "GLOBAL"),
None,
)
if global_group:
current = global_group.get("proxies") or []
current_set = set(current)
to_add = [n for n in expanded_group_names if n not in current_set]
global_group["proxies"] = current + to_add
cfg.pop("proxy-providers", None)
return yaml.dump(cfg, allow_unicode=True, sort_keys=False)
return _dump(cfg)
def inject_providers_for_delivery(base_yaml: str, subscriptions: list) -> str:
"""At delivery time: inject proxy-providers entries and use: for subscriptions absent from base_yaml."""
if not subscriptions:
return base_yaml
cfg = yaml.safe_load(base_yaml) or {}
db_sub_names = {sub.name for sub in subscriptions}
# Remove placeholder providers that aren't real DB subscriptions
if cfg.get("proxy-providers"):
for name in list(cfg["proxy-providers"].keys()):
if name not in db_sub_names:
del cfg["proxy-providers"][name]
existing_providers = set((cfg.get("proxy-providers") or {}).keys())
new_entries = {}
for sub in subscriptions:
if sub.name not in existing_providers:
new_entries[sub.name] = {
"type": "http",
"url": sub.url,
"interval": 3600,
"health-check": {
"enable": True,
"url": "https://www.gstatic.com/generate_204",
"interval": 300,
},
}
if not new_entries:
return base_yaml
all_known = existing_providers | set(new_entries.keys())
providers = cfg.setdefault("proxy-providers", {})
providers.update(new_entries)
new_names = list(new_entries.keys())
_special = {"DIRECT", "REJECT"}
for group in cfg.get("proxy-groups") or []:
if group.get("name") in _special:
continue
if "use" in group:
# Replace references to unknown providers with our subscriptions
patched = []
for u in group["use"]:
if u in all_known:
patched.append(u)
else:
patched.extend(new_names)
group["use"] = patched if patched else new_names
else:
group["use"] = new_names
return _dump(cfg)
def build_mihomo_config(all_base_yamls: list[str], secret: str) -> str:
@@ -60,4 +179,4 @@ def build_mihomo_config(all_base_yamls: list[str], secret: str) -> str:
if providers:
config["proxy-providers"] = providers
return yaml.dump(config, allow_unicode=True, sort_keys=False)
return _dump(config)