- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""
|
||
CloudSearch Transfer — 迅雷网盘凭证管理器 v1.0.0
|
||
|
||
迅雷网盘使用 refresh_token + captcha_token 双重认证机制:
|
||
|
||
1. refresh_token → access_token (OAuth)
|
||
POST https://xluser-ssl.xunlei.com/v1/auth/token
|
||
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
|
||
|
||
2. captcha_token 获取(某些操作需要)
|
||
POST /v1/shield/captcha/init
|
||
Body: {"client_id": "...", "action": "...", "device_id": "...", "meta": {"captcha_sign": "..."}}
|
||
|
||
3. get_headers() 返回所有需要的认证头:
|
||
Authorization: Bearer <access_token>
|
||
x-captcha-token: <captcha_token>
|
||
x-client-id: <client_id>
|
||
x-device-id: <device_id>
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
import threading
|
||
from typing import Dict, Optional
|
||
|
||
import requests
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ─── 常量 ───────────────────────────────────────────────────────────
|
||
# 迅雷网盘 OAuth 认证端点
|
||
XUNLEI_AUTH_API = "https://xluser-ssl.xunlei.com"
|
||
|
||
# 迅雷网盘客户端标识(固定值)
|
||
CLIENT_ID = "Xqp0kJBXWhwaTpB6"
|
||
DEVICE_ID = "925b7631473a13716b791d7f28289cad"
|
||
|
||
# ─── 默认请求头 ─────────────────────────────────────────────────────
|
||
DEFAULT_HEADERS: Dict[str, str] = {
|
||
"User-Agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/135.0.0.0 Safari/537.36"
|
||
),
|
||
"Accept": "application/json, text/plain, */*",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
|
||
class XunleiCredentialManager:
|
||
"""迅雷网盘凭证管理器。
|
||
|
||
职责:
|
||
- 使用 refresh_token 换取 access_token
|
||
- 获取 captcha_token(特定 action 需要)
|
||
- 构建包含所有认证头的请求头字典
|
||
- 访问令牌过期前自动刷新(提前 60s)
|
||
|
||
用法:
|
||
mgr = XunleiCredentialManager(refresh_token="xxx")
|
||
mgr.refresh_access_token() # 刷新 access_token
|
||
captcha = mgr.get_captcha_token("restore") # 获取验证码令牌
|
||
headers = mgr.get_headers() # 获取完整的认证请求头
|
||
is_ok = mgr.validate() # 验证凭证有效性
|
||
|
||
Attributes:
|
||
CLIENT_ID: 迅雷客户端 ID。
|
||
DEVICE_ID: 设备标识。
|
||
"""
|
||
|
||
# ─── 类常量 ────────────────────────────────────────────────
|
||
CLIENT_ID: str = CLIENT_ID
|
||
DEVICE_ID: str = DEVICE_ID
|
||
|
||
def __init__(self, refresh_token: str = "") -> None:
|
||
"""初始化迅雷凭证管理器。
|
||
|
||
Args:
|
||
refresh_token: 迅雷网盘的 refresh_token。
|
||
"""
|
||
self._refresh_token: str = refresh_token.strip()
|
||
self._access_token: str = ""
|
||
self._expires_at: float = 0.0
|
||
self._captcha_tokens: Dict[str, str] = {} # action → captcha_token
|
||
self._lock: threading.Lock = threading.Lock()
|
||
self._session: requests.Session = requests.Session()
|
||
self._session.headers.update(DEFAULT_HEADERS)
|
||
|
||
# ─── 公开 API ──────────────────────────────────────────────
|
||
|
||
def validate(self) -> bool:
|
||
"""验证 refresh_token 是否有效。
|
||
|
||
要求 refresh_token 长度 >= 20,且能成功换取 access_token。
|
||
|
||
Returns:
|
||
True 表示凭证有效。
|
||
"""
|
||
if not self._refresh_token or len(self._refresh_token) < 20:
|
||
logger.warning(
|
||
"[XunleiCredential] refresh_token 长度不足 20,验证失败"
|
||
)
|
||
return False
|
||
return self.refresh_access_token()
|
||
|
||
def is_valid(self) -> bool:
|
||
"""validate() 的别名。"""
|
||
return self.validate()
|
||
|
||
def refresh_access_token(self) -> bool:
|
||
"""使用 refresh_token 换取 access_token。
|
||
|
||
POST /v1/auth/token
|
||
Body: {"grant_type": "refresh_token", "refresh_token": "...", "client_id": "..."}
|
||
|
||
返回 True 表示成功,False 表示失败。
|
||
"""
|
||
with self._lock:
|
||
return self._do_refresh()
|
||
|
||
def get_captcha_token(self, action: str) -> str:
|
||
"""获取指定 action 的 captcha_token。
|
||
|
||
POST /v1/shield/captcha/init
|
||
Body: {
|
||
"client_id": "...",
|
||
"action": "...",
|
||
"device_id": "...",
|
||
"meta": {"captcha_sign": "..."}
|
||
}
|
||
|
||
captcha_token 会按 action 缓存,避免重复获取。
|
||
|
||
Args:
|
||
action: 操作类型,如 "restore"、"share" 等。
|
||
|
||
Returns:
|
||
captcha_token 字符串,获取失败返回空字符串。
|
||
"""
|
||
with self._lock:
|
||
# 检查缓存
|
||
if action in self._captcha_tokens:
|
||
return self._captcha_tokens[action]
|
||
return self._do_get_captcha(action)
|
||
|
||
def get_headers(self) -> Dict[str, str]:
|
||
"""构建包含所有认证头的请求头字典。
|
||
|
||
返回:
|
||
- Authorization: Bearer <access_token>
|
||
- x-captcha-token: <captcha_token> (如有)
|
||
- x-client-id: <client_id>
|
||
- x-device-id: <device_id>
|
||
|
||
Returns:
|
||
认证请求头字典。
|
||
"""
|
||
self._ensure_token_valid()
|
||
|
||
headers: Dict[str, str] = {
|
||
"x-client-id": self.CLIENT_ID,
|
||
"x-device-id": self.DEVICE_ID,
|
||
}
|
||
|
||
if self._access_token:
|
||
headers["Authorization"] = f"Bearer {self._access_token}"
|
||
|
||
return headers
|
||
|
||
def get_headers_with_captcha(self, action: str = "") -> Dict[str, str]:
|
||
"""获取带 captcha_token 的完整认证头。
|
||
|
||
Args:
|
||
action: captcha 操作类型,空字符串表示不需要 captcha。
|
||
|
||
Returns:
|
||
包含 Authorization + x-captcha-token 的请求头字典。
|
||
"""
|
||
headers = self.get_headers()
|
||
|
||
if action:
|
||
captcha = self.get_captcha_token(action)
|
||
if captcha:
|
||
headers["x-captcha-token"] = captcha
|
||
|
||
return headers
|
||
|
||
def get_access_token(self) -> str:
|
||
"""获取当前有效的 access_token(必要时自动刷新)。"""
|
||
self._ensure_token_valid()
|
||
return self._access_token
|
||
|
||
@property
|
||
def refresh_token(self) -> str:
|
||
"""返回当前 refresh_token。"""
|
||
return self._refresh_token
|
||
|
||
@refresh_token.setter
|
||
def refresh_token(self, value: str) -> None:
|
||
"""更新 refresh_token。"""
|
||
self._refresh_token = value.strip()
|
||
with self._lock:
|
||
self._access_token = ""
|
||
self._expires_at = 0.0
|
||
self._captcha_tokens.clear()
|
||
|
||
# ─── 内部方法 ──────────────────────────────────────────────
|
||
|
||
def _ensure_token_valid(self) -> None:
|
||
"""确保 access_token 有效(过期则自动刷新)。"""
|
||
if not self._access_token or time.time() >= (self._expires_at - 60):
|
||
self.refresh_access_token()
|
||
|
||
def _do_refresh(self) -> bool:
|
||
"""实际执行 token 刷新。
|
||
|
||
POST https://xluser-ssl.xunlei.com/v1/auth/token
|
||
"""
|
||
if not self._refresh_token:
|
||
logger.error("[XunleiCredential] 没有 refresh_token,无法刷新")
|
||
return False
|
||
|
||
url = f"{XUNLEI_AUTH_API}/v1/auth/token"
|
||
body: Dict[str, str] = {
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": self._refresh_token,
|
||
"client_id": self.CLIENT_ID,
|
||
}
|
||
|
||
try:
|
||
resp = self._session.post(url, json=body, timeout=30)
|
||
data = resp.json()
|
||
|
||
if resp.status_code != 200:
|
||
logger.error(
|
||
"[XunleiCredential] 刷新 token 失败: HTTP %d, %s",
|
||
resp.status_code,
|
||
data,
|
||
)
|
||
return False
|
||
|
||
access_token = data.get("access_token", "")
|
||
if not access_token:
|
||
logger.error(
|
||
"[XunleiCredential] 响应中缺少 access_token: %s", data
|
||
)
|
||
return False
|
||
|
||
expires_in = int(data.get("expires_in", 7200))
|
||
new_refresh = data.get("refresh_token", self._refresh_token)
|
||
|
||
self._access_token = access_token
|
||
self._expires_at = time.time() + expires_in
|
||
|
||
# 更新 refresh_token(服务端可能下发新的)
|
||
if new_refresh != self._refresh_token:
|
||
logger.info(
|
||
"[XunleiCredential] refresh_token 已轮换: "
|
||
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
|
||
)
|
||
self._refresh_token = new_refresh
|
||
|
||
# 清除 captcha 缓存(token 变了,captcha 可能也失效了)
|
||
self._captcha_tokens.clear()
|
||
|
||
logger.info(
|
||
"[XunleiCredential] Token 刷新成功 (expires_in=%ds)", expires_in
|
||
)
|
||
return True
|
||
|
||
except requests.RequestException as e:
|
||
logger.error(f"[XunleiCredential] 刷新 token 网络异常: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.exception(f"[XunleiCredential] 刷新 token 未知异常: {e}")
|
||
return False
|
||
|
||
def _do_get_captcha(self, action: str) -> str:
|
||
"""获取 captcha_token。
|
||
|
||
POST /v1/shield/captcha/init
|
||
"""
|
||
url = f"{XUNLEI_AUTH_API}/v1/shield/captcha/init"
|
||
body: Dict[str, Any] = {
|
||
"client_id": self.CLIENT_ID,
|
||
"action": action,
|
||
"device_id": self.DEVICE_ID,
|
||
"meta": {
|
||
"captcha_sign": "",
|
||
},
|
||
}
|
||
|
||
# 需要 Authorization 头
|
||
if not self._access_token:
|
||
if not self._do_refresh():
|
||
logger.error("[XunleiCredential] 无法获取 access_token,跳过 captcha")
|
||
return ""
|
||
|
||
headers: Dict[str, str] = {
|
||
"Authorization": f"Bearer {self._access_token}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
try:
|
||
resp = self._session.post(url, json=body, headers=headers, timeout=15)
|
||
data = resp.json()
|
||
|
||
captcha_token = data.get("captcha_token", "")
|
||
if captcha_token:
|
||
self._captcha_tokens[action] = captcha_token
|
||
logger.info(
|
||
"[XunleiCredential] captcha_token 获取成功 for action=%s",
|
||
action,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"[XunleiCredential] captcha_token 为空 for action=%s: %s",
|
||
action,
|
||
data,
|
||
)
|
||
|
||
return captcha_token
|
||
|
||
except requests.RequestException as e:
|
||
logger.error(f"[XunleiCredential] 获取 captcha_token 网络异常: {e}")
|
||
return ""
|
||
except Exception as e:
|
||
logger.exception(f"[XunleiCredential] 获取 captcha_token 异常: {e}")
|
||
return ""
|
||
|
||
def __repr__(self) -> str:
|
||
return (
|
||
f"XunleiCredentialManager("
|
||
f"refresh_token={'***' if self._refresh_token else 'None'}, "
|
||
f"has_access_token={bool(self._access_token)}, "
|
||
f"captcha_actions={list(self._captcha_tokens.keys())})"
|
||
)
|