v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
112
cloudsearch_transfer/adapter/xunlei/__init__.py
Normal file
112
cloudsearch_transfer/adapter/xunlei/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
CloudSearch Transfer — 迅雷网盘适配器 v1.0.0
|
||||
|
||||
PLATFORM_KEY = 'xunlei'
|
||||
迅雷网盘使用 refresh_token + captcha_token 双重认证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ..base import (
|
||||
BaseCloudDriveAdapter,
|
||||
FileInfo,
|
||||
TransferResult,
|
||||
VerifyResult,
|
||||
)
|
||||
from ...config import PlatformConfig, TransferConfig
|
||||
from ...errors import TransferError, TransferErrorCode
|
||||
from .credential import XunleiCredentialManager
|
||||
from .transfer import XunleiTransfer
|
||||
from .cleanup import XunleiCleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XunleiAdapter(BaseCloudDriveAdapter):
|
||||
"""迅雷网盘适配器"""
|
||||
|
||||
PLATFORM_NAME = "迅雷网盘"
|
||||
PLATFORM_KEY = "xunlei"
|
||||
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
|
||||
|
||||
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
|
||||
super().__init__(config, transfer_config)
|
||||
self._credential = XunleiCredentialManager(config)
|
||||
self._transfer_engine: Optional[XunleiTransfer] = None
|
||||
self._cleanup = XunleiCleanup()
|
||||
|
||||
def _setup_session(self):
|
||||
"""初始化 session 认证头"""
|
||||
headers = self._credential.get_auth_headers()
|
||||
if headers:
|
||||
self.session.headers.update(headers)
|
||||
|
||||
def _ensure_auth(self):
|
||||
"""确保认证头是最新的"""
|
||||
headers = self._credential.get_auth_headers()
|
||||
self.session.headers.update(headers)
|
||||
|
||||
@property
|
||||
def _transfer(self) -> XunleiTransfer:
|
||||
"""懒加载转存引擎"""
|
||||
if self._transfer_engine is None:
|
||||
self._transfer_engine = XunleiTransfer(
|
||||
self.session,
|
||||
self._credential,
|
||||
self.config,
|
||||
self.transfer_config,
|
||||
)
|
||||
return self._transfer_engine
|
||||
|
||||
# ─── 抽象方法实现 ──────────────────────────────
|
||||
|
||||
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
|
||||
self._ensure_auth()
|
||||
return self._transfer.get_share_info(pwd_id, passcode)
|
||||
|
||||
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
|
||||
self._ensure_auth()
|
||||
return self._transfer.save_files(pwd_id, detail, save_dir)
|
||||
|
||||
def _create_share(self, file_ids: List[str], title: str,
|
||||
password: str = "") -> Tuple[str, str]:
|
||||
self._ensure_auth()
|
||||
return self._transfer.create_share(file_ids, title, password)
|
||||
|
||||
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
|
||||
files = detail.get("files", [])
|
||||
return [
|
||||
FileInfo(fid=f.get("id", ""), name=f.get("name", ""),
|
||||
size=f.get("size", 0), is_dir=f.get("is_dir", False))
|
||||
for f in files
|
||||
]
|
||||
|
||||
def _filter_ads(self, file_ids: List[str]) -> List[str]:
|
||||
banned = self._get_banned_keywords()
|
||||
return self._cleanup.filter_ad_ids(
|
||||
file_ids,
|
||||
getattr(self._transfer, "_last_file_names", []),
|
||||
banned,
|
||||
)
|
||||
|
||||
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
|
||||
self._ensure_auth()
|
||||
return self._transfer.list_files(parent_fid)
|
||||
|
||||
def delete(self, file_ids: List[str]) -> bool:
|
||||
self._ensure_auth()
|
||||
return self._cleanup.delete_files(
|
||||
self.session, self._credential, file_ids
|
||||
)
|
||||
|
||||
def _get_banned_keywords(self) -> List[str]:
|
||||
return self.config.banned_keywords or self.transfer_config.default_banned_keywords
|
||||
|
||||
def close(self):
|
||||
self.session.close()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<XunleiAdapter account={self.config.account_name}>"
|
||||
198
cloudsearch_transfer/adapter/xunlei/cleanup.py
Normal file
198
cloudsearch_transfer/adapter/xunlei/cleanup.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
CloudSearch Transfer — 迅雷网盘清理模块 v1.0.0
|
||||
|
||||
提供文件删除和广告过滤功能。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import XunleiCredentialManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 迅雷 API ─────────────────────────────────────────────────────────
|
||||
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
|
||||
|
||||
|
||||
class XunleiCleanup:
|
||||
"""迅雷网盘文件清理器。
|
||||
|
||||
提供批量删除文件和广告文件过滤功能。
|
||||
|
||||
Attributes:
|
||||
credential: 迅雷凭证管理器。
|
||||
session: 复用的 requests.Session。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: XunleiCredentialManager,
|
||||
timeout: int = 30,
|
||||
) -> None:
|
||||
"""初始化清理器。
|
||||
|
||||
Args:
|
||||
credential: 有效的迅雷凭证管理器。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
"""
|
||||
self.credential: XunleiCredentialManager = credential
|
||||
self.timeout: int = timeout
|
||||
self.session: requests.Session = requests.Session()
|
||||
|
||||
def delete_files(self, file_ids: List[str]) -> bool:
|
||||
"""批量删除文件。
|
||||
|
||||
POST /drive/v1/files:batchDelete
|
||||
Body: {
|
||||
"ids": ["<fid1>", "<fid2>", ...],
|
||||
"space": ""
|
||||
}
|
||||
|
||||
Args:
|
||||
file_ids: 要删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除请求已提交成功,False 表示失败。
|
||||
|
||||
Raises:
|
||||
RuntimeError: HTTP 请求错误。
|
||||
"""
|
||||
if not file_ids:
|
||||
logger.warning("[XunleiCleanup] delete_files called with empty list")
|
||||
return True
|
||||
|
||||
url: str = f"{XUNLEI_PAN_API}/drive/v1/files:batchDelete"
|
||||
body: Dict[str, Any] = {
|
||||
"ids": file_ids,
|
||||
"space": "",
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[XunleiCleanup] Deleting %d files: %s", len(file_ids), file_ids)
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
url, json=body, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"删除文件失败: {exc}") from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
errcode = data.get("errcode", data.get("error_code", 0))
|
||||
if errcode != 0:
|
||||
logger.error(
|
||||
"[XunleiCleanup] Delete returned error: errcode=%s, message=%s",
|
||||
errcode,
|
||||
data.get("message", data.get("error", "")),
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("[XunleiCleanup] Delete succeeded for %d files", len(file_ids))
|
||||
return True
|
||||
|
||||
def delete_files_permanent(self, file_ids: List[str]) -> bool:
|
||||
"""彻底删除文件。
|
||||
|
||||
迅雷的 batchDelete 默认为彻底删除(与回收站不同),
|
||||
此方法与 delete_files 行为一致。
|
||||
|
||||
Args:
|
||||
file_ids: 要彻底删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除请求已提交成功。
|
||||
"""
|
||||
return self.delete_files(file_ids)
|
||||
|
||||
@staticmethod
|
||||
def filter_ads(
|
||||
files: List[Dict[str, Any]],
|
||||
banned_keywords: List[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按关键词过滤文件列表中的广告文件。
|
||||
|
||||
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
|
||||
匹配方式:不区分大小写的子串匹配。
|
||||
|
||||
Args:
|
||||
files: 文件信息字典列表,每个字典需包含 "name" 或 "file_name" 字段。
|
||||
banned_keywords: 被禁关键词列表(匹配不区分大小写)。
|
||||
|
||||
Returns:
|
||||
过滤后的文件信息列表。
|
||||
"""
|
||||
if not banned_keywords:
|
||||
return files
|
||||
|
||||
filtered: List[Dict[str, Any]] = []
|
||||
removed_count: int = 0
|
||||
|
||||
for f in files:
|
||||
name: str = f.get("name", f.get("file_name", ""))
|
||||
name_lower: str = str(name).lower()
|
||||
|
||||
if any(keyword.lower() in name_lower for keyword in banned_keywords):
|
||||
logger.info("[XunleiCleanup] Filtered ad file: '%s'", name)
|
||||
removed_count += 1
|
||||
continue
|
||||
|
||||
filtered.append(f)
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info(
|
||||
"[XunleiCleanup] Ad filter removed %d/%d files",
|
||||
removed_count,
|
||||
len(files),
|
||||
)
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def filter_ad_ids(
|
||||
file_ids: List[str],
|
||||
file_names: List[str],
|
||||
banned_keywords: List[str],
|
||||
) -> List[str]:
|
||||
"""按关键词过滤文件 ID 列表。
|
||||
|
||||
根据 file_names 判断是否为广告,返回对应的 file_ids。
|
||||
|
||||
Args:
|
||||
file_ids: 文件 ID 列表。
|
||||
file_names: 与 file_ids 一一对应的文件名列表。
|
||||
banned_keywords: 被禁关键词列表。
|
||||
|
||||
Returns:
|
||||
过滤后的 file_ids 列表。
|
||||
"""
|
||||
if not banned_keywords or len(file_ids) != len(file_names):
|
||||
return file_ids
|
||||
|
||||
filtered_ids: List[str] = []
|
||||
for fid, name in zip(file_ids, file_names):
|
||||
name_lower: str = str(name).lower()
|
||||
if any(kw.lower() in name_lower for kw in banned_keywords):
|
||||
logger.info(
|
||||
"[XunleiCleanup] Filtered ad file: '%s' (id=%s)", name, fid
|
||||
)
|
||||
continue
|
||||
filtered_ids.append(fid)
|
||||
|
||||
return filtered_ids
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 HTTP 会话。"""
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self) -> "XunleiCleanup":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
339
cloudsearch_transfer/adapter/xunlei/credential.py
Normal file
339
cloudsearch_transfer/adapter/xunlei/credential.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
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())})"
|
||||
)
|
||||
518
cloudsearch_transfer/adapter/xunlei/transfer.py
Normal file
518
cloudsearch_transfer/adapter/xunlei/transfer.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""
|
||||
CloudSearch Transfer — 迅雷网盘转存核心 v1.0.0
|
||||
|
||||
迅雷网盘 4 步转存流程:
|
||||
|
||||
① GET .../drive/v1/share?share_id=xx → pass_code_token, files[], title
|
||||
② POST .../share/restore → restore_task_id (转存)
|
||||
③ 轮询 GET .../tasks/{task_id} → progress==100, trace_file_ids → oldId→newId映射
|
||||
④ POST .../share → share_url + pass_code
|
||||
|
||||
迅雷网盘需要 refresh_token + captcha_token 双重认证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import XunleiCredentialManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 迅雷 API 基础地址 ──────────────────────────────────────────────
|
||||
XUNLEI_PAN_API = "https://api-pan.xunlei.com"
|
||||
|
||||
# ─── URL 解析正则 ───────────────────────────────────────────────────
|
||||
# 匹配 pan.xunlei.com/s/<share_id>
|
||||
SHARE_URL_PATTERN = re.compile(r"pan\.xunlei\.com/s/([A-Za-z0-9]+)")
|
||||
|
||||
|
||||
class XunleiTransfer:
|
||||
"""迅雷网盘转存引擎。
|
||||
|
||||
封装完整的 4 步 API 流程:获取分享详情 → 转存文件 →
|
||||
轮询转存任务 → 创建新分享。
|
||||
|
||||
Attributes:
|
||||
credential: 迅雷凭证管理器实例。
|
||||
session: 复用的 requests.Session。
|
||||
timeout: 请求超时(秒)。
|
||||
poll_interval: 轮询间隔(秒)。
|
||||
poll_max_attempts: 最大轮询次数。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: XunleiCredentialManager,
|
||||
timeout: int = 30,
|
||||
poll_interval: float = 1.0,
|
||||
poll_max_attempts: int = 60,
|
||||
) -> None:
|
||||
"""初始化转存引擎。
|
||||
|
||||
Args:
|
||||
credential: 有效的迅雷凭证管理器。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
poll_interval: 异步任务轮询间隔秒数。
|
||||
poll_max_attempts: 异步任务最大轮询次数。
|
||||
"""
|
||||
self.credential: XunleiCredentialManager = credential
|
||||
self.timeout: int = timeout
|
||||
self.poll_interval: float = poll_interval
|
||||
self.poll_max_attempts: int = poll_max_attempts
|
||||
self.session: requests.Session = requests.Session()
|
||||
|
||||
# ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
|
||||
|
||||
def _get_share_info(self, share_id: str) -> Dict[str, Any]:
|
||||
"""步骤①:获取分享详情。
|
||||
|
||||
GET /drive/v1/share?share_id=<share_id>
|
||||
|
||||
返回字段包含:pass_code_token, files[], title 等。
|
||||
|
||||
Args:
|
||||
share_id: 分享 ID(从 URL 解析)。
|
||||
|
||||
Returns:
|
||||
分享信息字典,包含 files, title, pass_code_token。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{XUNLEI_PAN_API}/drive/v1/share"
|
||||
params: Dict[str, str] = {"share_id": share_id}
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"获取分享详情失败: {exc}") from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
|
||||
# 检查业务错误
|
||||
errcode = data.get("errcode", data.get("error_code", 0))
|
||||
if errcode != 0:
|
||||
raise RuntimeError(
|
||||
f"分享详情API返回错误: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
|
||||
)
|
||||
|
||||
# 提取关键字段
|
||||
pass_code_token: str = data.get("pass_code_token", "")
|
||||
files: List[Dict[str, Any]] = data.get("files", [])
|
||||
title: str = data.get("title", data.get("share_name", ""))
|
||||
|
||||
if not files:
|
||||
raise RuntimeError("分享内容为空")
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] ① Share info: title=%s, files=%d, has_pass_code_token=%s",
|
||||
title,
|
||||
len(files),
|
||||
bool(pass_code_token),
|
||||
)
|
||||
|
||||
return {
|
||||
"pass_code_token": pass_code_token,
|
||||
"files": files,
|
||||
"title": title,
|
||||
"share_id": share_id,
|
||||
}
|
||||
|
||||
# ─── 步骤 ②:转存文件 ─────────────────────────────────────────
|
||||
|
||||
def _restore_files(
|
||||
self,
|
||||
share_id: str,
|
||||
pass_code_token: str,
|
||||
file_ids: List[str],
|
||||
parent_id: str = "",
|
||||
) -> str:
|
||||
"""步骤②:转存文件到自己的迅雷网盘。
|
||||
|
||||
POST /drive/v1/share/restore
|
||||
Body: {
|
||||
"file_ids": ["<fid1>", ...],
|
||||
"pass_code_token": "<token>",
|
||||
"share_id": "<share_id>",
|
||||
"parent_id": "",
|
||||
"specify_parent_id": true
|
||||
}
|
||||
|
||||
Args:
|
||||
share_id: 分享 ID。
|
||||
pass_code_token: 步骤①获取的 pass_code_token。
|
||||
file_ids: 要转存的文件 ID 列表。
|
||||
parent_id: 目标父目录 ID,空字符串表示根目录。
|
||||
|
||||
Returns:
|
||||
restore_task_id 字符串,用于步骤③轮询。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{XUNLEI_PAN_API}/drive/v1/share/restore"
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"file_ids": file_ids,
|
||||
"pass_code_token": pass_code_token,
|
||||
"share_id": share_id,
|
||||
"parent_id": parent_id or "",
|
||||
"specify_parent_id": True,
|
||||
}
|
||||
# restore 操作可能需要 captcha_token
|
||||
headers = self.credential.get_headers_with_captcha(action="restore")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] ② Restoring %d files from share_id=%s",
|
||||
len(file_ids),
|
||||
share_id,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
url, json=body, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"转存请求失败: {exc}") from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
errcode = data.get("errcode", data.get("error_code", 0))
|
||||
if errcode != 0:
|
||||
raise RuntimeError(
|
||||
f"转存请求失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
|
||||
)
|
||||
|
||||
task_id: Optional[str] = data.get("restore_task_id", data.get("task_id"))
|
||||
if not task_id:
|
||||
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
|
||||
|
||||
logger.info("[XunleiTransfer] ② Restore task created: task_id=%s", task_id)
|
||||
return task_id
|
||||
|
||||
# ─── 步骤 ③:轮询转存任务 ─────────────────────────────────────
|
||||
|
||||
def _poll_restore_task(self, task_id: str) -> Dict[str, str]:
|
||||
"""步骤③:轮询转存任务直到完成。
|
||||
|
||||
GET /drive/v1/tasks/{task_id}
|
||||
|
||||
当 progress==100 时表示完成,返回 oldId→newId 映射。
|
||||
从 params.trace_file_ids 解析 JSON 字符串获取映射关系。
|
||||
|
||||
Args:
|
||||
task_id: 步骤②返回的 restore_task_id。
|
||||
|
||||
Returns:
|
||||
{"oldId": "newId", ...} 文件 ID 映射字典。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 任务失败或超时。
|
||||
"""
|
||||
url = f"{XUNLEI_PAN_API}/drive/v1/tasks/{task_id}"
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
for attempt in range(1, self.poll_max_attempts + 1):
|
||||
try:
|
||||
resp = self.session.get(url, headers=headers, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
logger.warning(
|
||||
"[XunleiTransfer] ③ Poll attempt %d/%d failed, retrying...",
|
||||
attempt,
|
||||
self.poll_max_attempts,
|
||||
)
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
progress: int = data.get("progress", 0)
|
||||
status: str = data.get("status", "")
|
||||
|
||||
logger.debug(
|
||||
"[XunleiTransfer] ③ Poll %d/%d: progress=%d, status=%s",
|
||||
attempt,
|
||||
self.poll_max_attempts,
|
||||
progress,
|
||||
status,
|
||||
)
|
||||
|
||||
if status == "failed" or status == "error":
|
||||
raise RuntimeError(
|
||||
f"转存任务失败: task_id={task_id}, status={status}"
|
||||
)
|
||||
|
||||
if progress == 100:
|
||||
# 从 params.trace_file_ids 解析 oldId→newId 映射
|
||||
params: Dict[str, Any] = data.get("params", {})
|
||||
trace_file_ids: str = params.get("trace_file_ids", "")
|
||||
|
||||
if trace_file_ids:
|
||||
try:
|
||||
id_mapping: Dict[str, str] = json.loads(trace_file_ids)
|
||||
logger.info(
|
||||
"[XunleiTransfer] ③ Restore completed: %d files mapped",
|
||||
len(id_mapping),
|
||||
)
|
||||
return id_mapping
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"[XunleiTransfer] ③ Failed to parse trace_file_ids: %s",
|
||||
trace_file_ids,
|
||||
)
|
||||
|
||||
# fallback: 检查 result 字段
|
||||
result = data.get("result", {})
|
||||
if result:
|
||||
logger.info("[XunleiTransfer] ③ Restore completed via result field")
|
||||
return result
|
||||
|
||||
# 最后的 fallback: 返回空映射
|
||||
logger.warning(
|
||||
"[XunleiTransfer] ③ Restore completed but no file mapping found"
|
||||
)
|
||||
return {}
|
||||
|
||||
if progress < 0:
|
||||
raise RuntimeError(
|
||||
f"转存任务异常: task_id={task_id}, progress={progress}"
|
||||
)
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
raise RuntimeError(
|
||||
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts} 次"
|
||||
)
|
||||
|
||||
# ─── 步骤 ④:创建新分享 ─────────────────────────────────────
|
||||
|
||||
def _create_share(
|
||||
self,
|
||||
file_ids: List[str],
|
||||
expiration_days: str = "-1",
|
||||
) -> Tuple[str, str]:
|
||||
"""步骤④:创建新分享链接。
|
||||
|
||||
POST /drive/v1/share
|
||||
Body: {
|
||||
"file_ids": ["<fid1>", ...],
|
||||
"expiration_days": "-1"
|
||||
}
|
||||
|
||||
expiration_days: "-1" 表示永久有效。
|
||||
|
||||
Args:
|
||||
file_ids: 要分享的文件 ID 列表。
|
||||
expiration_days: 过期天数,"-1" 表示永久。
|
||||
|
||||
Returns:
|
||||
(share_url, pass_code) 元组。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{XUNLEI_PAN_API}/drive/v1/share"
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"file_ids": file_ids,
|
||||
"expiration_days": expiration_days,
|
||||
}
|
||||
# share 操作可能需要 captcha_token
|
||||
headers = self.credential.get_headers_with_captcha(action="share")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] ④ Creating share: %d files", len(file_ids)
|
||||
)
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
url, json=body, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(f"创建分享失败: {exc}") from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
errcode = data.get("errcode", data.get("error_code", 0))
|
||||
if errcode != 0:
|
||||
raise RuntimeError(
|
||||
f"创建分享失败: errcode={errcode}, message={data.get('message', data.get('error', ''))}"
|
||||
)
|
||||
|
||||
share_url: str = data.get("share_url", data.get("link", ""))
|
||||
pass_code: str = data.get("pass_code", data.get("code", ""))
|
||||
|
||||
if not share_url:
|
||||
share_id = data.get("share_id", "")
|
||||
if share_id:
|
||||
share_url = f"https://pan.xunlei.com/s/{share_id}"
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] ④ Share created: url=%s, pass_code=%s",
|
||||
share_url,
|
||||
pass_code,
|
||||
)
|
||||
return share_url, pass_code
|
||||
|
||||
# ─── 公开入口 ─────────────────────────────────────────────────
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
share_url: str,
|
||||
save_dir: str = "",
|
||||
share_password: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""执行完整的 4 步转存流程。
|
||||
|
||||
从原始迅雷分享链接开始,将文件转存到自己网盘,再创建新分享。
|
||||
|
||||
Args:
|
||||
share_url: 原始迅雷分享链接,如 https://pan.xunlei.com/s/xxxxx。
|
||||
save_dir: 转存目标目录 ID,空字符串表示根目录。
|
||||
share_password: 新分享的密码(迅雷使用 pass_code)。
|
||||
|
||||
Returns:
|
||||
包含以下字段的字典:
|
||||
- success: bool
|
||||
- new_file_ids: List[str] — 转存后的文件ID列表(newId)
|
||||
- file_name: str — 分享标题
|
||||
- share_url: str — 新分享链接
|
||||
- passcode: str — 新分享 pass_code
|
||||
|
||||
Raises:
|
||||
RuntimeError: 任一步骤失败。
|
||||
ValueError: URL 解析失败。
|
||||
"""
|
||||
# 0. 解析 URL 提取 share_id
|
||||
match = SHARE_URL_PATTERN.search(share_url)
|
||||
if not match:
|
||||
raise ValueError(f"无法从URL中提取迅雷分享ID: {share_url}")
|
||||
share_id: str = match.group(1)
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] Starting 4-step transfer for share_id=%s", share_id
|
||||
)
|
||||
|
||||
# ① 获取分享详情
|
||||
share_info: Dict[str, Any] = self._get_share_info(share_id)
|
||||
files: List[Dict[str, Any]] = share_info.get("files", [])
|
||||
title: str = share_info.get("title", "分享")
|
||||
pass_code_token: str = share_info.get("pass_code_token", "")
|
||||
|
||||
# 提取原始文件 ID
|
||||
file_ids: List[str] = [
|
||||
f.get("file_id", f.get("fid", f.get("id", "")))
|
||||
for f in files
|
||||
if f.get("file_id") or f.get("fid") or f.get("id")
|
||||
]
|
||||
|
||||
if not file_ids:
|
||||
raise RuntimeError("无法从分享中提取文件ID")
|
||||
|
||||
# ② 发起转存
|
||||
task_id: str = self._restore_files(
|
||||
share_id, pass_code_token, file_ids, parent_id=save_dir
|
||||
)
|
||||
|
||||
# ③ 轮询转存任务 → 获取 oldId→newId 映射
|
||||
id_mapping: Dict[str, str] = self._poll_restore_task(task_id)
|
||||
|
||||
# 从映射中提取新的文件 ID
|
||||
new_file_ids: List[str] = []
|
||||
for old_fid in file_ids:
|
||||
new_fid = id_mapping.get(old_fid, "")
|
||||
if new_fid:
|
||||
new_file_ids.append(new_fid)
|
||||
else:
|
||||
logger.warning(
|
||||
"[XunleiTransfer] No newId mapped for old_fid=%s", old_fid
|
||||
)
|
||||
|
||||
if not new_file_ids:
|
||||
raise RuntimeError("转存完成但未获取到新文件ID")
|
||||
|
||||
# ④ 创建新分享
|
||||
share_url_new, pass_code = self._create_share(new_file_ids)
|
||||
|
||||
logger.info(
|
||||
"[XunleiTransfer] Transfer complete: %d files, new_share=%s",
|
||||
len(new_file_ids),
|
||||
share_url_new,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_file_ids": new_file_ids,
|
||||
"file_name": title,
|
||||
"share_url": share_url_new,
|
||||
"passcode": pass_code or share_password,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_share_url(url: str) -> Optional[str]:
|
||||
"""从迅雷分享 URL 中提取 share_id。
|
||||
|
||||
Args:
|
||||
url: 迅雷分享链接。
|
||||
|
||||
Returns:
|
||||
share_id 字符串,解析失败返回 None。
|
||||
"""
|
||||
match = SHARE_URL_PATTERN.search(url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def extract_file_ids(files: List[Dict[str, Any]]) -> List[str]:
|
||||
"""从文件列表中提取 file_id。
|
||||
|
||||
Args:
|
||||
files: 文件信息字典列表。
|
||||
|
||||
Returns:
|
||||
file_id 字符串列表。
|
||||
"""
|
||||
return [
|
||||
f.get("file_id", f.get("fid", f.get("id", "")))
|
||||
for f in files
|
||||
if f.get("file_id") or f.get("fid") or f.get("id")
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def parse_trace_file_ids(trace: str) -> Dict[str, str]:
|
||||
"""解析 trace_file_ids JSON 字符串为 oldId→newId 映射。
|
||||
|
||||
Args:
|
||||
trace: trace_file_ids JSON 字符串,如 '{"oldId":"newId"}'.
|
||||
|
||||
Returns:
|
||||
{"oldId": "newId", ...} 映射字典。
|
||||
"""
|
||||
try:
|
||||
return json.loads(trace)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 HTTP 会话。"""
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self) -> "XunleiTransfer":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
Reference in New Issue
Block a user