release: cut v0.02 with Surge API proxy rotation
This commit is contained in:
+244
-11
@@ -24,6 +24,8 @@ import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
@@ -139,6 +141,14 @@ class ProxyConfig:
|
||||
_process: Any = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SurgeConfig:
|
||||
api_url: str
|
||||
api_key: str
|
||||
group_name: str
|
||||
proxy_url: str
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PoW 挑战解决器
|
||||
# ============================================================
|
||||
@@ -178,6 +188,26 @@ class ChallengeSolver:
|
||||
return None
|
||||
|
||||
|
||||
def load_dotenv(dotenv_path: str = ".env"):
|
||||
"""加载本地 .env 文件,不覆盖已存在的环境变量"""
|
||||
if not os.path.exists(dotenv_path):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(dotenv_path, "r", encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ZLib 会话管理
|
||||
# ============================================================
|
||||
@@ -222,14 +252,14 @@ class ZLibSession:
|
||||
)
|
||||
ip = parser(resp.json())
|
||||
print(
|
||||
f" 🌐 [{stage}] 当前出口 IP: {ip} (proxy={proxy_desc}, probe={probe_url})",
|
||||
f" 🌐 [{stage}] 调试探针出口 IP: {ip} (proxy={proxy_desc}, probe={probe_url})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
print(f" 🌐 [{stage}] 当前出口 IP 获取失败: {last_error}", file=sys.stderr)
|
||||
print(f" 🌐 [{stage}] 调试探针出口 IP 获取失败: {last_error}", file=sys.stderr)
|
||||
|
||||
def _request(self, url: str, **kwargs) -> cffi_requests.Response:
|
||||
"""发起请求,自动处理 PoW 挑战"""
|
||||
@@ -485,6 +515,173 @@ class ZLibSession:
|
||||
# 代理管理器
|
||||
# ============================================================
|
||||
|
||||
class SurgeRuntime:
|
||||
"""通过 Surge HTTP API 轮换指定策略组"""
|
||||
|
||||
MAX_DOWNLOADS_PER_PROXY = 5
|
||||
|
||||
def __init__(self, config: SurgeConfig, verbose: bool = False):
|
||||
self.config = config
|
||||
self.verbose = verbose
|
||||
self.policy_names: List[str] = []
|
||||
self.current_index = -1
|
||||
self.downloads_count = 0
|
||||
|
||||
def _api_get(self, path: str, params: Optional[Dict[str, str]] = None) -> Any:
|
||||
url = urllib.parse.urljoin(self.config.api_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
if params:
|
||||
url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(url, headers={"X-Key": self.config.api_key, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def _api_post(self, path: str, payload: Dict[str, Any]) -> Any:
|
||||
url = urllib.parse.urljoin(self.config.api_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"X-Key": self.config.api_key,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def _extract_group_policies(self, payload: Any) -> List[str]:
|
||||
def _coerce_names(items: Any) -> List[str]:
|
||||
names: List[str] = []
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
names.append(item)
|
||||
elif isinstance(item, dict) and item.get("name"):
|
||||
names.append(str(item["name"]))
|
||||
return names
|
||||
|
||||
if isinstance(payload, dict):
|
||||
direct = payload.get(self.config.group_name)
|
||||
names = _coerce_names(direct)
|
||||
if names:
|
||||
return names
|
||||
|
||||
groups = payload.get("policy_groups")
|
||||
if isinstance(groups, list):
|
||||
for group in groups:
|
||||
if isinstance(group, dict) and group.get("name") == self.config.group_name:
|
||||
names = _coerce_names(group.get("policies") or group.get("options") or [])
|
||||
if names:
|
||||
return names
|
||||
|
||||
groups = payload.get("groups")
|
||||
if isinstance(groups, dict):
|
||||
group = groups.get(self.config.group_name)
|
||||
if isinstance(group, dict):
|
||||
names = _coerce_names(group.get("policies") or group.get("options") or [])
|
||||
if names:
|
||||
return names
|
||||
names = _coerce_names(group)
|
||||
if names:
|
||||
return names
|
||||
|
||||
raise RuntimeError(f"无法从 Surge API 响应中解析策略组 {self.config.group_name}")
|
||||
|
||||
def _load_policies(self) -> List[str]:
|
||||
data = self._api_get("/v1/policy_groups")
|
||||
policies = self._extract_group_policies(data)
|
||||
if not policies:
|
||||
raise RuntimeError(f"策略组 {self.config.group_name} 中没有可用代理")
|
||||
self.policy_names = policies
|
||||
return policies
|
||||
|
||||
def _sync_current_index(self):
|
||||
current = self.get_current_proxy_name(refresh=True)
|
||||
if current in self.policy_names:
|
||||
self.current_index = self.policy_names.index(current)
|
||||
|
||||
def has_proxy_pool(self) -> bool:
|
||||
return True
|
||||
|
||||
def ensure_active(self) -> bool:
|
||||
try:
|
||||
if not self.policy_names:
|
||||
self._load_policies()
|
||||
self._sync_current_index()
|
||||
return self.current_index >= 0
|
||||
except Exception as e:
|
||||
print(f"代理错误: 初始化 Surge 策略组失败: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def get_current_proxy_url(self) -> Optional[str]:
|
||||
return self.config.proxy_url
|
||||
|
||||
def get_current_proxy_name(self, refresh: bool = True) -> str:
|
||||
try:
|
||||
data = self._api_get("/v1/policy_groups/select", {"group_name": self.config.group_name}) if refresh else None
|
||||
if data is not None:
|
||||
policy = data.get("policy")
|
||||
if isinstance(policy, str) and policy:
|
||||
if policy in self.policy_names:
|
||||
self.current_index = self.policy_names.index(policy)
|
||||
return policy
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 0 <= self.current_index < len(self.policy_names):
|
||||
return self.policy_names[self.current_index]
|
||||
return self.config.group_name
|
||||
|
||||
def record_download(self):
|
||||
self.downloads_count += 1
|
||||
|
||||
def should_rotate(self) -> bool:
|
||||
return self.downloads_count >= self.MAX_DOWNLOADS_PER_PROXY
|
||||
|
||||
def rotate(self) -> bool:
|
||||
try:
|
||||
if not self.policy_names:
|
||||
self._load_policies()
|
||||
self._sync_current_index()
|
||||
|
||||
if not self.policy_names:
|
||||
return False
|
||||
|
||||
next_index = self.current_index + 1
|
||||
if next_index >= len(self.policy_names):
|
||||
if self.verbose:
|
||||
print(" ⚠️ Surge 策略组中的代理已用完", file=sys.stderr)
|
||||
return False
|
||||
|
||||
next_policy = self.policy_names[next_index]
|
||||
self._api_post(
|
||||
"/v1/policy_groups/select",
|
||||
{"group_name": self.config.group_name, "policy": next_policy},
|
||||
)
|
||||
time.sleep(1.0)
|
||||
self.current_index = next_index
|
||||
self.downloads_count = 0
|
||||
|
||||
if self.verbose:
|
||||
print(f" 📡 Surge 已切换策略组 {self.config.group_name} -> {next_policy}", file=sys.stderr)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"代理错误: 切换 Surge 策略组失败: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def remaining_proxies(self) -> int:
|
||||
if not self.policy_names:
|
||||
return 0
|
||||
return max(0, len(self.policy_names) - self.current_index - 1)
|
||||
|
||||
def remaining_downloads(self) -> int:
|
||||
return max(0, self.MAX_DOWNLOADS_PER_PROXY - self.downloads_count)
|
||||
|
||||
def cleanup(self):
|
||||
return None
|
||||
|
||||
class ProxyManager:
|
||||
"""管理代理列表和轮询"""
|
||||
|
||||
@@ -1124,7 +1321,7 @@ def _should_rotate_proxy(message: str) -> bool:
|
||||
|
||||
|
||||
def _create_session_for_current_proxy(
|
||||
proxy_mgr: ProxyManager,
|
||||
proxy_mgr: Any,
|
||||
explicit_proxy: Optional[str],
|
||||
verbose: bool,
|
||||
) -> ZLibSession:
|
||||
@@ -1137,20 +1334,47 @@ def cmd_download(args):
|
||||
"""执行下载命令"""
|
||||
explicit_proxy = getattr(args, "proxy", None)
|
||||
proxy_file = getattr(args, "proxy_file", None)
|
||||
if explicit_proxy and proxy_file:
|
||||
print("错误: --proxy 与 --proxy-file 不能同时使用", file=sys.stderr)
|
||||
surge_key = getattr(args, "surge_key", None) or os.environ.get("SURGE_API_KEY")
|
||||
surge_api = getattr(args, "surge_api", None) or os.environ.get("SURGE_API_URL")
|
||||
use_surge = bool(surge_api)
|
||||
|
||||
if len([item for item in [explicit_proxy, proxy_file, surge_api] if item]) > 1:
|
||||
print("错误: --proxy / --proxy-file / --surge-api 只能选择一种模式", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if use_surge and not surge_key:
|
||||
print("错误: Surge 模式需要 --surge-key 或环境变量 SURGE_API_KEY", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 代理管理器
|
||||
proxy_mgr = ProxyManager(
|
||||
proxy_file=proxy_file, verbose=args.verbose
|
||||
)
|
||||
if use_surge:
|
||||
surge_key_str = str(surge_key)
|
||||
surge_proxy_url = (
|
||||
getattr(args, "surge_http_proxy", None)
|
||||
or os.environ.get("SURGE_HTTP_PROXY")
|
||||
or getattr(args, "surge_socks_proxy", None)
|
||||
or os.environ.get("SURGE_SOCKS_PROXY")
|
||||
or "http://127.0.0.1:6152"
|
||||
)
|
||||
proxy_mgr = SurgeRuntime(
|
||||
config=SurgeConfig(
|
||||
api_url=surge_api,
|
||||
api_key=surge_key_str,
|
||||
group_name=getattr(args, "surge_group", None) or os.environ.get("SURGE_GROUP", "Z-Library"),
|
||||
proxy_url=surge_proxy_url,
|
||||
),
|
||||
verbose=args.verbose,
|
||||
)
|
||||
explicit_proxy = None
|
||||
else:
|
||||
proxy_mgr = ProxyManager(
|
||||
proxy_file=proxy_file, verbose=args.verbose
|
||||
)
|
||||
|
||||
if proxy_mgr.has_proxy_pool() and not proxy_mgr.ensure_active():
|
||||
print("错误: 代理池不可用,无法启动任何代理", file=sys.stderr)
|
||||
print("错误: 代理不可用,无法初始化当前代理运行时", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
download_results: List[DownloadResult] = []
|
||||
@@ -1278,7 +1502,7 @@ def _download_one(
|
||||
author: str,
|
||||
fmt: str,
|
||||
output_dir: Path,
|
||||
proxy_mgr: ProxyManager,
|
||||
proxy_mgr: Any,
|
||||
explicit_proxy: Optional[str],
|
||||
verbose: bool,
|
||||
) -> DownloadResult:
|
||||
@@ -1432,6 +1656,9 @@ def main():
|
||||
|
||||
# 使用代理下载
|
||||
%(prog)s download --title "Project Hail Mary" --author "Andy Weir" --proxy-file proxies.txt
|
||||
|
||||
# 使用 Surge 策略组轮换下载
|
||||
%(prog)s download --title "Project Hail Mary" --author "Andy Weir" --surge-api http://127.0.0.1:6171
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -1464,7 +1691,13 @@ def main():
|
||||
dl_p.add_argument("--output-dir", "-o", default="./downloads", help="下载目录 (默认 ./downloads)")
|
||||
dl_p.add_argument("--proxy-file", "-p", default=None, help="代理列表文件")
|
||||
dl_p.add_argument("--proxy", default=None, help="单个代理 URL (socks5://host:port)")
|
||||
dl_p.add_argument("--surge-api", default=None, help="Surge HTTP API 地址 (如 http://127.0.0.1:6171)")
|
||||
dl_p.add_argument("--surge-key", default=None, help="Surge HTTP API Key,也可用环境变量 SURGE_API_KEY")
|
||||
dl_p.add_argument("--surge-group", default=None, help="Surge select 组名 (默认读取 .env 或 Z-Library)")
|
||||
dl_p.add_argument("--surge-http-proxy", default=None, help="Surge 本地 HTTP 代理 (默认读取 .env 或 http://127.0.0.1:6152)")
|
||||
dl_p.add_argument("--surge-socks-proxy", default=None, help="Surge 本地 SOCKS5 代理 (如 socks5://127.0.0.1:6153)")
|
||||
|
||||
load_dotenv()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
|
||||
Reference in New Issue
Block a user