feat: integrate Quark and UC drive APIs

Add optional drive API capabilities for Quark and UC adapters, including directory lookup/creation, rename, move, delete, task polling, Quark recycle cleanup, and UC share staging folder support.\n\nAdd unittest coverage for capability declarations, production transfer path save_dir resolution, staging-folder flow, delete_files behavior, task polling params, and HTTP/JSON error handling.\n\nDocument the Hong Kong test server deployment boundary and verification commands.
This commit is contained in:
2026-05-22 19:02:46 +08:00
parent d79c11fb15
commit 4c161b63c6
5 changed files with 768 additions and 73 deletions
+194 -49
View File
@@ -56,19 +56,21 @@ class UcAdapter(BaseCloudDriveAdapter):
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:
"""初始化 UC 适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: UcCredentialManager = UcCredentialManager(
cookie=config.cookie
)
"""初始化适配器。"""
self._credential: UcCredentialManager = UcCredentialManager(cookie=config.cookie)
self._transfer_engine: UcTransfer = UcTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
@@ -79,6 +81,7 @@ class UcAdapter(BaseCloudDriveAdapter):
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
@@ -93,21 +96,8 @@ class UcAdapter(BaseCloudDriveAdapter):
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,
@@ -115,18 +105,36 @@ class UcAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
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:
result: Dict[str, Any] = self._transfer_engine.transfer(
share_url=share_url,
save_dir=target_dir,
share_password=pwd,
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,
@@ -148,24 +156,13 @@ class UcAdapter(BaseCloudDriveAdapter):
) 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),
file_name=title,
share_url=share_url_new,
share_password=passcode_new,
original_url=share_url,
elapsed_ms=elapsed,
)
@@ -204,8 +201,8 @@ class UcAdapter(BaseCloudDriveAdapter):
files=files,
)
except TransferError:
raise
except TransferError as exc:
return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=exc)
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
@@ -344,6 +341,154 @@ class UcAdapter(BaseCloudDriveAdapter):
)
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]: