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
+175 -22
View File
@@ -55,19 +55,21 @@ class QuarkAdapter(BaseCloudDriveAdapter):
r"pan\.quark\.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": True,
"share_staging_folder": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化夸克适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: QuarkCredentialManager = QuarkCredentialManager(
cookie=config.cookie
)
"""初始化适配器。"""
self._credential: QuarkCredentialManager = QuarkCredentialManager(cookie=config.cookie)
self._transfer_engine: QuarkTransfer = QuarkTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
@@ -78,6 +80,7 @@ class QuarkAdapter(BaseCloudDriveAdapter):
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
@@ -116,8 +119,9 @@ class QuarkAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 目标目录:支持 fid 或路径;路径会先创建/解析为 fid。
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)
# 分享密码
pwd: str = share_password or self.config.share_password or ""
@@ -250,18 +254,13 @@ class QuarkAdapter(BaseCloudDriveAdapter):
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail)。
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
save_dir 可传 fid 或路径;路径会先通过 ensure_dir 创建/解析为 fid。
"""
# 需要 stoken,从 detail 间接获取(重新请求)
stoken: str = self._transfer_engine._get_stoken(pwd_id)
target_fid = save_dir if save_dir and not str(save_dir).startswith("/") else self.ensure_dir(save_dir or "/")
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=save_dir
pwd_id, stoken, detail, to_pdir_fid=target_fid
)
return self._transfer_engine._poll_save_task(task_id)
@@ -351,6 +350,160 @@ class QuarkAdapter(BaseCloudDriveAdapter):
logger.warning("[QuarkAdapter] 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://drive-pc.quark.cn/1/clouddrive/file"
params = {"pr": "ucpro", "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://drive-pc.quark.cn/1/clouddrive/file/rename"
params = {"pr": "ucpro", "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://drive-pc.quark.cn/1/clouddrive/file/info/path_list"
params = {"pr": "ucpro", "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://drive-pc.quark.cn/1/clouddrive/file/move"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
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": "ucpro",
"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://drive-pc.quark.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 recycle_list(self, page: int = 1, size: int = 30) -> List[Dict[str, Any]]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/list"
params = {"_page": page, "_size": size, "pr": "ucpro", "fr": "pc", "uc_param_str": ""}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出回收站")
return data.get("data", {}).get("list", [])
def recycle_remove(self, record_list: List[Dict[str, Any]]) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/remove"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
payload = {"select_mode": 2, "record_list": record_list}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
target_fids = {str(fid) for fid in fids if fid}
if not target_fids:
return {"code": 0, "message": "无回收站记录需要清理", "data": {"removed": 0}}
records = [
item for item in self.recycle_list()
if str(item.get("fid") or item.get("file_id") or "") in target_fids
]
if not records:
return {"code": 0, "message": "未找到匹配的回收站记录", "data": {"removed": 0}}
result = self.recycle_remove(records)
if self._api_success(result):
result.setdefault("data", {})["removed"] = len(records)
return result
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]: