v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
509
cloudsearch_transfer/adapter/quark/__init__.py
Normal file
509
cloudsearch_transfer/adapter/quark/__init__.py
Normal file
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
CloudSearch Transfer — 夸克网盘适配器 v1.0.0
|
||||
|
||||
将 QuarkCredentialManager、QuarkTransfer、QuarkCleanup 组合为
|
||||
BaseCloudDriveAdapter 的完整实现。
|
||||
|
||||
夸克网盘 7 步 API 转存流程:
|
||||
① POST .../share/sharepage/token → 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 实现 + netdisk 的 Pan 接口约定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..base import BaseCloudDriveAdapter, FileInfo, TransferResult, VerifyResult
|
||||
from ...config import PlatformConfig, TransferConfig
|
||||
from ...errors import TransferError, TransferErrorCode
|
||||
|
||||
from .credential import QuarkCredentialManager
|
||||
from .transfer import QuarkTransfer, SHARE_URL_PATTERN
|
||||
from .cleanup import QuarkCleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuarkAdapter(BaseCloudDriveAdapter):
|
||||
"""夸克网盘适配器。
|
||||
|
||||
组合 credential / transfer / cleanup 三个模块,
|
||||
实现 BaseCloudDriveAdapter 定义的所有抽象方法。
|
||||
|
||||
Attributes:
|
||||
PLATFORM_NAME: 展示用平台名称。
|
||||
PLATFORM_KEY: 内部平台标识。
|
||||
URL_PATTERNS: 夸克分享链接匹配正则列表。
|
||||
"""
|
||||
|
||||
# ─── 平台标识 ──────────────────────────────────────────────
|
||||
PLATFORM_NAME: str = "夸克网盘"
|
||||
PLATFORM_KEY: str = "quark"
|
||||
|
||||
# ─── URL 匹配 ──────────────────────────────────────────────
|
||||
# 支持 pan.quark.cn/s/<share_id>
|
||||
URL_PATTERNS: List[str] = [
|
||||
r"pan\.quark\.cn/s/(\w+)",
|
||||
]
|
||||
|
||||
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._transfer_engine: QuarkTransfer = QuarkTransfer(
|
||||
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: QuarkCleanup = QuarkCleanup(
|
||||
credential=self._credential,
|
||||
timeout=transfer_config.request_timeout,
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 公开接口实现
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
"""将夸克 Cookie 注入 session 的默认 headers。"""
|
||||
headers = self._credential.get_headers()
|
||||
if headers:
|
||||
self.session.headers.update(headers)
|
||||
logger.debug("[QuarkAdapter] Session headers updated with Cookie")
|
||||
|
||||
# ─── transfer() 使用基类模板,子类实现 _transfer ──────────
|
||||
|
||||
def _transfer(self, share_url: str, save_dir: str = "",
|
||||
share_password: str = "") -> TransferResult:
|
||||
"""执行转存的核心逻辑(被基类 transfer() 调用)。
|
||||
|
||||
通过 QuarkTransfer 引擎执行完整的 7 步流程。
|
||||
|
||||
Args:
|
||||
share_url: 夸克分享链接。
|
||||
save_dir: 目标目录,空则使用配置的默认目录。
|
||||
share_password: 新分享的密码。
|
||||
|
||||
Returns:
|
||||
TransferResult 包含转存结果。
|
||||
"""
|
||||
start: float = time.time()
|
||||
|
||||
# 凭证检查
|
||||
if not self._credential.validate():
|
||||
raise TransferError(
|
||||
TransferErrorCode.NOT_LOGIN,
|
||||
message="夸克 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_file_ids 进行过滤
|
||||
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,
|
||||
)
|
||||
|
||||
# ─── verify() 使用基类模板,子类实现 _verify ───────────────
|
||||
|
||||
def _verify(self, share_url: str) -> VerifyResult:
|
||||
"""验证夸克分享链接有效性。
|
||||
|
||||
通过获取 stoken → 获取详情来验证链接。
|
||||
|
||||
Args:
|
||||
share_url: 夸克分享链接。
|
||||
|
||||
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:
|
||||
"""获取夸克分享详情(基类 transfer() 流程中的步骤②)。
|
||||
|
||||
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]:
|
||||
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID。
|
||||
detail: 分享详情(来自 _get_share_detail)。
|
||||
save_dir: 目标目录 ID。
|
||||
|
||||
Returns:
|
||||
转存后的新文件 ID 列表。
|
||||
"""
|
||||
# 需要 stoken,从 detail 间接获取(重新请求)
|
||||
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]:
|
||||
"""创建夸克分享链接(基类 transfer() 流程中的步骤⑤⑥⑦)。
|
||||
|
||||
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]:
|
||||
"""从夸克分享详情中提取文件列表。
|
||||
|
||||
夸克的 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]:
|
||||
"""过滤广告文件。
|
||||
|
||||
合并配置层和平台层的 banned_keywords,调用 QuarkCleanup 执行过滤。
|
||||
当前实现基于 file_ids 列表过滤(无文件名信息时保持原样)。
|
||||
|
||||
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
|
||||
|
||||
# 获取文件信息以进行名称匹配
|
||||
# 在基类 transfer() 流程中,此处 file_ids 已为转存后的新 IDs
|
||||
try:
|
||||
files: List[FileInfo] = self.get_files()
|
||||
file_names: List[str] = [f.name for f in files]
|
||||
return QuarkCleanup.filter_ad_ids(file_ids, file_names, keywords)
|
||||
except Exception:
|
||||
# 如果无法获取文件名列表,跳过广告过滤
|
||||
logger.warning("[QuarkAdapter] Cannot fetch file list for ad filtering, skipping")
|
||||
return file_ids
|
||||
|
||||
# ─── get_files / delete ────────────────────────────────────
|
||||
|
||||
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
|
||||
"""列出夸克网盘指定目录下的文件。
|
||||
|
||||
GET /1/clouddrive/file/sort?pdir_fid=<parent_fid>&_page=1&_size=100&_sort=updated_at:desc
|
||||
|
||||
Args:
|
||||
parent_fid: 父目录 ID,默认 "0" 即根目录。
|
||||
|
||||
Returns:
|
||||
FileInfo 列表。
|
||||
"""
|
||||
url: str = "https://drive-pc.quark.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("[QuarkAdapter] Listed %d files in dir=%s", len(result), parent_fid)
|
||||
return result
|
||||
|
||||
def delete(self, file_ids: List[str]) -> bool:
|
||||
"""删除夸克网盘文件(移到回收站)。
|
||||
|
||||
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:
|
||||
"""彻底删除夸克网盘文件(不可恢复)。
|
||||
|
||||
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]:
|
||||
"""解析夸克分享 URL 提取 (pwd_id, passcode)。
|
||||
|
||||
夸克链接格式:https://pan.quark.cn/s/<pwd_id> 或带 ?pwd=xxxx
|
||||
|
||||
Args:
|
||||
url: 夸克分享链接。
|
||||
|
||||
Returns:
|
||||
(pwd_id, passcode) 元组。
|
||||
|
||||
Raises:
|
||||
TransferError: URL 格式无法识别。
|
||||
"""
|
||||
pwd_id: Optional[str] = QuarkTransfer.parse_share_url(url)
|
||||
if not pwd_id:
|
||||
raise TransferError(
|
||||
TransferErrorCode.URL_INVALID,
|
||||
message=f"无法解析夸克链接: {url}",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
# 提取密码参数
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
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("[QuarkAdapter] Cookie updated, new length=%d", len(cookie))
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭所有子模块的 HTTP 会话。"""
|
||||
self._transfer_engine.close()
|
||||
self._cleanup.close()
|
||||
self.session.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"QuarkAdapter(name={self.PLATFORM_NAME}, "
|
||||
f"account={self.config.account_name}, "
|
||||
f"credential_valid={self._credential.validate()})"
|
||||
)
|
||||
209
cloudsearch_transfer/adapter/quark/cleanup.py
Normal file
209
cloudsearch_transfer/adapter/quark/cleanup.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
CloudSearch Transfer — 夸克网盘清理模块 v1.0.0
|
||||
|
||||
提供文件删除和广告过滤功能。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import QuarkCredentialManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 夸克 API ─────────────────────────────────────────────────────
|
||||
QUARK_API_BASE = "https://drive-pc.quark.cn"
|
||||
QUARK_FILE_API = f"{QUARK_API_BASE}/1/clouddrive/file"
|
||||
|
||||
|
||||
class QuarkCleanup:
|
||||
"""夸克网盘文件清理器。
|
||||
|
||||
提供批量删除文件和广告文件过滤功能。
|
||||
|
||||
Attributes:
|
||||
credential: 夸克凭证管理器。
|
||||
session: 复用的 requests.Session。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: QuarkCredentialManager,
|
||||
timeout: int = 30,
|
||||
) -> None:
|
||||
"""初始化清理器。
|
||||
|
||||
Args:
|
||||
credential: 有效的夸克凭证管理器。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
"""
|
||||
self.credential: QuarkCredentialManager = credential
|
||||
self.timeout: int = timeout
|
||||
self.session: requests.Session = requests.Session()
|
||||
|
||||
def delete_files(self, file_ids: List[str]) -> bool:
|
||||
"""批量删除文件(回收站方式)。
|
||||
|
||||
POST /1/clouddrive/file/delete
|
||||
Body: {
|
||||
"action_type": 2,
|
||||
"filelist": ["<fid1>", "<fid2>", ...]
|
||||
}
|
||||
|
||||
action_type=1 表示彻底删除,action_type=2 表示移入回收站。
|
||||
|
||||
Args:
|
||||
file_ids: 要删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除请求已提交成功,False 表示失败。
|
||||
|
||||
Raises:
|
||||
RuntimeError: HTTP 请求错误。
|
||||
"""
|
||||
if not file_ids:
|
||||
logger.warning("[QuarkCleanup] delete_files called with empty list")
|
||||
return True
|
||||
|
||||
url: str = f"{QUARK_FILE_API}/delete"
|
||||
body: Dict[str, Any] = {
|
||||
"action_type": 2, # 2=回收站, 1=彻底删除
|
||||
"filelist": file_ids,
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkCleanup] 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()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0 and data.get("code") not in (0, None):
|
||||
logger.error("[QuarkCleanup] Delete returned error: status=%s, message=%s",
|
||||
status, data.get("message"))
|
||||
return False
|
||||
|
||||
logger.info("[QuarkCleanup] Delete succeeded for %d files", len(file_ids))
|
||||
return True
|
||||
|
||||
def delete_files_permanent(self, file_ids: List[str]) -> bool:
|
||||
"""彻底删除文件(不从回收站恢复)。
|
||||
|
||||
与 delete_files 类似,但 action_type=1。
|
||||
|
||||
Args:
|
||||
file_ids: 要彻底删除的文件 ID 列表。
|
||||
|
||||
Returns:
|
||||
True 表示删除请求已提交成功。
|
||||
"""
|
||||
if not file_ids:
|
||||
return True
|
||||
|
||||
url: str = f"{QUARK_FILE_API}/delete"
|
||||
body: Dict[str, Any] = {
|
||||
"action_type": 1, # 1=彻底删除
|
||||
"filelist": file_ids,
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkCleanup] Permanently deleting %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()
|
||||
return data.get("status") == 0 or data.get("code") in (0, None)
|
||||
|
||||
@staticmethod
|
||||
def filter_ads(
|
||||
files: List[Dict[str, Any]],
|
||||
banned_keywords: List[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按关键词过滤文件列表中的广告文件。
|
||||
|
||||
遍历文件列表,剔除文件名中包含任一 banned_keywords 的文件。
|
||||
匹配方式:不区分大小写的子串匹配。
|
||||
|
||||
Args:
|
||||
files: 文件信息字典列表,每个字典需包含 "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", "")
|
||||
name_lower: str = str(name).lower()
|
||||
|
||||
if any(keyword.lower() in name_lower for keyword in banned_keywords):
|
||||
logger.info("[QuarkCleanup] Filtered ad file: '%s'", name)
|
||||
removed_count += 1
|
||||
continue
|
||||
|
||||
filtered.append(f)
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info("[QuarkCleanup] 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("[QuarkCleanup] 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) -> "QuarkCleanup":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
89
cloudsearch_transfer/adapter/quark/credential.py
Normal file
89
cloudsearch_transfer/adapter/quark/credential.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
CloudSearch Transfer — 夸克网盘凭证管理 v1.0.0
|
||||
|
||||
夸克网盘使用 Cookie 直传,无需 token 刷新机制。
|
||||
验证方式:检查 Cookie 字符串长度是否 >= 50。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuarkCredentialManager:
|
||||
"""夸克网盘凭证管理器。
|
||||
|
||||
夸克网盘的上传/转存 API 直接从 Cookie 中读取认证信息,
|
||||
无需 OAuth 或 refresh_token 刷新流程。
|
||||
|
||||
Attributes:
|
||||
cookie: 存储的夸克 Cookie 字符串。
|
||||
"""
|
||||
|
||||
# 夸克 Cookie 最小长度阈值(经验值,正常 Cookie 远超此长度)
|
||||
MIN_COOKIE_LENGTH: int = 50
|
||||
|
||||
def __init__(self, cookie: str = "") -> None:
|
||||
"""初始化凭证管理器。
|
||||
|
||||
Args:
|
||||
cookie: 夸克网盘的 Cookie 字符串。
|
||||
"""
|
||||
self.cookie: str = cookie
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""验证 Cookie 是否满足最小长度要求。
|
||||
|
||||
Returns:
|
||||
True 表示 Cookie 长度 >= MIN_COOKIE_LENGTH,否则为 False。
|
||||
"""
|
||||
if not self.cookie:
|
||||
logger.warning("[QuarkCredential] Cookie is empty")
|
||||
return False
|
||||
|
||||
valid = len(self.cookie) >= self.MIN_COOKIE_LENGTH
|
||||
if not valid:
|
||||
logger.warning(
|
||||
"[QuarkCredential] Cookie too short: len=%d, min=%d",
|
||||
len(self.cookie),
|
||||
self.MIN_COOKIE_LENGTH,
|
||||
)
|
||||
return valid
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""validate() 的别名,便于适配器层调用。"""
|
||||
return self.validate()
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""构建带 Cookie 认证的 HTTP 请求头。
|
||||
|
||||
夸克 API 需要在每次请求头中携带完整的 Cookie 字符串。
|
||||
|
||||
Returns:
|
||||
包含 Cookie 字段的请求头字典。Cookie 无效时仍返回空字典。
|
||||
"""
|
||||
if not self.validate():
|
||||
logger.warning("[QuarkCredential] Cannot build headers: cookie invalid")
|
||||
return {}
|
||||
|
||||
return {
|
||||
"Cookie": self.cookie,
|
||||
}
|
||||
|
||||
def update_cookie(self, cookie: str) -> None:
|
||||
"""更新 Cookie 字符串(用于手动刷新场景)。
|
||||
|
||||
Args:
|
||||
cookie: 新的 Cookie 字符串。
|
||||
"""
|
||||
self.cookie = cookie
|
||||
logger.info("[QuarkCredential] Cookie updated, new length=%d", len(cookie))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"QuarkCredentialManager(cookie_len={len(self.cookie) if self.cookie else 0}, "
|
||||
f"valid={self.validate()})"
|
||||
)
|
||||
554
cloudsearch_transfer/adapter/quark/transfer.py
Normal file
554
cloudsearch_transfer/adapter/quark/transfer.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
CloudSearch Transfer — 夸克网盘转存核心 v1.0.0
|
||||
|
||||
夸克网盘 7 步转存流程:
|
||||
|
||||
① POST .../share/sharepage/token → stoken
|
||||
② GET .../share/sharepage/detail → fid, share_fid_token, title
|
||||
③ POST .../share/sharepage/save → task_id (转存任务)
|
||||
④ 轮询 GET .../task → save_as_top_fids (status==2 完成)
|
||||
⑤ POST .../share → task_id (创建分享任务)
|
||||
⑥ 轮询 GET .../task → share_id
|
||||
⑦ POST .../share/password → share_url, passcode
|
||||
|
||||
参考 cloud-auto-save 的 quark.py 实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import QuarkCredentialManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 夸克 API 基础地址 ──────────────────────────────────────────────
|
||||
QUARK_API_BASE = "https://drive-pc.quark.cn"
|
||||
QUARK_SHARE_API = f"{QUARK_API_BASE}/1/clouddrive/share"
|
||||
|
||||
# ─── URL 解析正则 ───────────────────────────────────────────────────
|
||||
# 匹配 pan.quark.cn/s/<share_id>
|
||||
SHARE_URL_PATTERN = re.compile(r"pan\.quark\.cn/s/(\w+)")
|
||||
|
||||
|
||||
class QuarkTransfer:
|
||||
"""夸克网盘转存引擎。
|
||||
|
||||
封装完整的 7 步 API 流程:获取 stoken → 获取详情 → 保存文件 →
|
||||
创建分享 → 设置密码。
|
||||
|
||||
Attributes:
|
||||
credential: 夸克凭证管理器实例。
|
||||
session: 复用的 requests.Session。
|
||||
timeout: 请求超时(秒)。
|
||||
poll_interval: 轮询间隔(秒)。
|
||||
poll_max_attempts: 最大轮询次数。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: QuarkCredentialManager,
|
||||
timeout: int = 30,
|
||||
poll_interval: float = 0.5,
|
||||
poll_max_attempts: int = 50,
|
||||
) -> None:
|
||||
"""初始化转存引擎。
|
||||
|
||||
Args:
|
||||
credential: 有效的夸克凭证管理器。
|
||||
timeout: HTTP 请求超时秒数。
|
||||
poll_interval: 异步任务轮询间隔秒数。
|
||||
poll_max_attempts: 异步任务最大轮询次数(默认 50,同 base 层配置)。
|
||||
"""
|
||||
self.credential: QuarkCredentialManager = 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()
|
||||
|
||||
# ─── 步骤 ①:获取 stoken ───────────────────────────────────────
|
||||
|
||||
def _get_stoken(self, pwd_id: str, passcode: str = "") -> str:
|
||||
"""步骤①:向夸克交换 stoken。
|
||||
|
||||
POST /1/clouddrive/share/sharepage/token
|
||||
Body: {"passcode": "", "pwd_id": "<share_id>"}
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID(从 URL 解析)。
|
||||
passcode: 分享提取码,无密码时为空字符串。
|
||||
|
||||
Returns:
|
||||
stoken 字符串。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误或 stoken 缺失。
|
||||
"""
|
||||
url = f"{QUARK_SHARE_API}/sharepage/token"
|
||||
body: Dict[str, str] = {
|
||||
"passcode": passcode,
|
||||
"pwd_id": pwd_id,
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkTransfer] ① Getting stoken for pwd_id=%s", pwd_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"获取 stoken 失败: {exc}") from exc
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
stoken: Optional[str] = data.get("data", {}).get("stoken")
|
||||
if not stoken:
|
||||
raise RuntimeError(f"stoken 缺失, response: {data}")
|
||||
|
||||
logger.info("[QuarkTransfer] ① stoken obtained")
|
||||
return stoken
|
||||
|
||||
# ─── 步骤 ②:获取分享详情 ─────────────────────────────────────
|
||||
|
||||
def _get_detail(self, pwd_id: str, stoken: str) -> Dict[str, Any]:
|
||||
"""步骤②:获取分享详情。
|
||||
|
||||
GET /1/clouddrive/share/sharepage/detail?pwd_id=xx&stoken=xx&_fetch_share=1
|
||||
|
||||
返回字段包含:title, fid, share_fid_token 等。
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID。
|
||||
stoken: 步骤①获取的 stoken。
|
||||
|
||||
Returns:
|
||||
分享详情字典。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{QUARK_SHARE_API}/sharepage/detail"
|
||||
params: Dict[str, str] = {
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"_fetch_share": "1",
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
logger.info("[QuarkTransfer] ② Fetching share detail for pwd_id=%s", pwd_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()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0 and data.get("code") not in (0, None):
|
||||
raise RuntimeError(f"分享详情API返回错误: status={status}, message={data.get('message')}")
|
||||
|
||||
detail: Optional[Dict[str, Any]] = data.get("data")
|
||||
if not detail:
|
||||
raise RuntimeError(f"分享详情数据为空, response: {data}")
|
||||
|
||||
# 提取关键字段供后续使用
|
||||
logger.info(
|
||||
"[QuarkTransfer] ② Detail: title=%s, fid=%s",
|
||||
detail.get("title"),
|
||||
detail.get("fid"),
|
||||
)
|
||||
return detail
|
||||
|
||||
# ─── 步骤 ③:发起转存 ─────────────────────────────────────────
|
||||
|
||||
def _init_save(self, pwd_id: str, stoken: str, detail: Dict[str, Any],
|
||||
to_pdir_fid: str = "0") -> str:
|
||||
"""步骤③:发起转存请求。
|
||||
|
||||
POST /1/clouddrive/share/sharepage/save
|
||||
Body: {
|
||||
"fid_list": [<fid>, ...],
|
||||
"fid_token_list": [<share_fid_token>, ...],
|
||||
"to_pdir_fid": "0",
|
||||
"pwd_id": "<pwd_id>",
|
||||
"stoken": "<stoken>",
|
||||
"pdir_fid": "0",
|
||||
"scene": "link"
|
||||
}
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID。
|
||||
stoken: stoken。
|
||||
detail: 步骤②的分享详情。
|
||||
to_pdir_fid: 目标目录 ID,默认 "0" 即根目录。
|
||||
|
||||
Returns:
|
||||
task_id 字符串,用于步骤④轮询。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{QUARK_SHARE_API}/sharepage/save"
|
||||
fid_list: List[str] = detail.get("fid_list", [detail.get("fid", [])])
|
||||
fid_token_list: List[str] = detail.get("fid_token_list", [detail.get("share_fid_token", [])])
|
||||
|
||||
# 如果 detail 的 fid/fid_token 是单值而非列表,则包装为列表
|
||||
if not isinstance(fid_list, list):
|
||||
fid_list = [fid_list] if fid_list else []
|
||||
if not isinstance(fid_token_list, list):
|
||||
fid_token_list = [fid_token_list] if fid_token_list else []
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"fid_list": fid_list,
|
||||
"fid_token_list": fid_token_list,
|
||||
"to_pdir_fid": to_pdir_fid,
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"scene": "link",
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkTransfer] ③ Initiating save: %d files to dir=%s", len(fid_list), to_pdir_fid)
|
||||
|
||||
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()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0:
|
||||
raise RuntimeError(f"转存请求失败: status={status}, message={data.get('message')}")
|
||||
|
||||
task_id: Optional[str] = data.get("data", {}).get("task_id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"转存 task_id 缺失, response: {data}")
|
||||
|
||||
logger.info("[QuarkTransfer] ③ Save task created: task_id=%s", task_id)
|
||||
return task_id
|
||||
|
||||
# ─── 步骤 ④:轮询转存任务 ─────────────────────────────────────
|
||||
|
||||
def _poll_save_task(self, task_id: str) -> List[str]:
|
||||
"""步骤④:轮询转存任务直到完成。
|
||||
|
||||
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
|
||||
|
||||
轮询最多 poll_max_attempts 次,
|
||||
当 status==2 时表示任务成功完成,
|
||||
status==-1 表示失败。
|
||||
|
||||
Args:
|
||||
task_id: 步骤③返回的 task_id。
|
||||
|
||||
Returns:
|
||||
save_as_top_fids 列表(转存后的文件 ID)。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 任务失败或超时。
|
||||
"""
|
||||
url = f"{QUARK_API_BASE}/1/clouddrive/task"
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
for attempt in range(1, self.poll_max_attempts + 1):
|
||||
params: Dict[str, str] = {
|
||||
"task_id": task_id,
|
||||
"retry_index": "0",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
logger.warning("[QuarkTransfer] ④ Poll attempt %d/%d failed, retrying...",
|
||||
attempt, self.poll_max_attempts)
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
task_status: int = data.get("data", {}).get("status", -1)
|
||||
|
||||
logger.debug("[QuarkTransfer] ④ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
|
||||
|
||||
if task_status == 2: # 成功
|
||||
save_as_top_fids: List[str] = (
|
||||
data.get("data", {}).get("save_as", {}).get("save_as_top_fids", [])
|
||||
)
|
||||
logger.info("[QuarkTransfer] ④ Save completed: %d files saved", len(save_as_top_fids))
|
||||
return save_as_top_fids
|
||||
|
||||
if task_status == -1:
|
||||
raise RuntimeError(f"转存任务失败: task_id={task_id}, response={data}")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
raise RuntimeError(
|
||||
f"转存任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts} 次"
|
||||
)
|
||||
|
||||
# ─── 步骤 ⑤:发起创建分享 ─────────────────────────────────────
|
||||
|
||||
def _init_share(self, fid_list: List[str], title: str,
|
||||
expired_type: int = 1) -> str:
|
||||
"""步骤⑤:创建分享链接。
|
||||
|
||||
POST /1/clouddrive/share
|
||||
Body: {
|
||||
"fid_list": [<fid>, ...],
|
||||
"title": "<title>",
|
||||
"expired_type": 1
|
||||
}
|
||||
|
||||
Args:
|
||||
fid_list: 要分享的文件 ID 列表。
|
||||
title: 分享标题。
|
||||
expired_type: 过期类型,1=永久有效(默认)。
|
||||
|
||||
Returns:
|
||||
task_id 字符串,用于步骤⑥轮询。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{QUARK_SHARE_API}"
|
||||
body: Dict[str, Any] = {
|
||||
"fid_list": fid_list,
|
||||
"title": title or "分享",
|
||||
"expired_type": expired_type,
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkTransfer] ⑤ Creating share: %d files, title='%s'", len(fid_list), title)
|
||||
|
||||
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()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0 and data.get("code") not in (0, None):
|
||||
raise RuntimeError(f"创建分享请求失败: status={status}, message={data.get('message')}")
|
||||
|
||||
task_id: Optional[str] = data.get("data", {}).get("task_id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"分享 task_id 缺失, response: {data}")
|
||||
|
||||
logger.info("[QuarkTransfer] ⑤ Share task created: task_id=%s", task_id)
|
||||
return task_id
|
||||
|
||||
# ─── 步骤 ⑥:轮询分享任务 ─────────────────────────────────────
|
||||
|
||||
def _poll_share_task(self, task_id: str) -> str:
|
||||
"""步骤⑥:轮询分享任务直到完成。
|
||||
|
||||
GET /1/clouddrive/task?task_id=<task_id>&retry_index=0
|
||||
|
||||
轮询最多 poll_max_attempts 次,status==2 完成,
|
||||
返回 share_id。
|
||||
|
||||
Args:
|
||||
task_id: 步骤⑤返回的 task_id。
|
||||
|
||||
Returns:
|
||||
share_id 字符串。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 任务失败或超时。
|
||||
"""
|
||||
url = f"{QUARK_API_BASE}/1/clouddrive/task"
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
for attempt in range(1, self.poll_max_attempts + 1):
|
||||
params: Dict[str, str] = {
|
||||
"task_id": task_id,
|
||||
"retry_index": "0",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
logger.warning("[QuarkTransfer] ⑥ Poll attempt %d/%d failed, retrying...",
|
||||
attempt, self.poll_max_attempts)
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
|
||||
data: Dict[str, Any] = resp.json()
|
||||
task_status: int = data.get("data", {}).get("status", -1)
|
||||
|
||||
logger.debug("[QuarkTransfer] ⑥ Poll %d/%d: status=%d", attempt, self.poll_max_attempts, task_status)
|
||||
|
||||
if task_status == 2: # 成功
|
||||
share_id: Optional[str] = data.get("data", {}).get("share_id")
|
||||
if not share_id:
|
||||
# 有时 share_id 在嵌套位置
|
||||
share_id = data.get("data", {}).get("result", {}).get("share_id", "")
|
||||
if not share_id:
|
||||
raise RuntimeError(f"分享完成但 share_id 缺失: {data}")
|
||||
logger.info("[QuarkTransfer] ⑥ Share completed: share_id=%s", share_id)
|
||||
return share_id
|
||||
|
||||
if task_status == -1:
|
||||
raise RuntimeError(f"分享任务失败: task_id={task_id}, response={data}")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
raise RuntimeError(
|
||||
f"分享任务超时: task_id={task_id}, 已轮询 {self.poll_max_attempts} 次"
|
||||
)
|
||||
|
||||
# ─── 步骤 ⑦:设置分享密码 ─────────────────────────────────────
|
||||
|
||||
def _set_password(self, share_id: str, password: str = "") -> Tuple[str, str]:
|
||||
"""步骤⑦:设置分享密码并获取分享链接。
|
||||
|
||||
POST /1/clouddrive/share/password
|
||||
Body: {"share_id": "<share_id>"}
|
||||
|
||||
即使不设密码也要调用此 API 以获取正式的 share_url。
|
||||
|
||||
Args:
|
||||
share_id: 步骤⑥返回的 share_id。
|
||||
password: 分享密码,空字符串表示无密码。
|
||||
|
||||
Returns:
|
||||
(share_url, passcode) 元组。
|
||||
|
||||
Raises:
|
||||
RuntimeError: API 返回错误。
|
||||
"""
|
||||
url = f"{QUARK_SHARE_API}/password"
|
||||
body: Dict[str, str] = {
|
||||
"share_id": share_id,
|
||||
}
|
||||
headers = self.credential.get_headers()
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
logger.info("[QuarkTransfer] ⑦ Setting password for share_id=%s", 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()
|
||||
status: int = data.get("status", -1)
|
||||
if status != 0 and data.get("code") not in (0, None):
|
||||
raise RuntimeError(f"设置密码失败: status={status}, message={data.get('message')}")
|
||||
|
||||
share_url: str = data.get("data", {}).get("share_url", "")
|
||||
passcode: str = data.get("data", {}).get("passcode", password)
|
||||
|
||||
if not share_url:
|
||||
# 用 share_id 构造默认分享链接
|
||||
share_url = f"https://pan.quark.cn/s/{share_id}"
|
||||
|
||||
logger.info("[QuarkTransfer] ⑦ Password set: share_url=%s, passcode=%s", share_url, passcode)
|
||||
return share_url, passcode
|
||||
|
||||
# ─── 公开入口 ─────────────────────────────────────────────────
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
share_url: str,
|
||||
save_dir: str = "0",
|
||||
share_password: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""执行完整的 7 步转存流程。
|
||||
|
||||
从原始夸克分享链接开始,将文件转存到自己网盘,再创建新分享。
|
||||
|
||||
Args:
|
||||
share_url: 原始夸克分享链接,如 https://pan.quark.cn/s/xxxxx。
|
||||
save_dir: 转存目标目录 ID,默认 "0"(根目录)。
|
||||
share_password: 新分享的密码,空字符串表示无密码。
|
||||
|
||||
Returns:
|
||||
包含以下字段的字典:
|
||||
- success: bool
|
||||
- new_file_ids: List[str] — 转存后的文件ID列表
|
||||
- file_name: str — 分享标题
|
||||
- share_url: str — 新分享链接
|
||||
- passcode: str — 新分享密码
|
||||
|
||||
Raises:
|
||||
RuntimeError: 任一步骤失败。
|
||||
ValueError: URL 解析失败。
|
||||
"""
|
||||
# 0. 解析 URL 提取 pwd_id
|
||||
match = SHARE_URL_PATTERN.search(share_url)
|
||||
if not match:
|
||||
raise ValueError(f"无法从URL中提取夸克分享ID: {share_url}")
|
||||
pwd_id: str = match.group(1)
|
||||
|
||||
logger.info("[QuarkTransfer] Starting 7-step transfer for pwd_id=%s", pwd_id)
|
||||
|
||||
# ① 获取 stoken
|
||||
stoken: str = self._get_stoken(pwd_id)
|
||||
|
||||
# ② 获取分享详情
|
||||
detail: Dict[str, Any] = self._get_detail(pwd_id, stoken)
|
||||
|
||||
# ③ 发起转存
|
||||
save_task_id: str = self._init_save(pwd_id, stoken, detail, to_pdir_fid=save_dir)
|
||||
|
||||
# ④ 轮询转存任务
|
||||
new_fids: List[str] = self._poll_save_task(save_task_id)
|
||||
if not new_fids:
|
||||
raise RuntimeError("转存完成但未获取到文件ID")
|
||||
|
||||
# ⑤ 发起创建分享
|
||||
title: str = detail.get("title", "分享")
|
||||
share_task_id: str = self._init_share(new_fids, title)
|
||||
|
||||
# ⑥ 轮询分享任务
|
||||
share_id: str = self._poll_share_task(share_task_id)
|
||||
|
||||
# ⑦ 设置密码
|
||||
new_share_url, passcode = self._set_password(share_id, share_password)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"new_file_ids": new_fids,
|
||||
"file_name": title,
|
||||
"share_url": new_share_url,
|
||||
"passcode": passcode,
|
||||
}
|
||||
logger.info("[QuarkTransfer] 7-step transfer complete: %s", result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def parse_share_url(url: str) -> Optional[str]:
|
||||
"""从夸克分享链接中提取 pwd_id。
|
||||
|
||||
Args:
|
||||
url: 夸克分享链接。
|
||||
|
||||
Returns:
|
||||
pwd_id 字符串,解析失败返回 None。
|
||||
"""
|
||||
match = SHARE_URL_PATTERN.search(url)
|
||||
return match.group(1) if match else None
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 HTTP 会话。"""
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self) -> "QuarkTransfer":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
Reference in New Issue
Block a user