v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
297
cloudsearch_transfer/adapter/aliyun/__init__.py
Normal file
297
cloudsearch_transfer/adapter/aliyun/__init__.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
阿里云盘适配器 v1.0.0
|
||||
AliyunAdapter — 继承 BaseCloudDriveAdapter,实现阿里云盘全部转存能力。
|
||||
|
||||
组件:
|
||||
- AliyunCredentialManager: refresh_token 刷新 + 缓存
|
||||
- AliyunTransfer: 4 步批量转存
|
||||
- AliyunCleanup: 回收站清理
|
||||
|
||||
URL 匹配: aliyundrive.com/s/<share_id>
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
|
||||
from ..config import PlatformConfig, TransferConfig
|
||||
from ..errors import TransferError, TransferErrorCode
|
||||
|
||||
from .credential import AliyunCredentialManager
|
||||
from .transfer import AliyunTransfer
|
||||
from .cleanup import AliyunCleanup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AliyunAdapter(BaseCloudDriveAdapter):
|
||||
"""阿里云盘适配器"""
|
||||
|
||||
PLATFORM_NAME = "阿里云盘"
|
||||
PLATFORM_KEY = "aliyun"
|
||||
|
||||
URL_PATTERNS = [
|
||||
r'aliyundrive\.com/s/([a-zA-Z0-9]+)',
|
||||
r'alipan\.com/s/([a-zA-Z0-9]+)',
|
||||
]
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/135.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"Referer": "https://aliyundrive.com",
|
||||
}
|
||||
|
||||
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
|
||||
super().__init__(config, transfer_config)
|
||||
|
||||
# 创建凭证管理器(AliyunCredentialManager)
|
||||
refresh_token = config.refresh_token or config.cookie or ""
|
||||
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
|
||||
|
||||
# 初始化 drive_id
|
||||
self._drive_id = ""
|
||||
|
||||
# 创建子模块
|
||||
self._transfer: Optional[AliyunTransfer] = None
|
||||
self._cleanup: Optional[AliyunCleanup] = None
|
||||
|
||||
def _setup_session(self):
|
||||
"""初始化 session 和凭证"""
|
||||
if self._credential.refresh_token:
|
||||
# 验证 refresh_token 并获取 drive_id
|
||||
if self._credential.validate():
|
||||
self._drive_id = self._credential.get_drive_id()
|
||||
logger.info(
|
||||
f"[AliyunAdapter] 凭证验证成功, drive_id={self._drive_id[:8]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning("[AliyunAdapter] 凭证验证失败,转存功能可能不可用")
|
||||
else:
|
||||
logger.warning("[AliyunAdapter] 未配置 refresh_token")
|
||||
|
||||
# ─── 核心抽象方法实现 ──────────────────────────────────
|
||||
|
||||
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
|
||||
"""
|
||||
获取分享详情。
|
||||
步骤①②: 先获取匿名分享信息,再获取 share_token。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"title": "分享标题",
|
||||
"share_id": "...",
|
||||
"share_token": "...",
|
||||
"files": [{"file_id": "...", "name": "...", "size": 0, "type": "file"}, ...],
|
||||
}
|
||||
"""
|
||||
try:
|
||||
transfer = self._get_transfer()
|
||||
|
||||
# ① 获取分享信息(匿名)
|
||||
share_info = transfer._get_share_info(pwd_id)
|
||||
if not share_info:
|
||||
raise TransferError(
|
||||
TransferErrorCode.SHARE_NOT_EXIST,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
# ② 获取分享令牌(Auth)
|
||||
share_token = transfer._get_share_token(pwd_id, passcode)
|
||||
if not share_token:
|
||||
raise TransferError(
|
||||
TransferErrorCode.PASSCODE_WRONG if passcode else TransferErrorCode.SHARE_NOT_EXIST,
|
||||
platform=self.PLATFORM_KEY,
|
||||
message="获取分享令牌失败(可能需要提取码)",
|
||||
)
|
||||
|
||||
return {
|
||||
"title": share_info.get("share_name", share_info.get("share_title", "")),
|
||||
"share_id": pwd_id,
|
||||
"share_token": share_token,
|
||||
"files": share_info.get("file_infos", []),
|
||||
"creator_name": share_info.get("creator_name", ""),
|
||||
}
|
||||
|
||||
except TransferError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunAdapter] 获取分享详情失败: {e}")
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message=str(e),
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
|
||||
"""
|
||||
步骤③: 批量复制文件到自己的网盘。
|
||||
|
||||
Args:
|
||||
pwd_id: 分享 ID
|
||||
detail: _get_share_detail 的返回值
|
||||
save_dir: 目标目录(根目录用 "root")
|
||||
|
||||
Returns:
|
||||
新文件 ID 列表
|
||||
"""
|
||||
share_token = detail.get("share_token", "")
|
||||
files = detail.get("files", [])
|
||||
|
||||
if not share_token:
|
||||
raise TransferError(
|
||||
TransferErrorCode.SHARE_NOT_EXIST,
|
||||
message="缺少 share_token",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
if not files:
|
||||
raise TransferError(
|
||||
TransferErrorCode.RESOURCE_EMPTY,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
file_ids = [f.get("file_id", "") for f in files if f.get("file_id")]
|
||||
if not file_ids:
|
||||
raise TransferError(
|
||||
TransferErrorCode.RESOURCE_EMPTY,
|
||||
message="无法提取文件 ID",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
# 确定目标目录
|
||||
to_parent = save_dir if save_dir and save_dir != "/" else "root"
|
||||
|
||||
transfer = self._get_transfer()
|
||||
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
|
||||
|
||||
if not new_ids:
|
||||
raise TransferError(
|
||||
TransferErrorCode.NETWORK_ERROR,
|
||||
message="批量转存失败,所有文件复制均失败",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
return new_ids
|
||||
|
||||
def _create_share(
|
||||
self, file_ids: List[str], title: str, password: str = ""
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
步骤④: 创建新分享链接。
|
||||
|
||||
Returns:
|
||||
(share_url, share_password)
|
||||
"""
|
||||
if not file_ids:
|
||||
raise TransferError(
|
||||
TransferErrorCode.RESOURCE_EMPTY,
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
transfer = self._get_transfer()
|
||||
result = transfer._create_share(file_ids, password)
|
||||
|
||||
share_url = result.get("share_url", "")
|
||||
share_pwd = result.get("share_pwd", password)
|
||||
|
||||
if not share_url:
|
||||
raise TransferError(
|
||||
TransferErrorCode.SHARE_LINK_FAIL,
|
||||
message="创建分享链接失败",
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
return share_url, share_pwd
|
||||
|
||||
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
|
||||
"""
|
||||
列出网盘目录下的文件。
|
||||
|
||||
NOTE: 当前实现为占位。如需完整功能,请调用阿里云盘 /adrive/v3/file/list API。
|
||||
"""
|
||||
logger.warning("[AliyunAdapter] get_files() 未完整实现,返回空列表")
|
||||
return []
|
||||
|
||||
def delete(self, file_ids: List[str]) -> bool:
|
||||
"""
|
||||
删除文件(移入回收站)。
|
||||
|
||||
Args:
|
||||
file_ids: 要删除的文件 ID 列表
|
||||
|
||||
Returns:
|
||||
是否全部删除成功
|
||||
"""
|
||||
if not file_ids:
|
||||
return True
|
||||
|
||||
cleanup = self._get_cleanup()
|
||||
result = cleanup.delete_files(file_ids)
|
||||
return result.get("success", False)
|
||||
|
||||
# ─── 扩展功能 ──────────────────────────────────────────
|
||||
|
||||
def cleanup_files(self, file_ids: List[str]) -> Dict:
|
||||
"""
|
||||
清理文件(移入回收站),返回详细结果。
|
||||
|
||||
Returns:
|
||||
AliyunCleanup.delete_files() 的返回字典
|
||||
"""
|
||||
cleanup = self._get_cleanup()
|
||||
return cleanup.delete_files(file_ids)
|
||||
|
||||
def force_refresh_token(self) -> bool:
|
||||
"""强制刷新 access_token"""
|
||||
return self._credential.refresh()
|
||||
|
||||
def get_credential_status(self) -> Dict:
|
||||
"""获取当前凭证状态"""
|
||||
return self._credential.to_dict()
|
||||
|
||||
# ─── 文件列表提取 ──────────────────────────────────────
|
||||
|
||||
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
|
||||
"""从分享详情中提取 FileInfo 列表"""
|
||||
files = detail.get("files", [])
|
||||
result = []
|
||||
for f in files:
|
||||
result.append(FileInfo(
|
||||
fid=f.get("file_id", ""),
|
||||
name=f.get("name", ""),
|
||||
size=int(f.get("size", 0)),
|
||||
is_dir=f.get("type", "") == "folder",
|
||||
ext=f.get("file_extension", ""),
|
||||
))
|
||||
return result
|
||||
|
||||
# ─── 内部辅助方法 ──────────────────────────────────────
|
||||
|
||||
def _get_transfer(self) -> AliyunTransfer:
|
||||
"""懒加载获取 AliyunTransfer 实例"""
|
||||
if self._transfer is None:
|
||||
drive_id = self._drive_id or self._credential.get_drive_id()
|
||||
self._transfer = AliyunTransfer(
|
||||
credential=self._credential,
|
||||
drive_id=drive_id,
|
||||
to_parent_file_id=self.config.save_dir or "root",
|
||||
request_timeout=self.transfer_config.request_timeout,
|
||||
)
|
||||
return self._transfer
|
||||
|
||||
def _get_cleanup(self) -> AliyunCleanup:
|
||||
"""懒加载获取 AliyunCleanup 实例"""
|
||||
if self._cleanup is None:
|
||||
drive_id = self._drive_id or self._credential.get_drive_id()
|
||||
self._cleanup = AliyunCleanup(
|
||||
credential=self._credential,
|
||||
drive_id=drive_id,
|
||||
request_timeout=self.transfer_config.request_timeout,
|
||||
)
|
||||
return self._cleanup
|
||||
203
cloudsearch_transfer/adapter/aliyun/cleanup.py
Normal file
203
cloudsearch_transfer/adapter/aliyun/cleanup.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
阿里云盘回收站清理模块 v1.0.0
|
||||
将文件移入回收站(非直接删除),支持批量操作。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import AliyunCredentialManager, API_HOST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── API 端点 ──────────────────────────────────────────────
|
||||
|
||||
# 批量操作(v4)
|
||||
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
|
||||
|
||||
# 默认请求头
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/135.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"Referer": "https://aliyundrive.com",
|
||||
}
|
||||
|
||||
|
||||
class AliyunCleanup:
|
||||
"""
|
||||
阿里云盘回收站清理
|
||||
|
||||
将文件移入回收站(放入回收站,非永久删除)。
|
||||
使用 v4 批量接口,支持一次清理多个文件。
|
||||
|
||||
用法:
|
||||
credential = AliyunCredentialManager(refresh_token="xxx")
|
||||
cleanup = AliyunCleanup(credential, drive_id="12345")
|
||||
result = cleanup.delete_files(["file_id_1", "file_id_2"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: AliyunCredentialManager,
|
||||
drive_id: str = "",
|
||||
request_timeout: int = 30,
|
||||
):
|
||||
self.credential = credential
|
||||
self.drive_id = drive_id or credential.get_drive_id()
|
||||
self.request_timeout = request_timeout
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(DEFAULT_HEADERS)
|
||||
|
||||
# ─── 公开 API ──────────────────────────────────────────
|
||||
|
||||
def delete_files(self, file_ids: List[str]) -> Dict:
|
||||
"""
|
||||
将指定文件移入回收站(批量)。
|
||||
|
||||
Args:
|
||||
file_ids: 要删除的文件 ID 列表
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True/False,
|
||||
"deleted_count": 成功删除数量,
|
||||
"total_count": 总文件数,
|
||||
"failed_ids": 失败的文件 ID 列表,
|
||||
"error": None or "错误信息",
|
||||
}
|
||||
|
||||
实现:
|
||||
POST /adrive/v4/batch
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"url": "/recyclebin/trash",
|
||||
"body": {"file_id": "...", "drive_id": "..."},
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"id": "...",
|
||||
"method": "POST"
|
||||
}
|
||||
],
|
||||
"resource": "file"
|
||||
}
|
||||
"""
|
||||
if not file_ids:
|
||||
return self._error("文件 ID 列表为空")
|
||||
|
||||
drive_id = self.drive_id
|
||||
if not drive_id:
|
||||
drive_id = self.credential.get_drive_id()
|
||||
if not drive_id:
|
||||
return self._error("缺少 drive_id,无法执行删除操作")
|
||||
|
||||
# 构建批量请求体
|
||||
requests_list = []
|
||||
for fid in file_ids:
|
||||
requests_list.append({
|
||||
"url": "/recyclebin/trash",
|
||||
"body": {
|
||||
"drive_id": drive_id,
|
||||
"file_id": fid,
|
||||
},
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"id": fid,
|
||||
"method": "POST",
|
||||
})
|
||||
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
resp = self._session.post(
|
||||
BATCH_URL,
|
||||
json={"requests": requests_list, "resource": "file"},
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunCleanup] 批量删除失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return self._error(f"HTTP {resp.status_code}")
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunCleanup] 批量删除 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return self._error(data.get("message", f"API code={code}"))
|
||||
|
||||
# 统计结果
|
||||
responses = data.get("responses", [])
|
||||
success_ids = []
|
||||
failed_ids = []
|
||||
|
||||
for item in responses:
|
||||
status = item.get("status", 0)
|
||||
fid = item.get("id", "")
|
||||
if status in (200, 201, 202):
|
||||
success_ids.append(fid)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[AliyunCleanup] 删除文件失败: "
|
||||
f"id={fid}, status={status}, body={item.get('body', {})}"
|
||||
)
|
||||
failed_ids.append(fid)
|
||||
|
||||
logger.info(
|
||||
f"[AliyunCleanup] 删除完成: "
|
||||
f"成功={len(success_ids)}, 失败={len(failed_ids)}, 总计={len(file_ids)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": len(failed_ids) == 0,
|
||||
"deleted_count": len(success_ids),
|
||||
"total_count": len(file_ids),
|
||||
"success_ids": success_ids,
|
||||
"failed_ids": failed_ids,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunCleanup] 批量删除网络异常: {e}")
|
||||
return self._error(str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunCleanup] 批量删除异常: {e}")
|
||||
return self._error(str(e))
|
||||
|
||||
def empty_recycle_bin(self) -> Dict:
|
||||
"""
|
||||
清空回收站(永久删除回收站中的所有文件)。
|
||||
|
||||
NOTE: 阿里云盘 API 目前不直接支持清空回收站,
|
||||
此方法作为占位,需要逐个文件 ID 调用 delete_files。
|
||||
实际使用请先 list 回收站内容再调用 delete_files。
|
||||
|
||||
Returns:
|
||||
{"success": False, "error": "清空回收站需要通过 list + delete 两步完成"}
|
||||
"""
|
||||
logger.warning("[AliyunCleanup] 清空回收站 API 暂未实现,需要 list+delete 两步")
|
||||
return self._error("清空回收站需要通过列出回收站内容 + 逐个删除两步完成,尚未实现")
|
||||
|
||||
# ─── 工具方法 ──────────────────────────────────────────
|
||||
|
||||
def _error(self, message: str) -> Dict:
|
||||
"""构造错误返回"""
|
||||
return {
|
||||
"success": False,
|
||||
"deleted_count": 0,
|
||||
"total_count": 0,
|
||||
"success_ids": [],
|
||||
"failed_ids": [],
|
||||
"error": message,
|
||||
}
|
||||
216
cloudsearch_transfer/adapter/aliyun/credential.py
Normal file
216
cloudsearch_transfer/adapter/aliyun/credential.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
阿里云盘凭证管理器 v1.0.0
|
||||
refresh_token → access_token 刷新 + 自动缓存 + 过期前自动刷新
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 常量 ──────────────────────────────────────────────────
|
||||
|
||||
API_HOST = "https://api.aliyundrive.com"
|
||||
TOKEN_REFRESH_URL = f"{API_HOST}/token/refresh"
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/135.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenInfo:
|
||||
"""缓存的 Token 信息"""
|
||||
access_token: str = ""
|
||||
refresh_token: str = ""
|
||||
expires_at: float = 0.0 # Unix 时间戳
|
||||
drive_id: str = ""
|
||||
user_id: str = ""
|
||||
nick_name: str = ""
|
||||
default_sbox_drive_id: str = ""
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""检查 access_token 是否已过期(提前 60s 视为过期)"""
|
||||
return time.time() >= (self.expires_at - 60)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return bool(self.access_token) and not self.is_expired
|
||||
|
||||
|
||||
class AliyunCredentialManager:
|
||||
"""
|
||||
阿里云盘凭证管理器
|
||||
|
||||
职责:
|
||||
- 使用 refresh_token 换取 access_token
|
||||
- 缓存 access_token / expires_at / drive_id
|
||||
- 过期前自动刷新(提前 60s)
|
||||
- 线程安全
|
||||
|
||||
用法:
|
||||
mgr = AliyunCredentialManager(refresh_token="xxx")
|
||||
mgr.refresh() # 强制刷新
|
||||
headers = mgr.get_headers() # 获取带 Auth 的请求头
|
||||
is_ok = mgr.validate() # 验证 refresh_token 有效性
|
||||
"""
|
||||
|
||||
def __init__(self, refresh_token: str = ""):
|
||||
self._refresh_token = refresh_token.strip()
|
||||
self._token: Optional[TokenInfo] = None
|
||||
self._lock = threading.Lock()
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(DEFAULT_HEADERS)
|
||||
|
||||
# ─── 公开 API ──────────────────────────────────────────
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""
|
||||
使用 refresh_token 换取 access_token。
|
||||
返回 True 表示成功,False 表示失败。
|
||||
"""
|
||||
with self._lock:
|
||||
return self._do_refresh()
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
获取带 Authorization 的请求头。
|
||||
自动检查 token 有效性,必要时自动刷新。
|
||||
|
||||
Returns:
|
||||
{"Authorization": "Bearer <access_token>", ...}
|
||||
"""
|
||||
self._ensure_token_valid()
|
||||
headers = {}
|
||||
if self._token and self._token.access_token:
|
||||
headers["Authorization"] = f"Bearer {self._token.access_token}"
|
||||
return headers
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
"""获取当前有效的 access_token(必要时自动刷新)"""
|
||||
self._ensure_token_valid()
|
||||
return self._token.access_token if self._token else ""
|
||||
|
||||
def get_drive_id(self) -> str:
|
||||
"""获取默认 drive_id"""
|
||||
self._ensure_token_valid()
|
||||
return self._token.drive_id if self._token else ""
|
||||
|
||||
def get_sbox_drive_id(self) -> str:
|
||||
"""获取保险箱 drive_id"""
|
||||
self._ensure_token_valid()
|
||||
return self._token.default_sbox_drive_id if self._token else ""
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""
|
||||
验证 refresh_token 是否有效。
|
||||
要求 refresh_token 长度 >= 20,且能成功换取 access_token。
|
||||
"""
|
||||
if not self._refresh_token or len(self._refresh_token) < 20:
|
||||
logger.warning("[AliyunCredential] refresh_token 长度不足 20,验证失败")
|
||||
return False
|
||||
return self.refresh()
|
||||
|
||||
@property
|
||||
def refresh_token(self) -> str:
|
||||
return self._refresh_token
|
||||
|
||||
@refresh_token.setter
|
||||
def refresh_token(self, value: str):
|
||||
"""更新 refresh_token(通常在 API 返回新 refresh_token 后调用)"""
|
||||
self._refresh_token = value.strip()
|
||||
# 清除旧缓存,下次请求自动刷新
|
||||
with self._lock:
|
||||
self._token = None
|
||||
|
||||
# ─── 内部方法 ──────────────────────────────────────────
|
||||
|
||||
def _ensure_token_valid(self):
|
||||
"""确保 token 有效(过期则自动刷新)"""
|
||||
if self._token is None or self._token.is_expired:
|
||||
self.refresh()
|
||||
|
||||
def _do_refresh(self) -> bool:
|
||||
"""实际执行 token 刷新"""
|
||||
if not self._refresh_token:
|
||||
logger.error("[AliyunCredential] 没有 refresh_token,无法刷新")
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = self._session.post(
|
||||
TOKEN_REFRESH_URL,
|
||||
json={"refresh_token": self._refresh_token},
|
||||
timeout=30,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200 or "access_token" not in data:
|
||||
code = data.get("code", "Unknown")
|
||||
message = data.get("message", "")
|
||||
logger.error(
|
||||
f"[AliyunCredential] 刷新 token 失败: "
|
||||
f"HTTP {resp.status_code} code={code} msg={message}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 解析响应
|
||||
access_token = data.get("access_token", "")
|
||||
expires_in = int(data.get("expires_in", 7200))
|
||||
new_refresh = data.get("refresh_token", self._refresh_token)
|
||||
|
||||
self._token = TokenInfo(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh,
|
||||
expires_at=time.time() + expires_in,
|
||||
drive_id=str(data.get("default_drive_id", "")),
|
||||
user_id=str(data.get("user_id", "")),
|
||||
nick_name=str(data.get("nick_name", "")),
|
||||
default_sbox_drive_id=str(data.get("default_sbox_drive_id", "")),
|
||||
)
|
||||
|
||||
# 更新 refresh_token(服务端可能下发新的)
|
||||
if new_refresh != self._refresh_token:
|
||||
logger.info(
|
||||
"[AliyunCredential] refresh_token 已轮换,新旧前缀: "
|
||||
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
|
||||
)
|
||||
self._refresh_token = new_refresh
|
||||
|
||||
logger.info(
|
||||
f"[AliyunCredential] Token 刷新成功 "
|
||||
f"(user={self._token.nick_name}, "
|
||||
f"expires_in={expires_in}s, "
|
||||
f"drive_id={self._token.drive_id[:8]}...)"
|
||||
)
|
||||
return True
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunCredential] 刷新 token 网络异常: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunCredential] 刷新 token 未知异常: {e}")
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""导出当前状态(用于持久化)"""
|
||||
self._ensure_token_valid()
|
||||
return {
|
||||
"refresh_token": self._refresh_token,
|
||||
"access_token": self._token.access_token if self._token else "",
|
||||
"expires_at": self._token.expires_at if self._token else 0,
|
||||
"drive_id": self._token.drive_id if self._token else "",
|
||||
"user_id": self._token.user_id if self._token else "",
|
||||
"nick_name": self._token.nick_name if self._token else "",
|
||||
}
|
||||
493
cloudsearch_transfer/adapter/aliyun/transfer.py
Normal file
493
cloudsearch_transfer/adapter/aliyun/transfer.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
阿里云盘转存模块 v1.0.0
|
||||
实现 4 步批量转存流程:获取分享详情 → 获取分享令牌 → 批量复制文件 → 创建新分享
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .credential import AliyunCredentialManager, API_HOST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── API 端点 ──────────────────────────────────────────────
|
||||
|
||||
# ① 获取分享详情(匿名)
|
||||
SHARE_INFO_URL = f"{API_HOST}/adrive/v3/share_link/get_share_by_anonymous"
|
||||
|
||||
# ② 获取分享令牌(需 Auth)
|
||||
SHARE_TOKEN_URL = f"{API_HOST}/v2/share_link/get_share_token"
|
||||
|
||||
# ③ 批量操作(复制文件)
|
||||
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
|
||||
|
||||
# ④ 创建分享
|
||||
CREATE_SHARE_URL = f"{API_HOST}/adrive/v2/share_link/create"
|
||||
|
||||
# ─── URL 模式 ──────────────────────────────────────────────
|
||||
|
||||
# 匹配 aliyundrive.com/s/<share_id>
|
||||
URL_PATTERN = re.compile(r'aliyundrive\.com/s/([a-zA-Z0-9]+)')
|
||||
|
||||
# ─── 默认请求头 ────────────────────────────────────────────
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/135.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"Referer": "https://aliyundrive.com",
|
||||
}
|
||||
|
||||
|
||||
class AliyunTransfer:
|
||||
"""
|
||||
阿里云盘批量转存
|
||||
|
||||
四步流程:
|
||||
① 获取分享详情(匿名):POST /adrive/v3/share_link/get_share_by_anonymous
|
||||
② 获取分享令牌(Auth):POST /v2/share_link/get_share_token
|
||||
③ 批量复制文件:POST /adrive/v4/batch (X-Share-Token 头)
|
||||
④ 创建新分享:POST /adrive/v2/share_link/create
|
||||
|
||||
用法:
|
||||
credential = AliyunCredentialManager(refresh_token="xxx")
|
||||
transfer = AliyunTransfer(credential, drive_id="12345")
|
||||
result = transfer.transfer(
|
||||
share_url="https://www.aliyundrive.com/s/abc123",
|
||||
share_password="",
|
||||
to_parent_file_id="root",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: AliyunCredentialManager,
|
||||
drive_id: str = "",
|
||||
to_parent_file_id: str = "root",
|
||||
request_timeout: int = 30,
|
||||
):
|
||||
self.credential = credential
|
||||
self.drive_id = drive_id or credential.get_drive_id()
|
||||
self.to_parent_file_id = to_parent_file_id
|
||||
self.request_timeout = request_timeout
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(DEFAULT_HEADERS)
|
||||
|
||||
# ─── 公开 API ──────────────────────────────────────────
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
share_url: str,
|
||||
share_password: str = "",
|
||||
to_parent_file_id: str = None,
|
||||
new_share_password: str = "",
|
||||
expiration: str = "",
|
||||
) -> Dict:
|
||||
"""
|
||||
执行完整的转存流程。
|
||||
|
||||
Args:
|
||||
share_url: 阿里云盘分享链接(如 https://www.aliyundrive.com/s/abc123)
|
||||
share_password: 分享提取码(如有)
|
||||
to_parent_file_id: 转存目标目录 file_id,默认用初始化时的值
|
||||
new_share_password: 新分享的密码(空=无密码)
|
||||
expiration: 分享有效期,空=永久
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True/False,
|
||||
"share_name": "...",
|
||||
"new_file_ids": ["id1", "id2"],
|
||||
"new_share_url": "https://...",
|
||||
"new_share_password": "...",
|
||||
"error": None or "...",
|
||||
}
|
||||
"""
|
||||
parent_id = to_parent_file_id or self.to_parent_file_id
|
||||
|
||||
try:
|
||||
# ① 获取分享详情
|
||||
share_id = self._extract_share_id(share_url)
|
||||
if not share_id:
|
||||
return self._error("无法从 URL 提取分享 ID")
|
||||
|
||||
share_info = self._get_share_info(share_id)
|
||||
if not share_info:
|
||||
return self._error("分享不存在或已失效")
|
||||
|
||||
share_name = share_info.get("share_name", "")
|
||||
file_infos = share_info.get("file_infos", [])
|
||||
if not file_infos:
|
||||
return self._error("分享内容为空")
|
||||
|
||||
logger.info(
|
||||
f"[AliyunTransfer] 分享详情获取成功: "
|
||||
f"name={share_name}, files={len(file_infos)}"
|
||||
)
|
||||
|
||||
# ② 获取分享令牌
|
||||
share_token = self._get_share_token(share_id, share_password)
|
||||
if not share_token:
|
||||
return self._error("获取分享令牌失败(可能需要提取码)")
|
||||
|
||||
logger.info(f"[AliyunTransfer] 分享令牌获取成功")
|
||||
|
||||
# ③ 批量复制文件
|
||||
file_ids = [fi.get("file_id", "") for fi in file_infos if fi.get("file_id")]
|
||||
if not file_ids:
|
||||
return self._error("无法提取文件 ID")
|
||||
|
||||
new_file_ids = self._batch_copy(share_id, share_token, file_ids, parent_id)
|
||||
if not new_file_ids:
|
||||
return self._error("批量转存失败,请检查权限或容量")
|
||||
|
||||
logger.info(f"[AliyunTransfer] 批量转存成功: {len(new_file_ids)} 个文件")
|
||||
|
||||
# ④ 创建新分享
|
||||
share_result = self._create_share(
|
||||
new_file_ids,
|
||||
share_password=new_share_password,
|
||||
expiration=expiration,
|
||||
)
|
||||
|
||||
new_share_url = share_result.get("share_url", "")
|
||||
new_share_pwd = share_result.get("share_pwd", new_share_password)
|
||||
|
||||
logger.info(f"[AliyunTransfer] 新分享创建成功: {new_share_url}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"share_name": share_name,
|
||||
"share_id": share_id,
|
||||
"new_file_ids": new_file_ids,
|
||||
"new_share_url": new_share_url,
|
||||
"new_share_password": new_share_pwd,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 转存异常: {e}")
|
||||
return self._error(str(e))
|
||||
|
||||
def get_share_info(self, share_url: str) -> Optional[Dict]:
|
||||
"""
|
||||
仅获取分享详情(不转存)。
|
||||
|
||||
Returns:
|
||||
{"share_name": "...", "file_infos": [...]} or None
|
||||
"""
|
||||
share_id = self._extract_share_id(share_url)
|
||||
if not share_id:
|
||||
logger.error(f"[AliyunTransfer] 无法从 URL 提取 share_id: {share_url}")
|
||||
return None
|
||||
return self._get_share_info(share_id)
|
||||
|
||||
# ─── 步骤 ①:获取分享详情 ───────────────────────────────
|
||||
|
||||
def _get_share_info(self, share_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
POST /adrive/v3/share_link/get_share_by_anonymous
|
||||
请求体: {"share_id": "..."}
|
||||
响应: {"share_name": "...", "file_infos": [{"file_id": "...", "name": "...", ...}]}
|
||||
"""
|
||||
try:
|
||||
resp = self._session.post(
|
||||
SHARE_INFO_URL,
|
||||
json={"share_id": share_id},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享详情失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 检查业务错误码
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享详情 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return None
|
||||
|
||||
return {
|
||||
"share_name": data.get("share_name", ""),
|
||||
"share_title": data.get("share_title", data.get("share_name", "")),
|
||||
"file_infos": data.get("file_infos", []),
|
||||
"expiration": data.get("expiration", ""),
|
||||
"creator_name": data.get("creator_name", ""),
|
||||
"creator_id": data.get("creator_id", ""),
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 获取分享详情网络异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 获取分享详情异常: {e}")
|
||||
return None
|
||||
|
||||
# ─── 步骤 ②:获取分享令牌 ────────────────────────────────
|
||||
|
||||
def _get_share_token(self, share_id: str, share_password: str = "") -> Optional[str]:
|
||||
"""
|
||||
POST /v2/share_link/get_share_token
|
||||
请求体: {"share_id": "..."}
|
||||
需要 Auth 头
|
||||
响应: {"share_token": "..."}
|
||||
"""
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
resp = self._session.post(
|
||||
SHARE_TOKEN_URL,
|
||||
json={
|
||||
"share_id": share_id,
|
||||
"share_pwd": share_password,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享令牌失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return None
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 获取分享令牌 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return None
|
||||
|
||||
share_token = data.get("share_token", "")
|
||||
if not share_token:
|
||||
logger.error("[AliyunTransfer] 响应中缺少 share_token")
|
||||
return None
|
||||
|
||||
return share_token
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 获取分享令牌网络异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 获取分享令牌异常: {e}")
|
||||
return None
|
||||
|
||||
# ─── 步骤 ③:批量复制文件 ────────────────────────────────
|
||||
|
||||
def _batch_copy(
|
||||
self,
|
||||
share_id: str,
|
||||
share_token: str,
|
||||
file_ids: List[str],
|
||||
to_parent_file_id: str = "root",
|
||||
) -> List[str]:
|
||||
"""
|
||||
POST /adrive/v4/batch
|
||||
头: X-Share-Token: <share_token>
|
||||
请求体:
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"url": "/file/copy",
|
||||
"body": {
|
||||
"file_id": "...",
|
||||
"share_id": "...",
|
||||
"to_drive_id": "...",
|
||||
"to_parent_file_id": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
响应: {"responses": [{"status": 200, "body": {"file_id": "new_id"}}, ...]}
|
||||
返回新的 file_id 列表
|
||||
"""
|
||||
drive_id = self.drive_id
|
||||
if not drive_id:
|
||||
drive_id = self.credential.get_drive_id()
|
||||
if not drive_id:
|
||||
logger.error("[AliyunTransfer] 缺少 drive_id,无法转存")
|
||||
return []
|
||||
|
||||
# 构建批量请求体
|
||||
requests_list = []
|
||||
for fid in file_ids:
|
||||
requests_list.append({
|
||||
"url": "/file/copy",
|
||||
"body": {
|
||||
"file_id": fid,
|
||||
"share_id": share_id,
|
||||
"to_drive_id": drive_id,
|
||||
"to_parent_file_id": to_parent_file_id,
|
||||
},
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"id": fid,
|
||||
"method": "POST",
|
||||
})
|
||||
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
headers["X-Share-Token"] = share_token
|
||||
|
||||
resp = self._session.post(
|
||||
BATCH_URL,
|
||||
json={"requests": requests_list, "resource": "file"},
|
||||
headers=headers,
|
||||
timeout=self.request_timeout * 2, # 批量操作可能较慢
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 批量复制失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return []
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 批量复制 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return []
|
||||
|
||||
# 提取新 file_id
|
||||
new_ids = []
|
||||
responses = data.get("responses", [])
|
||||
for item in responses:
|
||||
status = item.get("status", 0)
|
||||
body = item.get("body", {})
|
||||
if status in (200, 201, 202):
|
||||
new_fid = body.get("file_id", "")
|
||||
if new_fid:
|
||||
new_ids.append(new_fid)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[AliyunTransfer] 单个文件复制失败: "
|
||||
f"id={item.get('id')}, status={status}, body={body}"
|
||||
)
|
||||
|
||||
if not new_ids:
|
||||
logger.error("[AliyunTransfer] 所有文件复制均失败")
|
||||
elif len(new_ids) < len(file_ids):
|
||||
logger.warning(
|
||||
f"[AliyunTransfer] 部分文件复制成功: "
|
||||
f"{len(new_ids)}/{len(file_ids)}"
|
||||
)
|
||||
|
||||
return new_ids
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 批量复制网络异常: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 批量复制异常: {e}")
|
||||
return []
|
||||
|
||||
# ─── 步骤 ④:创建新分享 ──────────────────────────────────
|
||||
|
||||
def _create_share(
|
||||
self,
|
||||
file_ids: List[str],
|
||||
share_password: str = "",
|
||||
expiration: str = "",
|
||||
) -> Dict:
|
||||
"""
|
||||
POST /adrive/v2/share_link/create
|
||||
请求体: {"drive_id": "...", "file_id_list": [...], "share_pwd": "...", "expiration": "..."}
|
||||
响应: {"share_url": "...", "share_id": "..."}
|
||||
"""
|
||||
drive_id = self.drive_id or self.credential.get_drive_id()
|
||||
if not drive_id:
|
||||
logger.error("[AliyunTransfer] 缺少 drive_id,无法创建分享")
|
||||
return {"share_url": "", "share_pwd": ""}
|
||||
|
||||
body = {
|
||||
"drive_id": drive_id,
|
||||
"file_id_list": file_ids,
|
||||
"share_pwd": share_password or "",
|
||||
"expiration": expiration or "",
|
||||
}
|
||||
|
||||
try:
|
||||
headers = self.credential.get_headers()
|
||||
resp = self._session.post(
|
||||
CREATE_SHARE_URL,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 创建分享失败: "
|
||||
f"HTTP {resp.status_code}, {data}"
|
||||
)
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
code = data.get("code", "")
|
||||
if code:
|
||||
logger.error(
|
||||
f"[AliyunTransfer] 创建分享 API 错误: "
|
||||
f"code={code}, message={data.get('message', '')}"
|
||||
)
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
share_url = data.get("share_url", "")
|
||||
share_pwd = data.get("share_pwd", share_password)
|
||||
|
||||
return {"share_url": share_url, "share_pwd": share_pwd}
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"[AliyunTransfer] 创建分享网络异常: {e}")
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
except Exception as e:
|
||||
logger.exception(f"[AliyunTransfer] 创建分享异常: {e}")
|
||||
return {"share_url": "", "share_pwd": share_password}
|
||||
|
||||
# ─── URL 解析 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_share_id(url: str) -> Optional[str]:
|
||||
"""从阿里云盘分享 URL 中提取 share_id"""
|
||||
m = URL_PATTERN.search(url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_share_id_static(url: str) -> Optional[str]:
|
||||
"""静态方法:提取 share_id"""
|
||||
return AliyunTransfer._extract_share_id(url)
|
||||
|
||||
# ─── 工具方法 ──────────────────────────────────────────
|
||||
|
||||
def _error(self, message: str) -> Dict:
|
||||
"""构造错误返回"""
|
||||
return {
|
||||
"success": False,
|
||||
"share_name": "",
|
||||
"share_id": "",
|
||||
"new_file_ids": [],
|
||||
"new_share_url": "",
|
||||
"new_share_password": "",
|
||||
"error": message,
|
||||
}
|
||||
Reference in New Issue
Block a user