""" 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+)", ] capabilities: Dict[str, bool] = { "ensure_dir": True, "save_files": True, "poll_task": True, "rename": True, "move_files": True, "delete_files": True, "cleanup_recycle": False, "share_staging_folder": True, } def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None: """初始化适配器。""" 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, ) super().__init__(config, transfer_config) # ═══════════════════════════════════════════════════════════════ # 公开接口实现 # ═══════════════════════════════════════════════════════════════ 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: start: float = time.time() if not self._credential.validate(): raise TransferError( TransferErrorCode.NOT_LOGIN, message="UC Cookie 无效或长度不足", platform=self.PLATFORM_KEY, ) requested_dir: str = save_dir or self.config.save_dir or "/" target_dir: str = requested_dir if requested_dir and not str(requested_dir).startswith("/") else self.ensure_dir(requested_dir) staging_dir: str = self.get_or_create_share_folder() or target_dir pwd: str = share_password or self.config.share_password or "" try: pwd_id, passcode = self._parse_share_url(share_url) stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode) detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken) task_id: str = self._transfer_engine._init_save( pwd_id, stoken, detail, to_pdir_fid=staging_dir ) new_fids: List[str] = self._transfer_engine._poll_save_task(task_id) if not new_fids: raise RuntimeError("转存完成但未获取到文件ID") if staging_dir != target_dir: move_result = self.move_files(new_fids, target_dir) if not self._api_success(move_result): raise RuntimeError(f"移动到目标目录失败: {move_result.get('message', move_result)}") if self.transfer_config.ad_filter_enabled and new_fids: new_fids = self._filter_ads(new_fids) if not new_fids: raise RuntimeError("广告过滤后无可分享文件") title: str = detail.get("title", "分享") share_task_id: str = self._transfer_engine._init_share(new_fids, title) share_id: str = self._transfer_engine._poll_share_task(share_task_id) share_url_new, passcode_new = self._transfer_engine._set_password(share_id, 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) return TransferResult( success=True, platform=self.PLATFORM_KEY, new_file_id=",".join(new_fids), file_name=title, share_url=share_url_new, share_password=passcode_new, 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 as exc: return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=exc) 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 # ─── Drive API capability helpers ───────────────────────────── @staticmethod def _normalize_dir_path(dir_path: str) -> str: path = "/" + str(dir_path or "").strip().strip("/") return "/" if path == "/" else path @staticmethod def _api_success(payload: Dict[str, Any]) -> bool: if not isinstance(payload, dict): return False code = payload.get("code") status = payload.get("status") return code == 0 or status in (0, 200) def ensure_dir(self, dir_path: str) -> str: normalized = self._normalize_dir_path(dir_path) if normalized == "/": return "0" parts = [part for part in normalized.strip("/").split("/") if part] prefixes = ["/" + "/".join(parts[:idx]) for idx in range(1, len(parts) + 1)] existing = { item.get("file_path"): str(item.get("fid")) for item in self.get_fids(prefixes) if item.get("file_path") and item.get("fid") } leaf_fid = existing.get(normalized) if leaf_fid: return leaf_fid for prefix in prefixes: if prefix in existing: continue result = self.mkdir(prefix) if self._api_success(result) and result.get("data", {}).get("fid"): existing[prefix] = str(result["data"]["fid"]) continue raise TransferError( TransferErrorCode.NETWORK_ERROR, message=f"创建目录失败: {result.get('message', result)}", platform=self.PLATFORM_KEY, ) return existing[normalized] def mkdir(self, dir_path: str) -> Dict[str, Any]: url = "https://pc-api.uc.cn/1/clouddrive/file" params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""} payload = { "pdir_fid": "0", "file_name": "", "dir_path": self._normalize_dir_path(dir_path), "dir_init_lock": False, } return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录") def rename(self, fid: str, file_name: str) -> Dict[str, Any]: url = "https://pc-api.uc.cn/1/clouddrive/file/rename" params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""} payload = {"fid": fid, "file_name": file_name} return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录") def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]: pending = [self._normalize_dir_path(p) for p in file_paths] result: List[Dict[str, Any]] = [] while pending: batch, pending = pending[:50], pending[50:] url = "https://pc-api.uc.cn/1/clouddrive/file/info/path_list" params = {"pr": "UCBrowser", "fr": "pc"} payload = {"file_path": batch, "namespace": "0"} data = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="按路径获取文件ID") if not self._api_success(data): raise TransferError( TransferErrorCode.NETWORK_ERROR, message=f"获取目录ID失败: {data.get('message', data)}", platform=self.PLATFORM_KEY, ) result.extend(data.get("data", [])) return result def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]: if not fids: return {"code": 0, "message": "无文件需要移动"} last: Dict[str, Any] = {"code": 0, "message": "success"} for offset in range(0, len(fids), 100): batch = fids[offset:offset + 100] url = "https://pc-api.uc.cn/1/clouddrive/file/move" params = {"uc_param_str": "", "fr": "pc", "pr": "UCBrowser"} payload = {"filelist": batch, "to_pdir_fid": to_pdir_fid, "exclude_fids": [], "action_type": 1} last = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="移动网盘文件") if not self._api_success(last): return last task_id = last.get("data", {}).get("task_id") if task_id: task = self.query_task(task_id) if not self._api_success(task) or task.get("data", {}).get("status") == -1: return {"code": 1, "message": task.get("data", {}).get("message", task.get("message", "移动任务失败")), "data": task.get("data", {})} return {"code": 0, "message": "移动完成", "data": last.get("data", {})} def _task_query_params(self, retry_index: int = 0) -> Dict[str, Any]: now_ms = int(time.time() * 1000) return { "pr": "UCBrowser", "fr": "pc", "uc_param_str": "", "retry_index": retry_index, "__dt": 300, "__t": now_ms, } def delete_files(self, fids: List[str]) -> Dict[str, Any]: if not fids: return {"code": 0, "status": 200} if self.delete(fids): return {"code": 0, "status": 200} return {"code": 1, "status": 500, "message": "删除文件失败"} def query_task(self, task_id: str) -> Dict[str, Any]: url = "https://pc-api.uc.cn/1/clouddrive/task" try: data = self._poll_task(url, task_id, query_params=self._task_query_params) return {"code": 0, "status": 200, "data": data} except TransferError as exc: return {"code": 1, "status": 500, "message": str(exc), "data": {"status": -1}} def get_or_create_share_folder(self) -> Optional[str]: if getattr(self, "_share_folder_fid", None): return self._share_folder_fid root = self.ls_dir("0") if self._api_success(root): for item in root.get("data", {}).get("list", []): if item.get("file_name") == "来自:分享" and item.get("dir"): self._share_folder_fid = str(item["fid"]) return self._share_folder_fid result = self.mkdir("/来自:分享") if self._api_success(result) and result.get("data", {}).get("fid"): self._share_folder_fid = str(result["data"]["fid"]) return self._share_folder_fid return None def ls_dir(self, pdir_fid: str) -> Dict[str, Any]: url = "https://pc-api.uc.cn/1/clouddrive/file/sort" params = {"pr": "UCBrowser", "fr": "pc", "pdir_fid": pdir_fid or "0", "_page": 1, "_size": 50, "_fetch_total": 1, "_fetch_sub_dirs": 0, "_sort": "file_type:asc,updated_at:desc"} return self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出网盘目录") # ─── 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()