Files
Sub2Socks/fetcher.py
T
Hermes-1 ed52257dbe refactor: split monolith into typed modules with retry logic
- models.py: ProxyInfo and DNSServerConfig dataclasses with type hints
- fetcher.py: subscription fetcher with exponential backoff retries (3 attempts) plus VLESS URL parser
- config_builder.py: sing-box config builder decomposed into focused helpers
- main.py: orchestrator with setup_logging(), print_banner(), pick_node()
2026-07-09 08:14:23 +00:00

155 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Subscription fetching and VLESS URL parsing."""
import base64
import logging
import time
import urllib.error
import urllib.parse as up
import urllib.request
from typing import Dict, List, Optional
from models import ProxyInfo
logger = logging.getLogger(__name__)
def fetch_subscription(url: str, max_retries: int = 3, backoff_base: float = 1.0) -> str:
"""Download subscription content with retry and exponential backoff.
Args:
url: Subscription URL to download.
max_retries: Maximum number of retry attempts (default 3).
backoff_base: Base delay in seconds for exponential backoff (default 1.0s -> 1, 2, 4).
Returns:
Decoded subscription text content.
Raises:
urllib.error.URLError: If all retries fail.
Exception: On non-recoverable errors.
"""
last_error: Optional[Exception] = None
for attempt in range(max_retries):
try:
logger.info("Fetching subscription from %s (attempt %d/%d)", url, attempt + 1, max_retries)
req = urllib.request.Request(url, headers={"User-Agent": "clash.meta"})
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
logger.debug("Raw data is base64 encoded, decoding...")
content = base64.b64decode(raw).decode("utf-8")
if attempt > 0:
logger.info("Fetch succeeded on attempt %d", attempt + 1)
return content
except urllib.error.URLError as e:
last_error = e
wait_time = backoff_base * (2 ** attempt)
logger.warning("Fetch failed (attempt %d/%d): %s. Retrying in %.1fs...",
attempt + 1, max_retries, e, wait_time)
if attempt < max_retries - 1:
time.sleep(wait_time)
except Exception as e:
logger.error("Non-recoverable fetch error: %s", e)
raise
logger.error("All %d fetch attempts failed for %s", max_retries, url)
raise last_error # type: ignore[misc]
def extract_vless_lines(text: str) -> List[str]:
"""Extract VLESS URI lines from subscription text.
Handles both plain and base64-encoded line formats.
Args:
text: Raw subscription content.
Returns:
List of VLESS URL strings.
"""
result: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.lower().startswith("vless://"):
result.append(stripped)
continue
try:
decoded = base64.b64decode(stripped).decode("utf-8")
if decoded.lower().startswith("vless://"):
result.append(decoded)
except Exception:
pass
return result
def parse_vless_url(url_str: str) -> Dict[str, str]:
"""Parse a VLESS URL string into a component dictionary.
Args:
url_str: VLESS subscription URI (e.g. vless://uuid@host:port?params).
Returns:
Dictionary with all parsed proxy fields.
"""
parsed = up.urlparse(url_str.strip())
query_params = up.parse_qs(parsed.query)
def get_param(key: str, default: Optional[str] = None) -> Optional[str]:
if key in query_params:
return query_params[key][0]
return default
return {
"uuid": parsed.username or "",
"server": parsed.hostname or "",
"port": parsed.port or (443 if parsed.scheme == "vless+tls" else 80),
"security": get_param("security", "tls"),
"type": get_param("type", "tcp"),
"sni": get_param("sni") or "",
"alpn": get_param("alpn") or "",
"pbk": get_param("public_key") or get_param("pbk") or "",
"sid": get_param("sid") or "",
"spx": get_param("spx") or "",
"path": get_param("path") or "/",
"serviceName": get_param("serviceName") or get_param("service-name") or "",
"host": get_param("host") or "",
"flow": get_param("flow") or "",
}
def url_to_proxy_info(url_str: str) -> ProxyInfo:
"""Convert a VLESS URL directly to a ProxyInfo dataclass.
Args:
url_str: VLESS subscription URI.
Returns:
ProxyInfo instance with parsed fields.
"""
raw = parse_vless_url(url_str)
return ProxyInfo(
uuid=raw["uuid"],
server=raw["server"],
port=int(raw["port"]),
security=raw["security"],
type=raw["type"],
sni=raw["sni"],
alpn=raw["alpn"],
pbk=raw["pbk"],
sid=raw["sid"],
spx=raw["spx"],
path=raw["path"],
serviceName=raw["serviceName"],
host=raw["host"],
flow=raw["flow"],
)