Compare commits
2
Commits
97c48995b1
...
v0.02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7a6bed0d | ||
|
|
97fff0b30e |
@@ -0,0 +1,4 @@
|
|||||||
|
downloads/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
- ✅ **自动绕过反爬保护**:自动解决网站的 SHA1 工作量证明(PoW)挑战
|
- ✅ **自动绕过反爬保护**:自动解决网站的 SHA1 工作量证明(PoW)挑战
|
||||||
- ✅ **智能搜索匹配**:支持书名、作者、ISBN 等多维度匹配
|
- ✅ **智能搜索匹配**:支持书名、作者、ISBN 等多维度匹配
|
||||||
- ✅ **格式优先级**:自动选择 epub + pdf 版本下载
|
- ✅ **格式优先级**:自动选择 epub + pdf 版本下载
|
||||||
- ✅ **代理轮询**:支持 Shadowsocks(Surge 格式)代理列表,每 IP 5 本后自动切换
|
- ✅ **代理轮询**:支持 Shadowsocks(Surge 格式)代理列表,或直接通过 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.
@@ -1,5 +0,0 @@
|
|||||||
📡 已加载 56 个代理
|
|
||||||
🔗 已启动代理隧道: 🇭🇰 香港 01 -> 127.0.0.1:10800
|
|
||||||
⏳ 下载遇到 PoW 挑战,正在解决...
|
|
||||||
✅ 挑战已解决 (耗时 0.024s,节点 zh.ngd101.ru)
|
|
||||||
下载返回了 HTML 页面(可能是限制或错误)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Proxy IP: 61.171.248.193
|
|
||||||
Index status: 503
|
|
||||||
Index status after PoW: 503
|
|
||||||
Search status: 503
|
|
||||||
-88
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"))
|
|
||||||
@@ -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]}")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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))
|
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
⏳ 下载遇到 PoW 挑战,正在解决...
|
|
||||||
✅ 挑战已解决 (耗时 0.003s,节点 zlib.li)
|
|
||||||
Could not find /dl/ link in HTML!
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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!")
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user