#!/usr/bin/env python3 """Sanitized Clash-sub helpers. Never print or persist credentials.""" from __future__ import annotations import collections import json import os import re import urllib.parse from typing import Any INFO_NAME_RE = re.compile(r"^(剩余流量|套餐到期|到期|流量)") REGION_RULES: list[tuple[str, list[str]]] = [ ("香港", ["香港", "HK", "🇭🇰"]), ("台湾", ["台湾", "臺灣", "🇹🇼"]), ("日本", ["日本", "东京", "大阪", "🇯🇵"]), ("韩国", ["韩国", "韓國", "首尔", "🇰🇷"]), ("新加坡", ["新加坡", "狮城", "🇸🇬"]), ("美国", ["美国", "美國", "圣何塞", "洛杉矶", "硅谷", "西雅图", "芝加哥", "纽约", "🇺🇸"]), ("英国", ["英国", "英國", "伦敦", "🇬🇧"]), ("德国", ["德国", "德國", "法兰克福", "🇩🇪"]), ("法国", ["法国", "法國", "巴黎", "🇫🇷"]), ("加拿大", ["加拿大", "🇨🇦"]), ("澳大利亚", ["澳大利亚", "澳洲", "🇦🇺"]), ("澳门", ["澳门", "澳門", "🇲🇴"]), ("中国", ["中国", "回国"]), ] def infer_region(name: str) -> str: for label, keys in REGION_RULES: for key in keys: if key in name: return label return "其他/未知" def is_info_node(name: str) -> bool: return bool(INFO_NAME_RE.search(name)) def parse_flow_proxies(text: str) -> list[dict[str, str]]: proxies: list[dict[str, str]] = [] for line in text.splitlines(): if "name:" not in line or "type:" not in line or "server:" not in line: continue name_m = re.search(r"name:\s*'([^']+)'|name:\s*([^,}]+)", line) type_m = re.search(r"type:\s*([a-zA-Z0-9-]+)", line) if not name_m or not type_m: continue name = (name_m.group(1) or name_m.group(2)).strip() proxies.append( { "name": name, "type": type_m.group(1), "region": infer_region(name), } ) return proxies def detect_format(text: str) -> str: head = text.lstrip()[:400] if "proxies:" in text[:4000] and ( "mixed-port:" in head or "port:" in head or head.startswith("proxies:") ): return "clash-yaml" if any(head.startswith(s) for s in ("ss://", "vmess://", "trojan://", "vless://")): return "uri-list" compact = re.sub(r"\s+", "", text) if compact and re.fullmatch(r"[A-Za-z0-9+/=_-]+", compact[:80] or ""): return "base64-maybe" return "other" def rewrite_runtime_config(src: str, mixed_port: int, controller: str) -> str: """Rewrite listen ports / controller; strip original rules to MATCH selector.""" lines = src.splitlines() out: list[str] = [] skipping_rules = False selector = "🚀节点选择" for line in lines: if line.startswith("proxy-groups:"): skipping_rules = False if line.startswith("rules:"): skipping_rules = True continue if skipping_rules: continue if line.startswith("mixed-port:"): out.append(f"mixed-port: {mixed_port}") continue if line.startswith("allow-lan:"): out.append("allow-lan: true") continue if line.startswith("external-controller:"): out.append(f"external-controller: '{controller}'") continue if line.startswith("bind-address:"): out.append("bind-address: '*'") continue out.append(line) out.append("rules:") out.append(f" - MATCH,{selector}") return "\n".join(out) + "\n" def mask_secret(value: str, keep: int = 4) -> str: if not value: return "" return value[:keep] + "***" def summarize_subscription(text: str) -> dict[str, Any]: fmt = detect_format(text) proxies = parse_flow_proxies(text) real = [p for p in proxies if not is_info_node(p["name"])] info = [p for p in proxies if is_info_node(p["name"])] return { "format": fmt, "proxy_count_raw": len(proxies), "proxy_count_real": len(real), "info_nodes": [p["name"] for p in info], "types": dict(collections.Counter(p["type"] for p in real)), "regions": dict(collections.Counter(p["region"] for p in real)), "proxies": real, } def api_quote(name: str) -> str: return urllib.parse.quote(name, safe="") def require_sub_url() -> str: url = os.environ.get("PROXY_SUB_URL", "").strip() if not url: raise SystemExit("PROXY_SUB_URL is required (do not hardcode the URL)") if not url.startswith(("http://", "https://")): raise SystemExit("PROXY_SUB_URL must be an http(s) URL") return url def dump_json(path: str, obj: Any) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(obj, fh, ensure_ascii=False, indent=2) fh.write("\n")