Compare commits

2 Commits
24 changed files with 513 additions and 605 deletions
+4
View File
@@ -0,0 +1,4 @@
downloads/
__pycache__/
*.pyc
.env
+25 -1
View File
@@ -7,7 +7,7 @@
-**自动绕过反爬保护**:自动解决网站的 SHA1 工作量证明(PoW)挑战 -**自动绕过反爬保护**:自动解决网站的 SHA1 工作量证明(PoW)挑战
-**智能搜索匹配**:支持书名、作者、ISBN 等多维度匹配 -**智能搜索匹配**:支持书名、作者、ISBN 等多维度匹配
-**格式优先级**:自动选择 epub + pdf 版本下载 -**格式优先级**:自动选择 epub + pdf 版本下载
-**代理轮询**:支持 ShadowsocksSurge 格式)代理列表,每 IP 5 本后自动切换 -**代理轮询**:支持 ShadowsocksSurge 格式)代理列表,或直接通过 Surge `select` 组切换出口
-**AI 友好**:JSON 输出,结构化搜索结果,适合 AI Agent 调用 -**AI 友好**:JSON 输出,结构化搜索结果,适合 AI Agent 调用
## 安装 ## 安装
@@ -23,6 +23,8 @@ pip install curl_cffi beautifulsoup4 lxml
脚本内置自动代理引擎配置。如果使用 Shadowsocks 代理列表(如 Surge 格式),脚本**会在首次运行时自动将对应平台架构的 `mihomo` 引擎打包进执行环境中**,完全脱离 Host 依赖,无需手动通过 brew 安装任何插件。 脚本内置自动代理引擎配置。如果使用 Shadowsocks 代理列表(如 Surge 格式),脚本**会在首次运行时自动将对应平台架构的 `mihomo` 引擎打包进执行环境中**,完全脱离 Host 依赖,无需手动通过 brew 安装任何插件。
如果你已经在 macOS 上使用 Surge,并且将 zlib 域名路由到一个专门的 `select` 组,也可以直接使用 Surge HTTP API 轮换该组,无需再让脚本自行启动 `mihomo`
## 使用方法 ## 使用方法
### 搜索图书 ### 搜索图书
@@ -55,6 +57,9 @@ python3 zlib_dl.py download --title "Project Hail Mary" --author "Andy Weir" -o
# 使用代理列表 # 使用代理列表
python3 zlib_dl.py download --title "Project Hail Mary" --author "Andy Weir" --proxy-file proxies.txt python3 zlib_dl.py download --title "Project Hail Mary" --author "Andy Weir" --proxy-file proxies.txt
# 使用 Surge 的 Z-Library 策略组轮换出口(推荐先配置 .env)
python3 zlib_dl.py download --title "Project Hail Mary" --author "Andy Weir"
``` ```
### 详细模式 ### 详细模式
@@ -91,6 +96,25 @@ http://127.0.0.1:8080
- 达到上限后自动停止当前代理进程,启动下一个 - 达到上限后自动停止当前代理进程,启动下一个
- SS 代理会自动启动 `mihomo` 进程创建本地隧道,无需宿主机环境准备 - SS 代理会自动启动 `mihomo` 进程创建本地隧道,无需宿主机环境准备
### Surge 策略组模式
- 为 Surge 打开 HTTP API,例如:`http-api = <key>@127.0.0.1:6171`
- 将 zlib 相关域名规则指向一个 `select` 组,例如 `Z-Library`
- 脚本会默认通过 `http://127.0.0.1:6152` 访问 Surge 本地 HTTP 代理
- 当命中 IP 限额或下载失败时,脚本会调用 Surge API,把 `Z-Library` 切到下一个策略
在项目根目录创建 `.env`
```env
SURGE_API_URL=http://127.0.0.1:6171
SURGE_API_KEY=6f8d2c1a9b4e7f35c0d2a8e1b7f94c63
SURGE_GROUP=Z-Library
SURGE_HTTP_PROXY=http://127.0.0.1:6152
SURGE_SOCKS_PROXY=socks5://127.0.0.1:6153
```
CLI 参数优先级高于 `.env`;如果不传 `--surge-api`,但 `.env` 里存在 `SURGE_API_URL`,下载命令会自动进入 Surge 模式。
## AI Agent 集成 ## AI Agent 集成
### JSON 搜索结果结构 ### JSON 搜索结果结构
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
📡 已加载 56 个代理
🔗 已启动代理隧道: 🇭🇰 香港 01 -> 127.0.0.1:10800
⏳ 下载遇到 PoW 挑战,正在解决...
✅ 挑战已解决 (耗时 0.024s,节点 zh.ngd101.ru)
下载返回了 HTML 页面(可能是限制或错误)
-4
View File
@@ -1,4 +0,0 @@
Proxy IP: 61.171.248.193
Index status: 503
Index status after PoW: 503
Search status: 503
-88
View File
@@ -1,88 +0,0 @@
import urllib.request, time, hashlib, re, os, subprocess
from curl_cffi import requests
def solve_challenge(html):
m = re.search(r"\['([A-F0-9]{40})'", html)
if not m: return None, None
c = m.group(1)
n1 = int(c[0], 16)
i = 0
t0 = time.time()
while True:
d = hashlib.sha1((c + str(i)).encode()).digest()
if d[n1] == 0xb0 and d[n1+1] == 0x0b: break
i += 1
return c + str(i), f"{time.time() - t0:.3f}"
yaml_config = """
mode: global
mixed-port: 10899
bind-address: '127.0.0.1'
proxies:
"""
proxy_str = ""
with open("proxies.txt") as f:
for line in f:
if "ss," in line:
proxy_str = line.strip()
break
parts = proxy_str.split("=", 1)
cfg = parts[1].strip().split(",")
params = dict(p.strip().split("=", 1) for p in cfg[3:] if "=" in p)
yaml_config += f""" - name: "test"
type: ss
server: "{cfg[1].strip()}"
port: {cfg[2].strip()}
cipher: "{params.get('encrypt-method', '')}"
password: "{params.get('password', '')}"
"""
if "obfs" in params:
yaml_config += f" plugin: obfs\n plugin-opts:\n mode: {params['obfs']}\n host: {params['obfs-host']}\n"
with open("test_503.yaml", "w") as f: f.write(yaml_config)
proc = subprocess.Popen(["./.venv/bin/mihomo", "-f", "test_503.yaml"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
s = requests.Session()
p = {"http": "socks5://127.0.0.1:10899", "https": "socks5://127.0.0.1:10899"}
try:
with open("test_503.log", "w") as out:
ip_resp = s.get("http://httpbin.org/ip", proxies=p, impersonate="chrome", timeout=5)
out.write(f"Proxy IP: {ip_resp.json()['origin']}\n")
r1 = s.get("https://zlib.li/", proxies=p, impersonate="chrome", timeout=10)
out.write(f"Index status: {r1.status_code}\n")
if r1.status_code == 503 and "Checking your browser" in r1.text:
ct, cti = solve_challenge(r1.text)
if ct:
s.cookies.set("c_token", ct, domain="zlib.li", path="/")
s.cookies.set("c_time", cti, domain="zlib.li", path="/")
r1 = s.get("https://zlib.li/", proxies=p, impersonate="chrome", timeout=10)
out.write(f"Index status after PoW: {r1.status_code}\n")
r2 = s.get("https://zlib.li/s/Project", proxies=p, impersonate="chrome", timeout=10)
out.write(f"Search status: {r2.status_code}\n")
from bs4 import BeautifulSoup
soup = BeautifulSoup(r2.text, "lxml")
card = soup.find("z-bookcard")
if card and card.get("download"):
dl = card.get("download")
out.write(f"Got dl path: {dl}\n")
r3 = s.get("https://zlib.li" + dl, proxies=p, impersonate="chrome", allow_redirects=True, timeout=10)
out.write(f"Download status: {r3.status_code}\n")
if r3.status_code == 503:
if "Checking your browser" in r3.text:
ct, cti = solve_challenge(r3.text)
if ct:
s.cookies.set("c_token", ct, domain="zlib.li", path="/")
s.cookies.set("c_time", cti, domain="zlib.li", path="/")
r4 = s.get("https://zlib.li" + dl, proxies=p, impersonate="chrome", allow_redirects=True, timeout=10)
out.write(f"Retry status: {r4.status_code}\n")
with open("test_503_body.html", "w") as f2: f2.write(r4.text)
else:
out.write("Not PoW 503\n")
finally:
proc.terminate()
-23
View File
@@ -1,23 +0,0 @@
mode: rule
mixed-port: 10899
bind-address: '127.0.0.1'
proxies:
- name: "test"
type: ss
server: "d32b6cd.gylcxo.xyz"
port: 18300
cipher: "aes-128-gcm"
password: "ced24190439d1ed1"
plugin: obfs
plugin-opts:
mode: http
host: Sm0lO349ch.microsoft.com
proxy-groups:
- name: "Proxy"
type: select
proxies:
- "test"
rules:
- MATCH, Proxy
-61
View File
@@ -1,61 +0,0 @@
import urllib.request, time, hashlib, re, os, subprocess
from curl_cffi import requests
def solve_challenge(html):
m = re.search(r"\['([A-F0-9]{40})'", html)
if not m: return None, None
c = m.group(1)
n1 = int(c[0], 16)
i = 0
t0 = time.time()
while True:
d = hashlib.sha1((c + str(i)).encode()).digest()
if d[n1] == 0xb0 and d[n1+1] == 0x0b: break
i += 1
return c + str(i), f"{time.time() - t0:.3f}"
yaml_config = """
mode: global
mixed-port: 10899
bind-address: '127.0.0.1'
proxies:
"""
proxy_str = ""
with open("proxies.txt") as f:
for line in f:
if "ss," in line:
proxy_str = line.strip()
break
parts = proxy_str.split("=", 1)
cfg = parts[1].strip().split(",")
params = dict(p.strip().split("=", 1) for p in cfg[3:] if "=" in p)
yaml_config += f""" - name: "test"
type: ss
server: "{cfg[1].strip()}"
port: {cfg[2].strip()}
cipher: "{params.get('encrypt-method', '')}"
password: "{params.get('password', '')}"
"""
if "obfs" in params:
yaml_config += f" plugin: obfs\n plugin-opts:\n mode: {params['obfs']}\n host: {params['obfs-host']}\n"
with open("test_503.yaml", "w") as f: f.write(yaml_config)
proc = subprocess.Popen(["./.venv/bin/mihomo", "-f", "test_503.yaml"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
s = requests.Session()
p = {"http": "socks5://127.0.0.1:10899", "https": "socks5://127.0.0.1:10899"}
try:
r1 = s.get("https://zlib.li/", proxies=p, impersonate="chrome", timeout=10)
print("Initial:", r1.status_code)
if r1.status_code == 503 and "Checking your browser" in r1.text:
ct, cti = solve_challenge(r1.text)
s.cookies.set("c_token", ct, domain="zlib.li", path="/")
s.cookies.set("c_time", cti, domain="zlib.li", path="/")
r1 = s.get("https://zlib.li/", proxies=p, impersonate="chrome", timeout=10)
print("After PoW:", r1.status_code)
if r1.status_code == 503:
print(r1.text[:500])
finally:
proc.terminate()
-13
View File
@@ -1,13 +0,0 @@
port: 7890
socks-port: 7891
proxies:
- name: "ss-obfs"
type: ss
server: "127.0.0.1"
port: 18300
cipher: "aes-128-gcm"
password: "ced"
plugin: obfs
plugin-opts:
mode: http
host: Sm0lO349ch.microsoft.com
-6
View File
@@ -1,6 +0,0 @@
from curl_cffi import requests
s = requests.Session()
s.cookies.set("1", "A", domain="zlib.li", path="/")
s.cookies.set("2", "B", domain=".zlib.li", path="/")
s.get("http://httpbin.org/cookies", impersonate="chrome")
print(s.cookies)
-7
View File
@@ -1,7 +0,0 @@
from zlib_dl import ProxyManager, ZLibSession
mgr = ProxyManager("proxies.txt", verbose=True)
mgr.rotate()
s = ZLibSession(proxy_url=mgr.get_current_proxy_url(), verbose=True)
res, msg = s.download_file("/dl/22909249/a93325", "test.epub")
print(msg)
import os; os.path.exists("test.epub") and print("Saved epub size:", os.path.getsize("test.epub"))
-16
View File
@@ -1,16 +0,0 @@
from zlib_dl import ZLibSession
import re
s1 = ZLibSession(verbose=True)
resp1 = s1._request_download("https://zlib.li/book/22909249/a93325")
match = re.search(r'href="(/dl/[^"]+)"', resp1.text)
if match:
print("Found exact link:", match.group(1))
else:
print("Could not find /dl/ link in HTML!")
lines = [line.strip() for line in resp1.text.split('\n')]
for i, line in enumerate(lines):
if "download" in line.lower() or "href" in line.lower():
if "/22909249/" in line:
print(f"Possible DL Line [{i}]: {line[:300]}")
-22
View File
@@ -1,22 +0,0 @@
import urllib.request
import platform
system = platform.system().lower()
machine = platform.machine().lower()
is_arm = machine in ("arm64", "aarch64")
if system == "darwin":
arch_str = "arm64" if is_arm else "amd64-compatible"
elif system == "linux":
arch_str = "arm64" if is_arm else "amd64-compatible"
version = "v1.19.21"
url = f"https://github.com/MetaCubeX/mihomo/releases/download/{version}/mihomo-{system}-{arch_str}-{version}.gz"
print("URL:", url)
try:
req = urllib.request.Request(url, method='HEAD')
resp = urllib.request.urlopen(req, timeout=5)
print("Status:", resp.status)
except Exception as e:
print("Error:", e)
-72
View File
@@ -1,72 +0,0 @@
import subprocess
import time
import os
import urllib.request
from curl_cffi import requests
# We will read the first proxy from proxies.txt to really test it
proxy_str = ""
with open("proxies.txt") as f:
for line in f:
if "ss," in line:
proxy_str = line.strip()
break
parts = proxy_str.split("=", 1)
name = parts[0].strip()
cfg = parts[1].strip().split(",")
host = cfg[1].strip()
port = cfg[2].strip()
params = {}
for p in cfg[3:]:
if "=" in p:
k, v = p.split("=", 1)
params[k.strip()] = v.strip()
method = params.get("encrypt-method", "")
password = params.get("password", "")
obfs = params.get("obfs", "")
obfs_host = params.get("obfs-host", "")
def test_mihomo(use_en0):
port_local = 11111 if use_en0 else 11112
interface_cfg = "\n interface-name: en0" if use_en0 else ""
plugin_cfg = f"""
plugin: obfs
plugin-opts:
mode: {obfs}
host: {obfs_host}
""" if obfs else ""
yaml_cfg = f"""
mode: global
mixed-port: {port_local}
bind-address: '127.0.0.1'
dns:
enable: true
nameserver:
- 223.5.5.5
proxies:
- name: "test_proxy"
type: ss
server: "{host}"
port: {port}
cipher: "{method}"
password: "{password}"{interface_cfg}{plugin_cfg}
"""
with open(f"test_cfg_{port_local}.yaml", "w") as f: f.write(yaml_cfg)
proc = subprocess.Popen(["./.venv/bin/mihomo", "-f", f"test_cfg_{port_local}.yaml"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
try:
prox = {"http": f"socks5://127.0.0.1:{port_local}", "https": f"socks5://127.0.0.1:{port_local}"}
resp = requests.get("http://httpbin.org/ip", proxies=prox, impersonate="chrome", timeout=5)
return resp.json()["origin"]
except Exception as e:
return f"Error: {e}"
finally:
proc.terminate()
proc.wait(timeout=2)
os.remove(f"test_cfg_{port_local}.yaml")
print("Without en0:", test_mihomo(False))
print("With en0:", test_mihomo(True))
-46
View File
@@ -1,46 +0,0 @@
import subprocess
import time
import os
import json
import yaml
# 1. 启动 mihomo 并绑定到具体网卡 (en0)
local_port = 10850
cfg = """
mode: global
mixed-port: 10850
bind-address: '127.0.0.1'
dns:
enable: true
nameserver:
- https://223.5.5.5/dns-query
- https://1.1.1.1/dns-query
proxies:
- name: "test-direct"
type: direct
interface-name: en0
"""
with open("test_b.yaml", "w") as f:
f.write(cfg)
# 找到 mihomo
mihomo = "./.venv/bin/mihomo"
if not os.path.exists(mihomo):
mihomo = "mihomo"
proc = subprocess.Popen([mihomo, "-f", "test_b.yaml"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
try:
from curl_cffi import requests
resp = requests.get("http://httpbin.org/ip", proxies={"http": "socks5://127.0.0.1:10850", "https": "socks5://127.0.0.1:10850"}, impersonate="chrome")
print("Mihomo (direct bound to en0) IP:", resp.json()["origin"])
resp_direct = requests.get("http://httpbin.org/ip", impersonate="chrome")
print("System (Surge default) IP:", resp_direct.json()["origin"])
finally:
proc.kill()
os.remove("test_b.yaml")
-52
View File
@@ -1,52 +0,0 @@
import urllib.request, time, os, subprocess
from curl_cffi import requests
yaml_config = """
mode: rule
mixed-port: 10899
bind-address: '127.0.0.1'
proxies:
"""
proxy_str = ""
with open("proxies.txt") as f:
for line in f:
if "ss," in line:
proxy_str = line.strip()
break
parts = proxy_str.split("=", 1)
cfg = parts[1].strip().split(",")
params = dict(p.strip().split("=", 1) for p in cfg[3:] if "=" in p)
yaml_config += f""" - name: "test"
type: ss
server: "{cfg[1].strip()}"
port: {cfg[2].strip()}
cipher: "{params.get('encrypt-method', '')}"
password: "{params.get('password', '')}"
"""
if "obfs" in params:
yaml_config += f" plugin: obfs\n plugin-opts:\n mode: {params['obfs']}\n host: {params['obfs-host']}\n"
yaml_config += """
proxy-groups:
- name: "Proxy"
type: select
proxies:
- "test"
rules:
- MATCH, Proxy
"""
with open("test_503.yaml", "w") as f: f.write(yaml_config)
proc = subprocess.Popen(["./.venv/bin/mihomo", "-f", "test_503.yaml"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
s = requests.Session()
p = {"http": "socks5://127.0.0.1:10899", "https": "socks5://127.0.0.1:10899"}
try:
ip_resp = s.get("http://httpbin.org/ip", proxies=p, impersonate="chrome", timeout=5)
print("Proxy IP:", ip_resp.json()["origin"])
r1 = s.get("https://zlib.li/", proxies=p, impersonate="chrome", timeout=10)
print("Index status:", r1.status_code)
finally:
proc.terminate()
-42
View File
@@ -1,42 +0,0 @@
import platform, os, gzip, urllib.request, json
import yaml
def gen_config(proxy, local_port):
cfg = {
"port": 0,
"socks-port": local_port,
"mode": "Global",
"proxies": [{
"name": proxy["name"],
"type": "ss",
"server": proxy["host"],
"port": proxy["port"],
"cipher": proxy["method"],
"password": proxy["password"],
"plugin": "obfs",
"plugin-opts": {
"mode": proxy["obfs"],
"host": proxy["obfs_host"]
}
}],
"proxy-groups": [{
"name": "Proxy",
"type": "select",
"proxies": [proxy["name"]]
}],
"rules": [
"MATCH,Proxy"
]
}
return yaml.dump(cfg)
proxy_test = {
"name": "HK 01",
"host": "d32b6cd.gylcxo.xyz",
"port": 18300,
"method": "aes-128-gcm",
"password": "ced",
"obfs": "http",
"obfs_host": "Sm0lO349ch.microsoft.com"
}
print(gen_config(proxy_test, 10800))
-19
View File
@@ -1,19 +0,0 @@
import urllib.request
import json
from bs4 import BeautifulSoup
def get_ip(proxy_url=None):
import subprocess
import sys
try:
from curl_cffi import requests
session = requests.Session()
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
resp = session.get("http://httpbin.org/ip", proxies=proxies, impersonate="chrome")
return resp.json()["origin"]
except Exception as e:
return f"Error: {e}"
print("Direct IP:", get_ip())
-3
View File
@@ -1,3 +0,0 @@
⏳ 下载遇到 PoW 挑战,正在解决...
✅ 挑战已解决 (耗时 0.003s,节点 zlib.li)
Could not find /dl/ link in HTML!
-10
View File
@@ -1,10 +0,0 @@
from zlib_dl import ZLibSession
s = ZLibSession(verbose=True)
resp = s._request_download("https://zlib.li/s/%E6%B5%AA%E6%BD%AE%E4%B9%8B%E5%B7%85")
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
cards = soup.find_all("z-bookcard")
if cards:
print(cards[0].prettify())
else:
print("No cards found")
-13
View File
@@ -1,13 +0,0 @@
from zlib_dl import ZLibSession
import re
url = "https://zlib.li/book/mDJxkg7yO6/%E6%B5%AA%E6%BD%AE%E4%B9%8B%E5%B7%85%E7%AC%AC%E5%9B%9B%E7%89%88.html"
s = ZLibSession(verbose=True, proxy_url="socks5://127.0.0.1:10800")
print("Requesting clean detail URL:", url)
resp = s._request_download(url)
print("FINAL URL:", resp.url)
print("HTML Title:", re.search(r'<title>(.*?)</title>', resp.text).group(1))
match = re.search(r'href="(/dl/[^"]+)"', resp.text)
if match:
print("DOWNLOAD LINK FOUND:", match.group(1))
else:
print("NO DOWNLOAD LINK FOUND")
-21
View File
@@ -1,21 +0,0 @@
from zlib_dl import ZLibSession
s = ZLibSession(verbose=True, proxy_url="socks5://127.0.0.1:10800")
# Hit the detail page manually
resp = s._request_download("https://zlib.li/book/mDJxkg7yO6/%E6%B5%AA%E6%BD%AE%E4%B9%8B%E5%B7%85%E7%AC%AC%E5%9B%9B%E7%89%88.html")
print(f"STATUS CODE: {resp.status_code}")
print(f"FINAL URL: {resp.url}")
content = resp.text
print(f"CONTENT LENGTH: {len(content)}")
if '/dl/' in content:
print("FOUND /dl/ literally in content!")
else:
print("NO /dl/ FOUND!")
import re
links = re.findall(r'href="(.*?)"', content)
print("Some links found on page:")
for l in [x for x in links if 'book' in x or 'dl' in x or 'download' in x][:10]:
print(" - ", l)
if "daily limit" in content.lower() or "limit reached" in content.lower() or "已达下载" in content:
print("HIT RATE LIMIT MESSAGE ON DETAIL PAGE!")
-9
View File
@@ -1,9 +0,0 @@
from zlib_dl import ZLibSession
s = ZLibSession()
results = s.search("浪潮之巅")
if results:
for i, r in enumerate(results[:3]):
print(f"Item {i}:")
print(f" title: {r.title}")
print(f" page_url: {r.page_url}")
+484 -72
View File
@@ -24,6 +24,8 @@ import sys
import time import time
import unicodedata import unicodedata
import urllib.request import urllib.request
import urllib.parse
import urllib.error
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple from typing import Optional, List, Dict, Any, Tuple
@@ -139,6 +141,14 @@ class ProxyConfig:
_process: Any = field(default=None, repr=False) _process: Any = field(default=None, repr=False)
@dataclass
class SurgeConfig:
api_url: str
api_key: str
group_name: str
proxy_url: str
# ============================================================ # ============================================================
# PoW 挑战解决器 # PoW 挑战解决器
# ============================================================ # ============================================================
@@ -178,6 +188,26 @@ class ChallengeSolver:
return None 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 会话管理 # ZLib 会话管理
# ============================================================ # ============================================================
@@ -198,6 +228,39 @@ class ZLibSession:
return {"https": self.proxy_url, "http": self.proxy_url} return {"https": self.proxy_url, "http": self.proxy_url}
return None return None
def _debug_log_exit_ip(self, stage: str):
"""在 verbose 模式下打印当前会话出口 IP,便于核对代理是否一致"""
if not self.verbose:
return
proxies = self._get_proxies()
probe_targets = [
("http://httpbin.org/ip", lambda data: data.get("origin", "unknown")),
("http://ip-api.com/json", lambda data: data.get("query", "unknown")),
("https://api.ipify.org?format=json", lambda data: data.get("ip", "unknown")),
]
proxy_desc = self.proxy_url if self.proxy_url else "直连"
last_error = None
for probe_url, parser in probe_targets:
try:
resp = self.session.get(
probe_url,
impersonate="chrome",
proxies=proxies,
timeout=15,
)
ip = parser(resp.json())
print(
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)
def _request(self, url: str, **kwargs) -> cffi_requests.Response: def _request(self, url: str, **kwargs) -> cffi_requests.Response:
"""发起请求,自动处理 PoW 挑战""" """发起请求,自动处理 PoW 挑战"""
proxies = self._get_proxies() proxies = self._get_proxies()
@@ -231,6 +294,7 @@ class ZLibSession:
def search(self, query: str, max_results: int = 50) -> List[BookResult]: def search(self, query: str, max_results: int = 50) -> List[BookResult]:
"""搜索图书,返回结果列表""" """搜索图书,返回结果列表"""
self._debug_log_exit_ip("search")
url = f"{self.BASE_URL}/s/?q={query}" url = f"{self.BASE_URL}/s/?q={query}"
resp = self._request(url) resp = self._request(url)
@@ -271,8 +335,9 @@ class ZLibSession:
def _request_download(self, url: str, **kwargs) -> cffi_requests.Response: def _request_download(self, url: str, **kwargs) -> cffi_requests.Response:
"""发起下载请求,自动处理 PoW 挑战(支持 allow_redirects""" """发起下载请求,自动处理 PoW 挑战(支持 allow_redirects"""
proxies = self._get_proxies() proxies = self._get_proxies()
timeout = kwargs.pop("timeout", (20, 90))
resp = self.session.get( resp = self.session.get(
url, impersonate="chrome", proxies=proxies, timeout=300, url, impersonate="chrome", proxies=proxies, timeout=timeout,
allow_redirects=True, **kwargs allow_redirects=True, **kwargs
) )
@@ -303,7 +368,7 @@ class ZLibSession:
# 无论什么页面,PoW 是在哪个域名的哪个具体 URL 上发生的,就全盘用带着 Cookie 的新状态重新向该域名发出最初的请求 # 无论什么页面,PoW 是在哪个域名的哪个具体 URL 上发生的,就全盘用带着 Cookie 的新状态重新向该域名发出最初的请求
resp = self.session.get( resp = self.session.get(
resp.url, impersonate="chrome", proxies=proxies, timeout=300, resp.url, impersonate="chrome", proxies=proxies, timeout=timeout,
allow_redirects=True, **kwargs allow_redirects=True, **kwargs
) )
@@ -327,6 +392,7 @@ class ZLibSession:
if detail_url: if detail_url:
if self.verbose: if self.verbose:
print(f" 🔗 正在预热书籍详情页以注册当前代理指纹和获取新 Session...", file=sys.stderr) print(f" 🔗 正在预热书籍详情页以注册当前代理指纹和获取新 Session...", file=sys.stderr)
self._debug_log_exit_ip("warmup")
try: try:
dt_resp = self._request_download(detail_url, headers={"Referer": self.BASE_URL + "/"}) dt_resp = self._request_download(detail_url, headers={"Referer": self.BASE_URL + "/"})
import re import re
@@ -348,7 +414,13 @@ class ZLibSession:
return False, f"预热书籍详情页或解析出错: {e}" return False, f"预热书籍详情页或解析出错: {e}"
try: try:
resp = self._request_download(url, headers={"Referer": detail_url} if detail_url else {}) self._debug_log_exit_ip("download")
resp = self._request_download(
url,
headers={"Referer": detail_url} if detail_url else {},
stream=True,
timeout=(20, 120),
)
except Exception as e: except Exception as e:
return False, f"下载请求失败: {e}" return False, f"下载请求失败: {e}"
@@ -376,18 +448,240 @@ class ZLibSession:
else: else:
return False, "下载失败,服务端依然返回了 HTML 重定向网页!" return False, "下载失败,服务端依然返回了 HTML 重定向网页!"
# 写入文件 # 写入文件(流式下载:只要持续有数据就继续)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "wb") as f: tmp_path = output_path + ".part"
f.write(resp.content) total_bytes = 0
return True, f"下载完成: {len(resp.content)} bytes" try:
content_length = resp.headers.get("Content-Length", "").strip()
expected_total = int(content_length) if content_length.isdigit() else 0
if self.verbose:
if expected_total > 0:
print(
f" 📦 服务器文件大小: {expected_total / (1024 * 1024):.2f} MB",
file=sys.stderr,
)
else:
print(" 📦 服务器未返回文件大小(Content-Length", file=sys.stderr)
started_at = time.time()
last_log_at = started_at
with open(tmp_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=262144):
if not chunk:
continue
f.write(chunk)
total_bytes += len(chunk)
now = time.time()
if self.verbose and now - last_log_at >= 3:
elapsed = max(0.001, now - started_at)
speed = total_bytes / elapsed
if expected_total > 0:
pct = (total_bytes / expected_total) * 100
print(
f" ⏬ 已下载 {total_bytes / (1024 * 1024):.2f} / {expected_total / (1024 * 1024):.2f} MB ({pct:.1f}%), 速度 {speed / (1024 * 1024):.2f} MB/s",
file=sys.stderr,
)
else:
print(
f" ⏬ 已下载 {total_bytes / (1024 * 1024):.2f} MB, 速度 {speed / (1024 * 1024):.2f} MB/s",
file=sys.stderr,
)
last_log_at = now
os.replace(tmp_path, output_path)
if expected_total > 0:
return True, f"下载完成: {total_bytes} bytes / 期望 {expected_total} bytes"
return True, f"下载完成: {total_bytes} bytes"
except Exception as e:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
return False, f"下载流中断: {e}"
finally:
try:
resp.close()
except Exception:
pass
# ============================================================ # ============================================================
# 代理管理器 # 代理管理器
# ============================================================ # ============================================================
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: class ProxyManager:
"""管理代理列表和轮询""" """管理代理列表和轮询"""
@@ -589,6 +883,12 @@ proxies:
port: {proxy.port} port: {proxy.port}
cipher: "{proxy.method}" cipher: "{proxy.method}"
password: "{proxy.password}"{plugin_yaml}{interface_yaml} password: "{proxy.password}"{plugin_yaml}{interface_yaml}
proxy-groups:
- name: GLOBAL
type: select
proxies:
- "{proxy.name}"
""" """
with open(cfg_path, 'w', encoding='utf-8') as f: with open(cfg_path, 'w', encoding='utf-8') as f:
f.write(yaml_config) f.write(yaml_config)
@@ -679,6 +979,18 @@ proxies:
proxy = self.proxies[self.current_index] proxy = self.proxies[self.current_index]
return proxy.downloads_count >= self.MAX_DOWNLOADS_PER_PROXY return proxy.downloads_count >= self.MAX_DOWNLOADS_PER_PROXY
def has_proxy_pool(self) -> bool:
"""是否配置了可轮换的代理池"""
return len(self.proxies) > 0
def ensure_active(self) -> bool:
"""确保当前有活跃代理(有代理池时)"""
if not self.proxies:
return True
if 0 <= self.current_index < len(self.proxies):
return True
return self.rotate()
def rotate(self) -> bool: def rotate(self) -> bool:
""" """
轮换到下一个代理。 轮换到下一个代理。
@@ -991,15 +1303,79 @@ def sanitize_filename(name: str) -> str:
return name.strip()[:200] return name.strip()[:200]
def _should_rotate_proxy(message: str) -> bool:
"""根据错误信息判断是否应该切换代理重试"""
msg_lower = message.lower()
return (
"" in message
or "limit" in msg_lower
or "429" in message
or "503" in message
or "504" in message
or "403" in message
or "502" in message
or "timeout" in msg_lower
or "请求失败" in message
or "html" in msg_lower
)
def _create_session_for_current_proxy(
proxy_mgr: Any,
explicit_proxy: Optional[str],
verbose: bool,
) -> ZLibSession:
"""根据当前代理状态创建新的 ZLibSession"""
proxy_url = explicit_proxy if explicit_proxy else proxy_mgr.get_current_proxy_url()
return ZLibSession(proxy_url=proxy_url, verbose=verbose)
def cmd_download(args): def cmd_download(args):
"""执行下载命令""" """执行下载命令"""
explicit_proxy = getattr(args, "proxy", None)
proxy_file = getattr(args, "proxy_file", None)
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 = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
# 代理管理器 if use_surge:
proxy_mgr = ProxyManager( surge_key_str = str(surge_key)
proxy_file=getattr(args, "proxy_file", None), verbose=args.verbose 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)
sys.exit(1)
download_results: List[DownloadResult] = [] download_results: List[DownloadResult] = []
@@ -1014,11 +1390,13 @@ def cmd_download(args):
for dl_path in dl_paths: for dl_path in dl_paths:
result = _download_one( result = _download_one(
dl_path=dl_path, dl_path=dl_path,
page_url="",
title=title, title=title,
author=author, author=author,
fmt=fmt or "", fmt=fmt or "",
output_dir=output_dir, output_dir=output_dir,
proxy_mgr=proxy_mgr, proxy_mgr=proxy_mgr,
explicit_proxy=explicit_proxy,
verbose=args.verbose, verbose=args.verbose,
) )
download_results.append(result) download_results.append(result)
@@ -1037,8 +1415,11 @@ def cmd_download(args):
print(f" 🔍 搜索: {query_str}", file=sys.stderr) print(f" 🔍 搜索: {query_str}", file=sys.stderr)
# 搜索 # 搜索
proxy_url = proxy_mgr.get_current_proxy_url() session = _create_session_for_current_proxy(
session = ZLibSession(proxy_url=proxy_url, verbose=args.verbose) proxy_mgr=proxy_mgr,
explicit_proxy=explicit_proxy,
verbose=args.verbose,
)
results = session.search(query_str) results = session.search(query_str)
if not results: if not results:
@@ -1066,6 +1447,7 @@ def cmd_download(args):
fmt=book.format, fmt=book.format,
output_dir=output_dir, output_dir=output_dir,
proxy_mgr=proxy_mgr, proxy_mgr=proxy_mgr,
explicit_proxy=explicit_proxy,
verbose=args.verbose, verbose=args.verbose,
) )
download_results.append(result) download_results.append(result)
@@ -1082,6 +1464,7 @@ def cmd_download(args):
fmt=book.format, fmt=book.format,
output_dir=output_dir, output_dir=output_dir,
proxy_mgr=proxy_mgr, proxy_mgr=proxy_mgr,
explicit_proxy=explicit_proxy,
verbose=args.verbose, verbose=args.verbose,
) )
download_results.append(result) download_results.append(result)
@@ -1119,7 +1502,8 @@ def _download_one(
author: str, author: str,
fmt: str, fmt: str,
output_dir: Path, output_dir: Path,
proxy_mgr: ProxyManager, proxy_mgr: Any,
explicit_proxy: Optional[str],
verbose: bool, verbose: bool,
) -> DownloadResult: ) -> DownloadResult:
"""下载单个文件,带代理轮询逻辑""" """下载单个文件,带代理轮询逻辑"""
@@ -1147,8 +1531,77 @@ def _download_one(
message="文件已存在,跳过", message="文件已存在,跳过",
) )
# 检查代理轮换 uses_proxy_pool = proxy_mgr.has_proxy_pool()
if proxy_mgr.should_rotate():
# 进入下载前,若当前代理已达配额,先轮换
if uses_proxy_pool and proxy_mgr.should_rotate() and not proxy_mgr.rotate():
return DownloadResult(
status="error",
title=title,
author=author,
format=fmt,
filesize="",
filepath="",
message="所有代理已用完,且当前代理下载次数已达上限",
)
while True:
if uses_proxy_pool and not proxy_mgr.ensure_active():
return DownloadResult(
status="error",
title=title,
author=author,
format=fmt,
filesize="",
filepath="",
message="所有代理已用完",
)
if uses_proxy_pool:
proxy_name = proxy_mgr.get_current_proxy_name()
elif explicit_proxy:
proxy_name = explicit_proxy
else:
proxy_name = "直连"
if verbose:
print(f" ⬇️ 下载: {filename} (代理: {proxy_name})", file=sys.stderr)
# 每次下载尝试都创建全新 Session;切代理后会自动带新出口重建会话
session = _create_session_for_current_proxy(
proxy_mgr=proxy_mgr,
explicit_proxy=explicit_proxy,
verbose=verbose,
)
success, message = session.download_file(dl_path, page_url, output_path)
if success:
if uses_proxy_pool:
proxy_mgr.record_download()
filesize = os.path.getsize(output_path)
size_str = _format_size(filesize)
return DownloadResult(
status="success",
title=title,
author=author,
format=fmt,
filesize=size_str,
filepath=output_path,
message=f"下载成功 ({size_str})",
proxy_used=proxy_name,
)
if not uses_proxy_pool or not _should_rotate_proxy(message):
return DownloadResult(
status="error",
title=title,
author=author,
format=fmt,
filesize="",
filepath="",
message=message,
)
if not proxy_mgr.rotate(): if not proxy_mgr.rotate():
return DownloadResult( return DownloadResult(
status="error", status="error",
@@ -1157,64 +1610,14 @@ def _download_one(
format=fmt, format=fmt,
filesize="", filesize="",
filepath="", filepath="",
message="所有代理已用完,且当前代理下载次数已达上限", message=f"{message};且所有代理已用完",
) )
proxy_url = proxy_mgr.get_current_proxy_url() if verbose:
proxy_name = proxy_mgr.get_current_proxy_name() print(
f" 🔄 下载受阻 ({message}),切换代理重试: {proxy_mgr.get_current_proxy_name()}",
if verbose: file=sys.stderr,
print( )
f" ⬇️ 下载: {filename} (代理: {proxy_name})", file=sys.stderr
)
# 完全创建新 Session,废弃之前的旧 Session 以获取全新的 CDN 下载授权
session = ZLibSession(proxy_url=proxy_url, verbose=verbose)
success, message = session.download_file(dl_path, page_url, output_path)
if success:
proxy_mgr.record_download()
filesize = os.path.getsize(output_path)
size_str = format_size(filesize)
return DownloadResult(
status="success",
title=title,
author=author,
format=fmt,
filesize=size_str,
filepath=output_path,
message=f"下载成功 ({size_str})",
proxy_used=proxy_name,
)
else:
# 如果是限制错误(达到配额)或遇到 503/403 等代理被屏蔽导致的无限验证循环,尝试切换代理重试
msg_lower = message.lower()
should_retry = (
"" in message or "limit" in msg_lower or
"503" in message or "403" in message or "502" in message or
"timeout" in msg_lower or "请求失败" in message or "html" in msg_lower
)
if should_retry:
if proxy_mgr.rotate():
if verbose:
print(
f" 🔄 下载受阻 ({message}),切换代理重试: {proxy_mgr.get_current_proxy_name()}",
file=sys.stderr,
)
return _download_one(
dl_path, page_url, title, author, fmt, output_dir, proxy_mgr, verbose
)
return DownloadResult(
status="error",
title=title,
author=author,
format=fmt,
filesize="",
filepath="",
message=message,
)
def _format_size(size_bytes: int) -> str: def _format_size(size_bytes: int) -> str:
"""格式化文件大小""" """格式化文件大小"""
@@ -1253,6 +1656,9 @@ def main():
# 使用代理下载 # 使用代理下载
%(prog)s download --title "Project Hail Mary" --author "Andy Weir" --proxy-file proxies.txt %(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
""", """,
) )
@@ -1285,7 +1691,13 @@ def main():
dl_p.add_argument("--output-dir", "-o", default="./downloads", help="下载目录 (默认 ./downloads)") 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-file", "-p", default=None, help="代理列表文件")
dl_p.add_argument("--proxy", default=None, help="单个代理 URL (socks5://host:port)") 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() args = parser.parse_args()
if not args.command: if not args.command: