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:
+129
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user