""" CloudSearch Transfer — UC网盘适配器 v1.0.0 将 UcCredentialManager、UcTransfer、UcCleanup 组合为 BaseCloudDriveAdapter 的完整实现。 UC网盘 7 步 API 转存流程(与夸克高度相似,API 域名不同): ① POST .../share/sharepage/v2/detail?pr=UCBrowser&fr=pc → stoken ② GET .../share/sharepage/detail → fid, share_fid_token, title ③ POST .../share/sharepage/save → task_id (转存) ④ 轮询 GET .../task → save_as_top_fids ⑤ POST .../share → task_id (创建分享) ⑥ 轮询 GET .../task → share_id ⑦ POST .../share/password → share_url, passcode 参考 cloud-auto-save 的 quark 实现,域名从 drive-pc.quark.cn 改为 pc-api.uc.cn。 """ from __future__ import annotations import logging import time from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse, parse_qs from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult from ...config import PlatformConfig, TransferConfig from ...errors import TransferError, TransferErrorCode from .credential import UcCredentialManager from .transfer import UcTransfer, SHARE_URL_PATTERN from .cleanup import UcCleanup logger = logging.getLogger(__name__) class UcAdapter(BaseCloudDriveAdapter): """UC网盘适配器。 组合 credential / transfer / cleanup 三个模块, 实现 BaseCloudDriveAdapter 定义的所有抽象方法。 Attributes: PLATFORM_NAME: 展示用平台名称。 PLATFORM_KEY: 内部平台标识。 URL_PATTERNS: UC 分享链接匹配正则列表。 """ # ─── 平台标识 ────────────────────────────────────────────── PLATFORM_NAME: str = "UC网盘" PLATFORM_KEY: str = "uc" # ─── URL 匹配 ────────────────────────────────────────────── # 支持 drive.uc.cn/s/ URL_PATTERNS: List[str] = [ r"drive\.uc\.cn/s/(\w+)", ] def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None: """初始化 UC 适配器。 Args: config: 平台配置(含 Cookie 等)。 transfer_config: 全局转存配置(超时、重试、轮询参数等)。 """ super().__init__(config, transfer_config) # 初始化三个子模块 self._credential: UcCredentialManager = UcCredentialManager( cookie=config.cookie ) self._transfer_engine: UcTransfer = UcTransfer( credential=self._credential, timeout=transfer_config.request_timeout, poll_interval=transfer_config.task_poll_interval, poll_max_attempts=transfer_config.task_poll_max_attempts, ) self._cleanup: UcCleanup = UcCleanup( credential=self._credential, timeout=transfer_config.request_timeout, ) # ═══════════════════════════════════════════════════════════════ # 公开接口实现 # ═══════════════════════════════════════════════════════════════ def _setup_session(self) -> None: """将 UC Cookie 注入 session 的默认 headers。""" headers = self._credential.get_headers() if headers: self.session.headers.update(headers) logger.debug("[UcAdapter] Session headers updated with Cookie") def transfer(self, share_url: str, save_dir: str = "", share_password: str = "") -> TransferResult: """执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。 通过 UcTransfer 引擎执行完整的 7 步流程。 Args: share_url: UC 分享链接。 save_dir: 目标目录,空则使用配置的默认目录。 share_password: 新分享的密码。 Returns: TransferResult 包含转存结果。 """ start: float = time.time() # 凭证检查 if not self._credential.validate(): raise TransferError( TransferErrorCode.NOT_LOGIN, message="UC Cookie 无效或长度不足", platform=self.PLATFORM_KEY, ) # 目标目录:默认根目录 "0" target_dir: str = save_dir or self.config.save_dir or "0" # 分享密码 pwd: str = share_password or self.config.share_password or "" try: result: Dict[str, Any] = self._transfer_engine.transfer( share_url=share_url, save_dir=target_dir, share_password=pwd, ) except ValueError as exc: raise TransferError( TransferErrorCode.URL_INVALID, message=str(exc), platform=self.PLATFORM_KEY, ) from exc except RuntimeError as exc: msg: str = str(exc) if "stoken" in msg or "status" in msg: raise TransferError( TransferErrorCode.SHARE_NOT_EXIST, message=msg, platform=self.PLATFORM_KEY, ) from exc raise TransferError( TransferErrorCode.NETWORK_ERROR, message=msg, platform=self.PLATFORM_KEY, ) from exc elapsed: int = int((time.time() - start) * 1000) # 广告过滤 new_fids: List[str] = result.get("new_file_ids", []) if self.transfer_config.ad_filter_enabled and new_fids: new_fids = self._filter_ads(new_fids) if not new_fids: raise TransferError( TransferErrorCode.RESOURCE_EMPTY, platform=self.PLATFORM_KEY, ) return TransferResult( success=True, platform=self.PLATFORM_KEY, new_file_id=",".join(new_fids), file_name=result.get("file_name", ""), share_url=result.get("share_url", ""), share_password=result.get("passcode", pwd), original_url=share_url, elapsed_ms=elapsed, ) def verify(self, share_url: str) -> VerifyResult: """验证 UC 分享链接有效性。 Args: share_url: UC 分享链接。 Returns: VerifyResult 包含验证结果。 """ try: pwd_id, passcode = self._parse_share_url(share_url) if not self._credential.validate(): return VerifyResult( valid=False, platform=self.PLATFORM_KEY, error=TransferError( TransferErrorCode.NOT_LOGIN, platform=self.PLATFORM_KEY, ), ) stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode) detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken) files: List[FileInfo] = self._extract_file_list(detail) return VerifyResult( valid=True, platform=self.PLATFORM_KEY, title=detail.get("title", ""), file_count=len(files), files=files, ) except TransferError: raise except (ValueError, RuntimeError) as exc: return VerifyResult( valid=False, platform=self.PLATFORM_KEY, error=TransferError( TransferErrorCode.SHARE_NOT_EXIST, message=str(exc), platform=self.PLATFORM_KEY, ), ) except Exception as exc: return VerifyResult( valid=False, platform=self.PLATFORM_KEY, error=TransferError( TransferErrorCode.NETWORK_ERROR, message=str(exc), platform=self.PLATFORM_KEY, ), ) # ─── 核心抽象方法 ───────────────────────────────────────── def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict: """获取 UC 分享详情。 Args: pwd_id: 分享 ID。 passcode: 提取码。 Returns: 分享详情字典,包含 title, fid, share_fid_token 等字段。 """ stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode) return self._transfer_engine._get_detail(pwd_id, stoken) def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]: """转存文件到自己的 UC 网盘。 Args: pwd_id: 分享 ID。 detail: 分享详情(来自 _get_share_detail)。 save_dir: 目标目录 ID。 Returns: 转存后的新文件 ID 列表。 """ stoken: str = self._transfer_engine._get_stoken(pwd_id) task_id: str = self._transfer_engine._init_save( pwd_id, stoken, detail, to_pdir_fid=save_dir ) return self._transfer_engine._poll_save_task(task_id) def _create_share( self, file_ids: List[str], title: str, password: str = "" ) -> Tuple[str, str]: """创建 UC 分享链接。 Args: file_ids: 要分享的文件 ID 列表。 title: 分享标题。 password: 分享密码。 Returns: (share_url, share_password) 元组。 """ task_id: str = self._transfer_engine._init_share(file_ids, title) share_id: str = self._transfer_engine._poll_share_task(task_id) return self._transfer_engine._set_password(share_id, password) def _extract_file_list(self, detail: dict) -> List[FileInfo]: """从 UC 分享详情中提取文件列表。 UC 的 sharepage/detail 返回格式与夸克一致: { "files": [ {"fid": "...", "file_name": "...", "size": 123, "dir": false, ...}, ] } Args: detail: 分享详情字典。 Returns: FileInfo 对象列表。 """ files_data: List[Dict[str, Any]] = detail.get("files", []) result: List[FileInfo] = [] for f in files_data: file_info = FileInfo( fid=str(f.get("fid", f.get("file_id", ""))), name=str(f.get("file_name", f.get("name", ""))), size=int(f.get("size", 0)), is_dir=bool(f.get("dir", f.get("is_dir", False))), ext=str(f.get("ext", f.get("file_extension", ""))), ) result.append(file_info) # 如果 files 为空,尝试用 detail 顶层字段构造单个文件信息 if not result and detail.get("fid"): result.append( FileInfo( fid=str(detail.get("fid", "")), name=str(detail.get("title", detail.get("file_name", ""))), size=0, is_dir=False, ) ) return result def _filter_ads(self, file_ids: List[str]) -> List[str]: """过滤广告文件。 Args: file_ids: 文件 ID 列表。 Returns: 过滤后的文件 ID 列表。 """ keywords: List[str] = list( set(self.config.banned_keywords) | set(self.transfer_config.default_banned_keywords) ) if not keywords: return file_ids try: files: List[FileInfo] = self.get_files() file_names: List[str] = [f.name for f in files] return UcCleanup.filter_ad_ids(file_ids, file_names, keywords) except Exception: logger.warning( "[UcAdapter] Cannot fetch file list for ad filtering, skipping" ) return file_ids # ─── get_files / delete ──────────────────────────────────── def get_files(self, parent_fid: str = "0") -> List[FileInfo]: """列出 UC 网盘指定目录下的文件。 GET /1/clouddrive/file/sort?pdir_fid=&_page=1&_size=100&_sort=updated_at:desc Args: parent_fid: 父目录 ID,默认 "0" 即根目录。 Returns: FileInfo 列表。 """ url: str = f"https://pc-api.uc.cn/1/clouddrive/file/sort" params: Dict[str, str] = { "pdir_fid": parent_fid, "_page": "1", "_size": "100", "_sort": "updated_at:desc", } headers: Dict[str, str] = self._credential.get_headers() try: resp = self._get(url, params=params, headers=headers) except Exception as exc: raise TransferError( TransferErrorCode.NETWORK_ERROR, message=f"获取文件列表失败: {exc}", platform=self.PLATFORM_KEY, ) from exc data: Dict[str, Any] = resp.json() status: int = data.get("status", -1) if status != 0 and data.get("code") not in (0, None): raise TransferError( TransferErrorCode.NETWORK_ERROR, message=f"获取文件列表失败: {data.get('message')}", platform=self.PLATFORM_KEY, ) files_data: List[Dict[str, Any]] = data.get("data", {}).get("list", []) result: List[FileInfo] = [] for f in files_data: result.append( FileInfo( fid=str(f.get("fid", "")), name=str(f.get("file_name", f.get("name", ""))), size=int(f.get("size", 0)), is_dir=bool(f.get("dir", f.get("is_dir", False))), ext=str(f.get("file_extension", f.get("ext", ""))), ) ) logger.debug("[UcAdapter] Listed %d files in dir=%s", len(result), parent_fid) return result def delete(self, file_ids: List[str]) -> bool: """删除 UC 网盘文件(移到回收站)。 Args: file_ids: 要删除的文件 ID 列表。 Returns: True 表示删除成功。 """ if not self._credential.validate(): raise TransferError( TransferErrorCode.NOT_LOGIN, platform=self.PLATFORM_KEY, ) try: return self._cleanup.delete_files(file_ids) except RuntimeError as exc: raise TransferError( TransferErrorCode.NETWORK_ERROR, message=str(exc), platform=self.PLATFORM_KEY, ) from exc def delete_permanent(self, file_ids: List[str]) -> bool: """彻底删除 UC 网盘文件(不可恢复)。 Args: file_ids: 要彻底删除的文件 ID 列表。 Returns: True 表示删除成功。 """ if not self._credential.validate(): raise TransferError( TransferErrorCode.NOT_LOGIN, platform=self.PLATFORM_KEY, ) try: return self._cleanup.delete_files_permanent(file_ids) except RuntimeError as exc: raise TransferError( TransferErrorCode.NETWORK_ERROR, message=str(exc), platform=self.PLATFORM_KEY, ) from exc # ─── 工具方法 ───────────────────────────────────────────── def _parse_share_url(self, url: str) -> Tuple[str, str]: """解析 UC 分享 URL 提取 (pwd_id, passcode)。 UC 链接格式:https://drive.uc.cn/s/ 或带 ?pwd=xxxx Args: url: UC 分享链接。 Returns: (pwd_id, passcode) 元组。 Raises: TransferError: URL 格式无法识别。 """ pwd_id: Optional[str] = UcTransfer.parse_share_url(url) if not pwd_id: raise TransferError( TransferErrorCode.URL_INVALID, message=f"无法解析UC链接: {url}", platform=self.PLATFORM_KEY, ) parsed = urlparse(url) params = parse_qs(parsed.query) passcode: str = params.get("pwd", params.get("code", [""]))[0] return pwd_id, passcode def update_cookie(self, cookie: str) -> None: """动态更新 Cookie 并同步到 session headers。 Args: cookie: 新的 Cookie 字符串。 """ self._credential.update_cookie(cookie) self._setup_session() logger.info("[UcAdapter] Cookie updated, new length=%d", len(cookie)) def close(self) -> None: """关闭所有子模块的 HTTP 会话。""" self._transfer_engine.close()