Compare commits

...

4 Commits

Author SHA1 Message Date
timaa ce3f95b8b9 deploy: sync server CloudSearch 0.5.6 2026-06-25 02:08:39 +08:00
timaa 75f5d26964 feat: add CloudSearch observability 2026-05-27 10:08:59 +08:00
timaa 4c161b63c6 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.
2026-05-22 19:02:46 +08:00
timaa d79c11fb15 chore: normalize test-server health governance 2026-05-21 14:21:27 +08:00
70 changed files with 3972 additions and 194 deletions
+4
View File
@@ -0,0 +1,4 @@
version=0.5.6
source=restored-cloudsearch-existing-source
updated_at=2026-05-23T19:14:59+08:00
backup=/root/cloudsearch_deploy_backups/source_clean_before_d7e1d90_20260523-191331.tgz
+1 -1
View File
@@ -1 +1 @@
0.5.5 0.5.6
+7 -4
View File
@@ -2,10 +2,13 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . ENV PYTHONPATH=/app
COPY cloudsearch_transfer/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY cloudsearch_transfer ./cloudsearch_transfer
RUN mkdir -p /data
ENV PORT=9528 ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
@@ -13,6 +16,6 @@ ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528 EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "server.py"] CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.getenv('PORT', '9528'), timeout=3)"
CMD ["python", "server.py"] CMD ["python", "-m", "cloudsearch_transfer.server"]
+87 -11
View File
@@ -15,8 +15,8 @@ import logging
from typing import List, Dict, Tuple, Optional from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ..config import PlatformConfig, TransferConfig from ...config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode from ...errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer from .transfer import AliyunTransfer
@@ -47,23 +47,29 @@ class AliyunAdapter(BaseCloudDriveAdapter):
"Referer": "https://aliyundrive.com", "Referer": "https://aliyundrive.com",
} }
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): capabilities: Dict[str, bool] = {
super().__init__(config, transfer_config) **BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
# 创建凭证管理器(AliyunCredentialManager def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
refresh_token = config.refresh_token or config.cookie or "" refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token) self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = "" self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None self._cleanup: Optional[AliyunCleanup] = None
super().__init__(config, transfer_config)
def _setup_session(self): def _setup_session(self):
"""初始化 session 和凭证""" """初始化 session 和凭证"""
if self._credential.refresh_token: refresh_token = getattr(self._credential, "refresh_token", "")
if refresh_token:
# 验证 refresh_token 并获取 drive_id # 验证 refresh_token 并获取 drive_id
if self._credential.validate(): if self._credential.validate():
self._drive_id = self._credential.get_drive_id() self._drive_id = self._credential.get_drive_id()
@@ -164,8 +170,10 @@ class AliyunAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY, platform=self.PLATFORM_KEY,
) )
# 确定目标目录 # 确定目标目录:路径先解析/创建为 file_id。
to_parent = save_dir if save_dir and save_dir != "/" else "root" to_parent = save_dir if save_dir and save_dir != "/" else "root"
if isinstance(to_parent, str) and to_parent.startswith("/"):
to_parent = self.ensure_dir(to_parent)
transfer = self._get_transfer() transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent) new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
@@ -237,6 +245,74 @@ class AliyunAdapter(BaseCloudDriveAdapter):
# ─── 扩展功能 ────────────────────────────────────────── # ─── 扩展功能 ──────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def get_fids(self, file_paths: List[str]) -> List[Dict]:
wanted = {p.rstrip("/") or "/" for p in file_paths}
found: List[Dict] = []
for path in wanted:
if path == "/":
found.append({"file_path": "/", "fid": "root"})
continue
parent = path.rsplit("/", 1)[0] or "/"
name = path.rsplit("/", 1)[-1]
for item in self.get_files(parent):
if item.is_dir and item.name == name:
found.append({"file_path": path, "fid": item.fid})
break
return found
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "root"
current = ""
last_fid = "root"
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
matches = self.get_fids([current])
if matches:
last_fid = matches[0].get("fid") or matches[0].get("file_id") or last_fid
continue
created = self.mkdir(current)
data = created.get("data", created) if isinstance(created, dict) else {}
last_fid = data.get("fid") or data.get("file_id") or data.get("id") or last_fid
return last_fid
def mkdir(self, dir_path: str) -> Dict:
parent = dir_path.rsplit("/", 1)[0] or "/"
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
parent_fid = "root" if parent == "/" else self.ensure_dir(parent)
url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
body = {
"drive_id": self._drive_id or self._credential.get_drive_id(),
"parent_file_id": parent_fid,
"name": name,
"type": "folder",
"check_name_mode": "refuse",
}
resp = self._post(url, json_data=body, headers=self._credential.get_headers())
data = self._drive_api_json(resp, context="阿里云盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
url = "https://api.aliyundrive.com/v3/file/update"
body = {"drive_id": self._drive_id or self._credential.get_drive_id(), "file_id": fid, "name": file_name, "check_name_mode": "refuse"}
data = self._drive_api_json(self._post(url, json_data=body, headers=self._credential.get_headers()), context="阿里云盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
if isinstance(to_pdir_fid, str) and to_pdir_fid.startswith("/"):
to_pdir_fid = self.ensure_dir(to_pdir_fid)
drive_id = self._drive_id or self._credential.get_drive_id()
requests = [{"id": fid, "method": "POST", "url": "/file/move", "headers": {"Content-Type": "application/json"}, "body": {"drive_id": drive_id, "file_id": fid, "to_parent_file_id": to_pdir_fid}} for fid in fids]
data = self._drive_api_json(self._post("https://api.aliyundrive.com/adrive/v4/batch", json_data={"requests": requests, "resource": "file"}, headers=self._credential.get_headers()), context="阿里云盘移动文件")
return {"code": 0, "status": 200, "data": data}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def cleanup_files(self, file_ids: List[str]) -> Dict: def cleanup_files(self, file_ids: List[str]) -> Dict:
""" """
清理文件(移入回收站),返回详细结果。 清理文件(移入回收站),返回详细结果。
+79 -3
View File
@@ -6,7 +6,7 @@
""" """
import logging import logging
from typing import List, Tuple from typing import List, Tuple, Dict
from ..base import BaseCloudDriveAdapter, FileInfo from ..base import BaseCloudDriveAdapter, FileInfo
from ...config import PlatformConfig, TransferConfig from ...config import PlatformConfig, TransferConfig
@@ -36,6 +36,15 @@ class BaiduAdapter(BaseCloudDriveAdapter):
r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)', r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)',
] ]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config) super().__init__(config, transfer_config)
@@ -123,11 +132,13 @@ class BaiduAdapter(BaseCloudDriveAdapter):
fs_ids = detail["fs_ids"] fs_ids = detail["fs_ids"]
filenames = detail.get("filenames", []) filenames = detail.get("filenames", [])
target_dir = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "/")
# ③ 转存 # ③ 转存
self._transfer._transfer_files(shareid, uk, fs_ids, save_dir, bdstoken) self._transfer._transfer_files(shareid, uk, fs_ids, target_dir, bdstoken)
# ④ 列出目录匹配新 fs_id # ④ 列出目录匹配新 fs_id
new_fs_ids = self._transfer._list_and_match(save_dir, filenames, bdstoken) new_fs_ids = self._transfer._list_and_match(target_dir, filenames, bdstoken)
# 暂存文件信息供 _filter_ads + _create_share 使用 # 暂存文件信息供 _filter_ads + _create_share 使用
self._last_transfer_files = [ self._last_transfer_files = [
@@ -248,6 +259,71 @@ class BaiduAdapter(BaseCloudDriveAdapter):
# ─── 扩展方法 ──────────────────────────────────────────── # ─── 扩展方法 ────────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "/"
current = ""
for part in [p for p in normalized.split("/") if p]:
parent = current or "/"
current = f"{current}/{part}" if current else f"/{part}"
exists = any(item.is_dir and item.name == part for item in self.get_files(parent))
if not exists:
self.mkdir(current)
return normalized
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing Baidu paths by listing their parent directories."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": "/", "path": "/"})
continue
parent, name = normalized.rsplit("/", 1)
parent = parent or "/"
match = next((item for item in self.get_files(parent) if item.name == name), None)
if match:
results.append({"file_path": path, "fid": match.fid, "path": normalized})
return results
def mkdir(self, dir_path: str) -> Dict:
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/create"
params = {"a": "commit", "bdstoken": bdstoken}
data = {"path": dir_path, "isdir": 1, "block_list": "[]"}
resp = self._post(url, data=data, params=params, headers=self.credential.get_headers())
payload = self._drive_api_json(resp, context="百度网盘创建目录")
errno = payload.get("errno", 0)
if errno not in (0, -8):
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度创建目录失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": {"path": dir_path, **payload}}
def rename(self, fid: str, file_name: str) -> Dict:
return self._filemanager("rename", [{"path": fid, "newname": file_name}])
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
return self._filemanager("move", [{"path": fid, "dest": target} for fid in fids])
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def _filemanager(self, opera: str, filelist: List[Dict]) -> Dict:
import json
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/filemanager"
params = {"opera": opera, "bdstoken": bdstoken}
data = {"filelist": json.dumps(filelist, ensure_ascii=False)}
payload = self._drive_api_json(self._post(url, data=data, params=params, headers=self.credential.get_headers()), context=f"百度网盘{opera}")
errno = payload.get("errno", 0)
if errno != 0:
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度文件操作失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": payload}
def delete_paths(self, paths: List[str]) -> bool: def delete_paths(self, paths: List[str]) -> bool:
"""便捷删除方法(直接调用 cleanup)""" """便捷删除方法(直接调用 cleanup)"""
return self._cleanup.delete_files(paths) return self._cleanup.delete_files(paths)
+72 -2
View File
@@ -76,6 +76,18 @@ class BaseCloudDriveAdapter(ABC):
# URL匹配正则(子类覆盖) # URL匹配正则(子类覆盖)
URL_PATTERNS: List[str] = [] URL_PATTERNS: List[str] = []
# 可选 Drive API 能力;子类按需覆盖为 True 并实现对应方法。
capabilities: Dict[str, bool] = {
"ensure_dir": False,
"save_files": False,
"poll_task": False,
"rename": False,
"move_files": False,
"delete_files": False,
"cleanup_recycle": False,
"share_staging_folder": False,
}
# 默认请求头 # 默认请求头
DEFAULT_HEADERS: Dict[str, str] = { DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -235,6 +247,40 @@ class BaseCloudDriveAdapter(ABC):
"""广告过滤(默认不实现,子类可覆盖)""" """广告过滤(默认不实现,子类可覆盖)"""
return file_ids return file_ids
# ─── Optional Drive API capability protocol ─────────────────────
def _unsupported_capability(self, capability: str) -> None:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{self.PLATFORM_KEY or self.PLATFORM_NAME} 不支持 Drive API 能力: {capability}",
platform=self.PLATFORM_KEY,
)
def ensure_dir(self, dir_path: str) -> str:
self._unsupported_capability("ensure_dir")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
self._unsupported_capability("get_fids")
def mkdir(self, dir_path: str) -> Dict[str, Any]:
self._unsupported_capability("mkdir")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
self._unsupported_capability("rename")
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
self._unsupported_capability("move_files")
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("delete_files")
def query_task(self, task_id: str) -> Dict[str, Any]:
self._unsupported_capability("poll_task")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("cleanup_recycle")
# ─── HTTP 工具方法 ───────────────────────────────────── # ─── HTTP 工具方法 ─────────────────────────────────────
def _get(self, url: str, params: dict = None, headers: dict = None, def _get(self, url: str, params: dict = None, headers: dict = None,
@@ -271,6 +317,28 @@ class BaseCloudDriveAdapter(ABC):
raise TransferError(TransferErrorCode.NETWORK_ERROR, raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(last_exc), platform=self.PLATFORM_KEY) message=str(last_exc), platform=self.PLATFORM_KEY)
def _drive_api_json(self, resp: requests.Response, context: str = "网盘 API") -> Dict[str, Any]:
"""Validate HTTP response and decode JSON for drive helper APIs."""
try:
resp.raise_for_status()
except requests.HTTPError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} HTTP错误: {exc}",
platform=self.PLATFORM_KEY,
details={"status_code": getattr(resp, "status_code", None)},
) from exc
try:
return resp.json()
except ValueError as exc:
text = getattr(resp, "text", "") or ""
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} JSON解析失败: {text[:200]}",
platform=self.PLATFORM_KEY,
) from exc
def _poll_task(self, task_url: str, task_id: str, def _poll_task(self, task_url: str, task_id: str,
status_field: str = "status", status_field: str = "status",
success_value: Any = 2, success_value: Any = 2,
@@ -292,10 +360,12 @@ class BaseCloudDriveAdapter(ABC):
details={"task_id": task_id}) details={"task_id": task_id})
try: try:
params = query_params or {} base_params = query_params(attempt) if callable(query_params) else (query_params or {})
params = dict(base_params)
params["task_id"] = task_id params["task_id"] = task_id
resp = self._get(task_url, params=params, retry=1) resp = self._get(task_url, params=params, retry=1)
data = resp.json().get("data", resp.json()) payload = resp.json()
data = payload.get("data", payload)
current_status = data.get(status_field) current_status = data.get(status_field)
if current_status == success_value: if current_status == success_value:
+175 -22
View File
@@ -55,19 +55,21 @@ class QuarkAdapter(BaseCloudDriveAdapter):
r"pan\.quark\.cn/s/(\w+)", 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: def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化夸克适配器。 """初始化适配器。"""
self._credential: QuarkCredentialManager = QuarkCredentialManager(cookie=config.cookie)
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: QuarkCredentialManager = QuarkCredentialManager(
cookie=config.cookie
)
self._transfer_engine: QuarkTransfer = QuarkTransfer( self._transfer_engine: QuarkTransfer = QuarkTransfer(
credential=self._credential, credential=self._credential,
timeout=transfer_config.request_timeout, timeout=transfer_config.request_timeout,
@@ -78,6 +80,7 @@ class QuarkAdapter(BaseCloudDriveAdapter):
credential=self._credential, credential=self._credential,
timeout=transfer_config.request_timeout, timeout=transfer_config.request_timeout,
) )
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# 公开接口实现 # 公开接口实现
@@ -116,8 +119,9 @@ class QuarkAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY, platform=self.PLATFORM_KEY,
) )
# 目标目录:默认根目录 "0" # 目标目录:支持 fid 或路径;路径会先创建/解析为 fid。
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)
# 分享密码 # 分享密码
pwd: str = share_password or self.config.share_password or "" 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]: def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。 """转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
Args: save_dir 可传 fid 或路径;路径会先通过 ensure_dir 创建/解析为 fid。
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail)。
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
""" """
# 需要 stoken,从 detail 间接获取(重新请求) # 需要 stoken,从 detail 间接获取(重新请求)
stoken: str = self._transfer_engine._get_stoken(pwd_id) 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( 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) 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") logger.warning("[QuarkAdapter] Cannot fetch file list for ad filtering, skipping")
return file_ids 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 ──────────────────────────────────── # ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]: def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
+194 -49
View File
@@ -56,19 +56,21 @@ class UcAdapter(BaseCloudDriveAdapter):
r"drive\.uc\.cn/s/(\w+)", 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: def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化 UC 适配器。 """初始化适配器。"""
self._credential: UcCredentialManager = UcCredentialManager(cookie=config.cookie)
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: UcCredentialManager = UcCredentialManager(
cookie=config.cookie
)
self._transfer_engine: UcTransfer = UcTransfer( self._transfer_engine: UcTransfer = UcTransfer(
credential=self._credential, credential=self._credential,
timeout=transfer_config.request_timeout, timeout=transfer_config.request_timeout,
@@ -79,6 +81,7 @@ class UcAdapter(BaseCloudDriveAdapter):
credential=self._credential, credential=self._credential,
timeout=transfer_config.request_timeout, 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 = "", def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult: share_password: str = "") -> TransferResult:
"""执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。
通过 UcTransfer 引擎执行完整的 7 步流程。
Args:
share_url: UC 分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time() start: float = time.time()
# 凭证检查
if not self._credential.validate(): if not self._credential.validate():
raise TransferError( raise TransferError(
TransferErrorCode.NOT_LOGIN, TransferErrorCode.NOT_LOGIN,
@@ -115,18 +105,36 @@ class UcAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY, platform=self.PLATFORM_KEY,
) )
# 目标目录:默认根目录 "0" requested_dir: str = save_dir or self.config.save_dir or "/"
target_dir: str = save_dir or self.config.save_dir or "0" 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 "" pwd: str = share_password or self.config.share_password or ""
try: try:
result: Dict[str, Any] = self._transfer_engine.transfer( pwd_id, passcode = self._parse_share_url(share_url)
share_url=share_url, stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
save_dir=target_dir, detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
share_password=pwd, 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: except ValueError as exc:
raise TransferError( raise TransferError(
TransferErrorCode.URL_INVALID, TransferErrorCode.URL_INVALID,
@@ -148,24 +156,13 @@ class UcAdapter(BaseCloudDriveAdapter):
) from exc ) from exc
elapsed: int = int((time.time() - start) * 1000) 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( return TransferResult(
success=True, success=True,
platform=self.PLATFORM_KEY, platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids), new_file_id=",".join(new_fids),
file_name=result.get("file_name", ""), file_name=title,
share_url=result.get("share_url", ""), share_url=share_url_new,
share_password=result.get("passcode", pwd), share_password=passcode_new,
original_url=share_url, original_url=share_url,
elapsed_ms=elapsed, elapsed_ms=elapsed,
) )
@@ -204,8 +201,8 @@ class UcAdapter(BaseCloudDriveAdapter):
files=files, files=files,
) )
except TransferError: except TransferError as exc:
raise return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=exc)
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
return VerifyResult( return VerifyResult(
valid=False, valid=False,
@@ -344,6 +341,154 @@ class UcAdapter(BaseCloudDriveAdapter):
) )
return file_ids 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 ──────────────────────────────────── # ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]: def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
+102 -14
View File
@@ -8,7 +8,7 @@ PLATFORM_KEY = 'xunlei'
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import List, Optional, Tuple from typing import List, Optional, Tuple, Dict
from ..base import ( from ..base import (
BaseCloudDriveAdapter, BaseCloudDriveAdapter,
@@ -32,11 +32,24 @@ class XunleiAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "xunlei" PLATFORM_KEY = "xunlei"
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"] URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
# batchDelete is permanent on Xunlei, so do not advertise it as generic delete_files.
"delete_files": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig): def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config) # BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
self._credential = XunleiCredentialManager(config) self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup() self._cleanup = XunleiCleanup(self._credential)
super().__init__(config, transfer_config)
def _setup_session(self): def _setup_session(self):
"""初始化 session 认证头""" """初始化 session 认证头"""
@@ -54,27 +67,35 @@ class XunleiAdapter(BaseCloudDriveAdapter):
"""懒加载转存引擎""" """懒加载转存引擎"""
if self._transfer_engine is None: if self._transfer_engine is None:
self._transfer_engine = XunleiTransfer( self._transfer_engine = XunleiTransfer(
self.session, credential=self._credential,
self._credential, timeout=self.transfer_config.request_timeout,
self.config, poll_interval=self.transfer_config.task_poll_interval,
self.transfer_config, poll_max_attempts=self.transfer_config.task_poll_max_attempts,
) )
self._transfer_engine.session = self.session
return self._transfer_engine return self._transfer_engine
# ─── 抽象方法实现 ────────────────────────────── # ─── 抽象方法实现 ──────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict: def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
self._ensure_auth() self._ensure_auth()
return self._transfer.get_share_info(pwd_id, passcode) return self._transfer._get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]: def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
self._ensure_auth() self._ensure_auth()
return self._transfer.save_files(pwd_id, detail, save_dir) target = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "")
files = detail.get("files", [])
file_ids = [f.get("file_id") or f.get("fid") or f.get("id") for f in files if f.get("file_id") or f.get("fid") or f.get("id")]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
task_id = self._transfer._restore_files(pwd_id, detail.get("pass_code_token", ""), file_ids, parent_id=target)
mapping = self._transfer._poll_restore_task(task_id)
return [mapping.get(fid, "") for fid in file_ids if mapping.get(fid, "")]
def _create_share(self, file_ids: List[str], title: str, def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]: password: str = "") -> Tuple[str, str]:
self._ensure_auth() self._ensure_auth()
return self._transfer.create_share(file_ids, title, password) return self._transfer._create_share(file_ids, password=password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]: def _extract_file_list(self, detail: dict) -> List[FileInfo]:
files = detail.get("files", []) files = detail.get("files", [])
@@ -94,13 +115,80 @@ class XunleiAdapter(BaseCloudDriveAdapter):
def get_files(self, parent_fid: str = "0") -> List[FileInfo]: def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
self._ensure_auth() self._ensure_auth()
return self._transfer.list_files(parent_fid) url = "https://api-pan.xunlei.com/drive/v1/files"
params = {"parent_id": "" if parent_fid in ("0", "/") else parent_fid}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="迅雷网盘列目录")
items = data.get("files", data.get("list", []))
return [FileInfo(fid=item.get("id") or item.get("file_id", ""), name=item.get("name", ""), size=item.get("size", 0), is_dir=item.get("kind") == "drive#folder" or item.get("is_dir", False)) for item in items]
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return ""
parent_id = ""
current = ""
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
existing = next((item for item in self.get_files(parent_id or "0") if item.is_dir and item.name == part), None)
if existing:
parent_id = existing.fid
continue
result = self.mkdir(current if not parent_id else part, parent_id=parent_id)
parent_id = result.get("data", {}).get("fid", parent_id)
return parent_id
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing file/directory paths without creating anything."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": ""})
continue
parent_id = ""
found: Optional[FileInfo] = None
missing = False
parts = [p for p in normalized.split("/") if p]
for index, part in enumerate(parts):
found = next((item for item in self.get_files(parent_id or "0") if item.name == part), None)
if not found:
missing = True
break
if index < len(parts) - 1 and not found.is_dir:
missing = True
break
parent_id = found.fid
if not missing and found:
results.append({"file_path": path, "fid": found.fid})
return results
def mkdir(self, dir_path: str, parent_id: str = "") -> Dict:
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
body = {"kind": "drive#folder", "name": name, "parent_id": parent_id or ""}
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files", json_data=body, headers=self._credential.get_headers_with_captcha(action="mkdir")), context="迅雷网盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("id") or data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
data = self._drive_api_json(self._post(f"https://api-pan.xunlei.com/drive/v1/files/{fid}", json_data={"name": file_name}, headers=self._credential.get_headers()), context="迅雷网盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files:batchMove", json_data={"ids": fids, "parent_id": target}, headers=self._credential.get_headers()), context="迅雷网盘移动文件")
return {"code": 0, "status": 200, "data": data}
def query_task(self, task_id: str) -> Dict:
return self.poll_task(task_id)
def poll_task(self, task_id: str) -> Dict:
return {"code": 0, "status": 200, "data": self._transfer._poll_restore_task(task_id)}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def delete(self, file_ids: List[str]) -> bool: def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth() self._ensure_auth()
return self._cleanup.delete_files( return self._cleanup.delete_files(file_ids)
self.session, self._credential, file_ids
)
def _get_banned_keywords(self) -> List[str]: def _get_banned_keywords(self) -> List[str]:
return self.config.banned_keywords or self.transfer_config.default_banned_keywords return self.config.banned_keywords or self.transfer_config.default_banned_keywords
@@ -70,7 +70,7 @@ class XunleiTransfer:
# ─── 步骤 ①:获取分享详情 ───────────────────────────────────── # ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
def _get_share_info(self, share_id: str) -> Dict[str, Any]: def _get_share_info(self, share_id: str, passcode: str = "") -> Dict[str, Any]:
"""步骤①:获取分享详情。 """步骤①:获取分享详情。
GET /drive/v1/share?share_id=<share_id> GET /drive/v1/share?share_id=<share_id>
@@ -88,6 +88,8 @@ class XunleiTransfer:
""" """
url = f"{XUNLEI_PAN_API}/drive/v1/share" url = f"{XUNLEI_PAN_API}/drive/v1/share"
params: Dict[str, str] = {"share_id": share_id} params: Dict[str, str] = {"share_id": share_id}
if passcode:
params["pass_code"] = passcode
headers = self.credential.get_headers() headers = self.credential.get_headers()
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id) logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
@@ -303,6 +305,7 @@ class XunleiTransfer:
def _create_share( def _create_share(
self, self,
file_ids: List[str], file_ids: List[str],
password: str = "",
expiration_days: str = "-1", expiration_days: str = "-1",
) -> Tuple[str, str]: ) -> Tuple[str, str]:
"""步骤④:创建新分享链接。 """步骤④:创建新分享链接。
@@ -331,6 +334,8 @@ class XunleiTransfer:
"file_ids": file_ids, "file_ids": file_ids,
"expiration_days": expiration_days, "expiration_days": expiration_days,
} }
if password:
body["pass_code"] = password
# share 操作可能需要 captcha_token # share 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="share") headers = self.credential.get_headers_with_captcha(action="share")
headers.setdefault("Content-Type", "application/json") headers.setdefault("Content-Type", "application/json")
@@ -367,7 +372,7 @@ class XunleiTransfer:
share_url, share_url,
pass_code, pass_code,
) )
return share_url, pass_code return share_url, pass_code or password
# ─── 公开入口 ───────────────────────────────────────────────── # ─── 公开入口 ─────────────────────────────────────────────────
+2 -2
View File
@@ -150,8 +150,8 @@ class ConfigManager:
"platforms": { "platforms": {
name: { name: {
"enabled": cfg.enabled, "enabled": cfg.enabled,
"cookie": cfg.cookie[:20] + "..." if cfg.cookie else "", "cookie": cfg.cookie,
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "", "refresh_token": cfg.refresh_token,
"account_name": cfg.account_name, "account_name": cfg.account_name,
"save_dir": cfg.save_dir, "save_dir": cfg.save_dir,
"share_password": cfg.share_password, "share_password": cfg.share_password,
+19 -4
View File
@@ -7,8 +7,8 @@ import os
import uuid import uuid
import logging import logging
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from config import ConfigManager from cloudsearch_transfer.config import ConfigManager
from orchestration.transfer import TransferOrchestrator from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator
# ─── 初始化 ──────────────────────────────────────────── # ─── 初始化 ────────────────────────────────────────────
@@ -149,6 +149,18 @@ def stats():
return jsonify(orchestrator.get_stats()) return jsonify(orchestrator.get_stats())
def require_config_auth():
token = os.getenv("TRANSFER_API_TOKEN", "")
if not token:
return jsonify({"error": "TRANSFER_API_TOKEN is required"}), 401
supplied = request.headers.get("X-Transfer-Token") or request.headers.get("Authorization", "")
if supplied.startswith("Bearer "):
supplied = supplied[7:]
if supplied != token:
return jsonify({"error": "unauthorized"}), 401
return None
# ─── 配置管理 ────────────────────────────────────────── # ─── 配置管理 ──────────────────────────────────────────
@app.route("/api/config/platforms", methods=["GET"]) @app.route("/api/config/platforms", methods=["GET"])
@@ -169,14 +181,17 @@ def get_platforms():
@app.route("/api/config/platforms/<name>", methods=["PUT"]) @app.route("/api/config/platforms/<name>", methods=["PUT"])
def update_platform(name): def update_platform(name):
"""更新平台配置""" """更新平台配置"""
auth_error = require_config_auth()
if auth_error:
return auth_error
data = request.get_json() or {} data = request.get_json() or {}
if name not in config.platforms: if name not in config.platforms:
from config import PlatformConfig from cloudsearch_transfer.config import PlatformConfig
config.platforms[name] = PlatformConfig() config.platforms[name] = PlatformConfig()
cfg = config.platforms[name] cfg = config.platforms[name]
if "enabled" in data: if "enabled" in data:
cfg.enabled = data["enabled"] cfg.enabled = bool(data["enabled"])
if "cookie" in data: if "cookie" in data:
cfg.cookie = data["cookie"] cfg.cookie = data["cookie"]
if "refresh_token" in data: if "refresh_token" in data:
@@ -0,0 +1,198 @@
import unittest
from unittest.mock import patch
from cloudsearch_transfer.adapter.aliyun import AliyunAdapter
from cloudsearch_transfer.adapter.baidu import BaiduAdapter
from cloudsearch_transfer.adapter.xunlei import XunleiAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import FileInfo
class DummyCredential:
cookie = "k=xxxxxxxx"
refresh_token = "rt"
def validate(self):
return True
def get_bdstoken(self):
return "bdstoken"
def get_drive_id(self):
return "drive-id"
def get_headers(self):
return {}
def get_auth_headers(self):
return {}
def make_config(**kwargs):
data = dict(enabled=True, cookie="k=" + "x" * 80, refresh_token="rt", account_name="test", save_dir="/")
data.update(kwargs)
return PlatformConfig(**data)
def make_transfer_config():
return TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False, task_poll_interval=0, task_poll_max_attempts=2)
class P1DriveApiCapabilityTests(unittest.TestCase):
def make_aliyun(self):
with patch("cloudsearch_transfer.adapter.aliyun.AliyunCredentialManager", return_value=DummyCredential()):
return AliyunAdapter(make_config(), make_transfer_config())
def make_baidu(self):
with patch("cloudsearch_transfer.adapter.baidu.BaiduCredentialManager", return_value=DummyCredential()):
return BaiduAdapter(make_config(), make_transfer_config())
def make_xunlei(self):
with patch("cloudsearch_transfer.adapter.xunlei.XunleiCredentialManager", return_value=DummyCredential()):
return XunleiAdapter(make_config(), make_transfer_config())
def test_p1_adapters_declare_callable_core_drive_api_methods(self):
expectations = {
"aliyun": (self.make_aliyun(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"baidu": (self.make_baidu(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"xunlei": (self.make_xunlei(), ["ensure_dir", "save_files", "poll_task", "rename", "move_files"]),
}
for platform, (adapter, required) in expectations.items():
with self.subTest(platform=platform):
for capability in required:
self.assertTrue(adapter.capabilities.get(capability), f"{platform}.{capability} capability not declared")
for method in ["ensure_dir", "mkdir", "rename", "move_files"]:
self.assertNotEqual(getattr(type(adapter), method), getattr(adapter.__class__.__mro__[1], method, None), f"{platform}.{method} not overridden")
if adapter.capabilities.get("delete_files"):
self.assertNotEqual(getattr(type(adapter), "delete_files"), getattr(adapter.__class__.__mro__[1], "delete_files", None), f"{platform}.delete_files not overridden")
def test_aliyun_save_files_resolves_path_to_file_id_before_copy(self):
adapter = self.make_aliyun()
adapter.ensure_dir = lambda path: "target-folder-id"
captured = {}
transfer = adapter._get_transfer()
def fake_batch_copy(share_id, share_token, file_ids, to_parent):
captured["to_parent"] = to_parent
return ["new-file"]
transfer._batch_copy = fake_batch_copy
detail = {"share_token": "share-token", "files": [{"file_id": "src-file"}]}
self.assertEqual(adapter._save_files("share-id", detail, "/Media"), ["new-file"])
self.assertEqual(captured["to_parent"], "target-folder-id")
def test_aliyun_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_aliyun()
existing = {"/A": "fid-A"}
created = []
adapter.get_fids = lambda paths: [{"file_path": p, "fid": existing[p]} for p in paths if p in existing]
def fake_mkdir(path):
created.append(path)
existing[path] = "fid-" + path.rsplit("/", 1)[-1]
return {"code": 0, "data": {"fid": existing[path]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A/B"])
def test_baidu_save_files_ensures_path_before_transfer_and_matching(self):
adapter = self.make_baidu()
adapter.ensure_dir = lambda path: "/CloudSearch/Media"
captured = {}
adapter.credential.get_bdstoken = lambda: "bdstoken"
def fake_transfer_files(shareid, uk, fs_ids, save_dir, token):
captured["transfer_dir"] = save_dir
adapter._transfer._transfer_files = fake_transfer_files
def fake_list_and_match(save_dir, filenames, token):
captured["list_dir"] = save_dir
return ["1001"]
adapter._transfer._list_and_match = fake_list_and_match
detail = {"shareid": "sid", "uk": "uk", "fs_ids": ["old"], "filenames": ["demo.mkv"]}
self.assertEqual(adapter._save_files("pwd", detail, "/CloudSearch/Media"), ["1001"])
self.assertEqual(captured["transfer_dir"], "/CloudSearch/Media")
self.assertEqual(captured["list_dir"], "/CloudSearch/Media")
def test_baidu_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_baidu()
dirs = {"/A"}
created = []
adapter.get_files = lambda parent="/": [FileInfo(fid=parent.rstrip("/") + "/A", name="A", is_dir=True)] if parent == "/" else []
def fake_mkdir(path):
created.append(path)
dirs.add(path)
return {"code": 0, "data": {"path": path}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "/A/B")
self.assertEqual(created, ["/A/B"])
def test_xunlei_save_files_resolves_path_and_polls_restore_task(self):
adapter = self.make_xunlei()
adapter.ensure_dir = lambda path: "target-parent-id"
transfer = adapter._transfer
transfer._restore_files = lambda share_id, token, fids, parent_id="": "task-1" if parent_id == "target-parent-id" else (_ for _ in ()).throw(AssertionError(parent_id))
transfer._poll_restore_task = lambda task_id: {"old-1": "new-1"}
detail = {"share_id": "share", "pass_code_token": "pct", "files": [{"id": "old-1", "name": "demo"}]}
self.assertEqual(adapter._save_files("share", detail, "/Media"), ["new-1"])
def test_xunlei_query_task_delegates_to_restore_poll(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.query_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_does_not_advertise_permanent_delete_as_drive_delete_capability(self):
adapter = self.make_xunlei()
self.assertFalse(adapter.capabilities.get("delete_files"), "迅雷 batchDelete is permanent and must not be advertised as generic delete_files")
def test_xunlei_get_share_detail_passes_extraction_code_to_transfer_engine(self):
adapter = self.make_xunlei()
captured = {}
def fake_get_share_info(share_id, passcode=""):
captured["share_id"] = share_id
captured["passcode"] = passcode
return {"title": "demo", "files": [{"id": "old"}], "pass_code_token": "pct"}
adapter._transfer._get_share_info = fake_get_share_info
detail = adapter._get_share_detail("share-id", "abcd")
self.assertEqual(detail["title"], "demo")
self.assertEqual(captured, {"share_id": "share-id", "passcode": "abcd"})
def test_xunlei_create_share_forwards_requested_share_password(self):
adapter = self.make_xunlei()
captured = {}
def fake_create_share(file_ids, password=""):
captured["file_ids"] = file_ids
captured["password"] = password
return ("https://pan.xunlei.com/s/new", password)
adapter._transfer._create_share = fake_create_share
self.assertEqual(adapter._create_share(["fid-1"], "demo", "xy12"), ("https://pan.xunlei.com/s/new", "xy12"))
self.assertEqual(captured, {"file_ids": ["fid-1"], "password": "xy12"})
def test_xunlei_poll_task_alias_matches_declared_capability(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.poll_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_get_fids_is_read_only_and_does_not_create_missing_paths(self):
adapter = self.make_xunlei()
calls = []
def fake_get_files(parent="0"):
calls.append(("get_files", parent))
if parent in ("0", ""):
return [FileInfo(fid="fid-A", name="A", is_dir=True)]
if parent == "fid-A":
return [FileInfo(fid="fid-B", name="B", is_dir=False)]
return []
def fail_mkdir(*args, **kwargs):
raise AssertionError("get_fids must not create directories")
adapter.get_files = fake_get_files
adapter.mkdir = fail_mkdir
self.assertEqual(adapter.get_fids(["/A/B", "/A/Missing"]), [{"file_path": "/A/B", "fid": "fid-B"}])
self.assertEqual(calls, [("get_files", "0"), ("get_files", "fid-A"), ("get_files", "0"), ("get_files", "fid-A")])
def test_baidu_get_fids_checks_existing_parent_listing(self):
adapter = self.make_baidu()
calls = []
def fake_get_files(parent="/"):
calls.append(parent)
if parent == "/A":
return [FileInfo(fid="1001", name="movie.mkv", is_dir=False)]
return []
adapter.get_files = fake_get_files
self.assertEqual(adapter.get_fids(["/A/movie.mkv", "/A/missing.mkv"]), [{"file_path": "/A/movie.mkv", "fid": "1001", "path": "/A/movie.mkv"}])
self.assertEqual(calls, ["/A", "/A"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,308 @@
import unittest
import requests
from unittest.mock import patch
from cloudsearch_transfer.adapter.quark import QuarkAdapter
from cloudsearch_transfer.adapter.uc import UcAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import BaseCloudDriveAdapter
from cloudsearch_transfer.errors import TransferError
class DummyResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def raise_for_status(self):
return None
def make_adapter(adapter_cls):
return adapter_cls(
PlatformConfig(enabled=True, cookie="k=" + "x" * 80, account_name="test"),
TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False),
)
class QuarkUcCapabilityTests(unittest.TestCase):
def test_quark_declares_p0_drive_api_capabilities(self):
adapter = make_adapter(QuarkAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["cleanup_recycle"])
def test_uc_declares_p0_drive_api_capabilities_and_share_staging(self):
adapter = make_adapter(UcAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["share_staging_folder"])
def test_quark_ensure_dir_uses_path_lookup_before_mkdir(self):
adapter = make_adapter(QuarkAdapter)
mkdir_calls = []
adapter.get_fids = lambda paths: [{"file_path": "/Media", "fid": "fid-media"}]
adapter.mkdir = lambda path: mkdir_calls.append(path) or {"code": 0, "data": {"fid": "new"}}
self.assertEqual(adapter.ensure_dir("/Media"), "fid-media")
self.assertEqual(mkdir_calls, [])
def test_quark_ensure_dir_creates_missing_path(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "created-fid"}}
self.assertEqual(adapter.ensure_dir("Media/Shows"), "created-fid")
def test_uc_share_folder_created_when_missing(self):
adapter = make_adapter(UcAdapter)
adapter.ls_dir = lambda parent: {"code": 0, "data": {"list": []}}
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "share-folder-fid"}}
self.assertEqual(adapter.get_or_create_share_folder(), "share-folder-fid")
def test_uc_move_files_polls_task_until_complete(self):
adapter = make_adapter(UcAdapter)
calls = []
def fake_post(url, json_data=None, params=None, headers=None):
calls.append((url, json_data))
return DummyResponse({"code": 0, "status": 200, "data": {"task_id": "task-1"}})
adapter._post = fake_post
adapter.query_task = lambda task_id: {"code": 0, "status": 200, "data": {"status": 2}}
result = adapter.move_files(["fid-1", "fid-2"], "target-fid")
self.assertEqual(result["code"], 0)
self.assertEqual(calls[0][1]["filelist"], ["fid-1", "fid-2"])
self.assertEqual(calls[0][1]["to_pdir_fid"], "target-fid")
def test_quark_save_files_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._transfer_engine._get_stoken = lambda pwd_id: "stoken"
captured = {}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: captured.setdefault("to_pdir_fid", to_pdir_fid) or "task-1"
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
self.assertEqual(adapter._save_files("pwd", {"fid": "src"}, "/Media"), ["new-fid"])
self.assertEqual(captured["to_pdir_fid"], "target-fid")
def test_uc_transfer_resolves_path_to_fid(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "target-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_dir"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_quark_transfer_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._credential.validate = lambda: True
captured = {}
def fake_transfer(share_url, save_dir, share_password):
captured["save_dir"] = save_dir
return {
"new_file_ids": ["new-fid"],
"file_name": "demo",
"share_url": "https://pan.quark.cn/s/new",
"passcode": share_password,
}
adapter._transfer_engine.transfer = fake_transfer
result = adapter._transfer("https://pan.quark.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_ensure_dir_creates_nested_paths_progressively(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
created = []
def fake_mkdir(path):
created.append(path)
return {"code": 0, "data": {"fid": "fid-" + path.rsplit("/", 1)[-1]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A", "/A/B"])
def test_uc_transfer_uses_staging_folder_then_moves_to_target(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "staging-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_to"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
def fake_move(fids, to_pdir_fid):
captured["move_to"] = to_pdir_fid
return {"code": 0, "status": 200}
adapter.move_files = fake_move
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_to"], "staging-fid")
self.assertEqual(captured["move_to"], "target-fid")
def test_api_success_requires_explicit_success_code_or_status(self):
adapter = make_adapter(QuarkAdapter)
self.assertFalse(adapter._api_success({}))
self.assertFalse(adapter._api_success({"message": "bad"}))
self.assertTrue(adapter._api_success({"code": 0}))
self.assertTrue(adapter._api_success({"status": 200}))
def test_query_task_passes_retry_index_and_timestamp_params(self):
adapter = make_adapter(QuarkAdapter)
captured_params = []
attempts = {"count": 0}
def fake_get(url, params=None, retry=None):
captured_params.append(dict(params))
attempts["count"] += 1
if attempts["count"] == 1:
return DummyResponse({"data": {"status": 1}})
return DummyResponse({"data": {"status": 2, "task_id": "task-1"}})
adapter._get = fake_get
result = adapter.query_task("task-1")
self.assertEqual(result["code"], 0)
self.assertEqual(captured_params[0]["retry_index"], 0)
self.assertEqual(captured_params[1]["retry_index"], 1)
self.assertIn("__dt", captured_params[0])
self.assertIn("__t", captured_params[0])
def test_quark_cleanup_recycle_only_removes_matching_fids(self):
adapter = make_adapter(QuarkAdapter)
adapter.recycle_list = lambda: [
{"fid": "keep", "record_id": "r-keep"},
{"fid": "target", "record_id": "r-target"},
]
captured = {}
def fake_recycle_remove(records):
captured["records"] = records
return {"code": 0}
adapter.recycle_remove = fake_recycle_remove
result = adapter.cleanup_recycle(["target"])
self.assertEqual(result["code"], 0)
self.assertEqual(captured["records"], [{"fid": "target", "record_id": "r-target"}])
def test_drive_api_json_raises_transfer_error_on_http_error(self):
adapter = make_adapter(QuarkAdapter)
class ErrorResponse(DummyResponse):
status_code = 429
text = "too many requests"
def raise_for_status(self):
raise requests.HTTPError("429 Too Many Requests")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(ErrorResponse({"code": 0}), context="限流测试")
self.assertIn("限流测试", str(ctx.exception))
def test_drive_api_json_raises_transfer_error_on_invalid_json(self):
adapter = make_adapter(QuarkAdapter)
class InvalidJsonResponse(DummyResponse):
status_code = 200
text = "<html>bad gateway</html>"
def json(self):
raise ValueError("not json")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(InvalidJsonResponse({}), context="JSON测试")
self.assertIn("JSON测试", str(ctx.exception))
def test_quark_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(QuarkAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1", "fid-2"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1", "fid-2"]])
def test_uc_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(UcAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1"]])
def test_uc_transfer_filters_ads_after_staging_save(self):
adapter = make_adapter(UcAdapter)
adapter.ensure_dir = lambda path: "target-dir"
adapter.get_or_create_share_folder = lambda: "staging-dir"
adapter._parse_share_url = lambda url: ("pwd-id", "")
adapter._filter_ads = lambda fids: [fid for fid in fids if fid != "ad-fid"]
adapter.transfer_config.ad_filter_enabled = True
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode: "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "Title"}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: "save-task"
adapter._transfer_engine._poll_save_task = lambda task_id: ["keep-fid", "ad-fid"]
adapter.move_files = lambda fids, target: {"code": 0}
adapter._transfer_engine._init_share = lambda fids, title: "share-task" if fids == ["keep-fid"] else (_ for _ in ()).throw(AssertionError(f"unfiltered fids: {fids}"))
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, pwd: ("https://share", pwd)
result = adapter.transfer("https://drive.uc.cn/s/abc")
self.assertEqual(result.new_file_id, "keep-fid")
def test_uc_ls_dir_wraps_http_and_json_errors(self):
adapter = make_adapter(UcAdapter)
class BadResponse:
text = "<html>bad gateway</html>"
def raise_for_status(self):
raise requests.HTTPError("HTTP 502")
def json(self):
raise AssertionError("json() should not be called after HTTP error")
adapter._get = lambda *args, **kwargs: BadResponse()
with self.assertRaises(TransferError):
adapter.ls_dir("0")
def test_base_declares_optional_drive_api_methods(self):
required = [
"ensure_dir", "get_fids", "mkdir", "rename", "move_files",
"delete_files", "query_task", "cleanup_recycle",
]
for name in required:
self.assertTrue(hasattr(BaseCloudDriveAdapter, name), name)
def test_base_optional_drive_api_methods_raise_transfer_error(self):
class MinimalAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "minimal"
def verify(self, share_url):
raise NotImplementedError
def _get_share_detail(self, pwd_id, passcode=""):
raise NotImplementedError
def _save_files(self, pwd_id, detail, save_dir):
raise NotImplementedError
def _create_share(self, file_ids, title, password=""):
raise NotImplementedError
def get_files(self, parent_fid="0"):
raise NotImplementedError
def delete(self, file_ids):
raise NotImplementedError
adapter = MinimalAdapter(PlatformConfig(), TransferConfig())
with self.assertRaises(TransferError):
adapter.ensure_dir("/Media")
with self.assertRaises(TransferError):
adapter.move_files(["fid"], "target")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,135 @@
import subprocess
import unittest
from pathlib import Path
class TransferServerRuntimeTests(unittest.TestCase):
def test_transfer_image_imports_server_module_from_app_workdir(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config.json",
"cloudsearch-transfer:test",
"python", "-c",
"import cloudsearch_transfer.server as s; print(hasattr(s, 'app'))",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("True", result.stdout)
def test_update_platform_creates_config_with_package_import(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_server_test.json",
"cloudsearch-transfer:test",
"python", "-c",
"""
import os
os.environ['TRANSFER_API_TOKEN'] = 'test-token'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'test-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(resp.status_code)
print(resp.get_data(as_text=True))
""",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("200", result.stdout)
self.assertIn("newplatform", result.stdout)
def test_dockerfile_healthcheck_uses_http_health_probe(self):
dockerfile = Path(__file__).resolve().parents[1] / "Dockerfile"
content = dockerfile.read_text()
healthcheck_block = content.split("HEALTHCHECK", 1)[1].split("\n\nCMD", 1)[0]
self.assertIn("/health", healthcheck_block)
self.assertNotIn('python -m cloudsearch_transfer.server', healthcheck_block)
def test_config_update_requires_api_token_when_configured(self):
script = """
import os
os.environ['TRANSFER_API_TOKEN'] = 'secret-token'
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
unauth = client.put('/api/config/platforms/newplatform', json={'enabled': True})
auth = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'secret-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(unauth.status_code)
print(auth.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
self.assertIn("200", result.stdout)
def test_config_update_requires_token_by_default(self):
script = """
import os
os.environ.pop('TRANSFER_API_TOKEN', None)
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_default_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', json={'enabled': True})
print(resp.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_default_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
def test_config_save_persists_full_credentials_for_runtime_reload(self):
script = """
from cloudsearch_transfer.config import ConfigManager, PlatformConfig
path = '/tmp/transfer_config_persist_test.json'
cfg = ConfigManager(path)
cfg.platforms['quark'] = PlatformConfig(enabled=True, cookie='cookie-value-that-is-longer-than-twenty-characters', refresh_token='refresh-token-that-is-longer-than-twenty-characters')
cfg.save()
reloaded = ConfigManager(path)
print(reloaded.platforms['quark'].cookie)
print(reloaded.platforms['quark'].refresh_token)
"""
result = subprocess.run(
["docker", "run", "--rm", "cloudsearch-transfer:test", "python", "-c", script],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("cookie-value-that-is-longer-than-twenty-characters", result.stdout)
self.assertIn("refresh-token-that-is-longer-than-twenty-characters", result.stdout)
self.assertNotIn("...", result.stdout)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -0,0 +1,19 @@
# CloudSearch 测试服部署记录
- 服务器:泽御云香港测试服,SSH 入口 root@82.158.228.152:18924。
- 当前运行来源:`/root/cloudsearch_deploy`
- 主应用目录:`/root/cloudsearch_deploy/source_clean`
- 网盘能力层:`/root/cloudsearch_deploy/cloudsearch_transfer`
- 启动方式:Docker Compose,主容器挂载 `/root/cloudsearch_deploy` 相关目录。
- 集成边界:只集成 cloud-auto-save 中可复用的网盘 API 调用能力;不集成签到、收益事件、收益上报,也不长期部署独立 cloud-auto-save 服务。
- Video Parser 当前未部署,属于测试环境预期缺口,不作为主服务故障处理。
- 本轮隔离点:`backup-before-drive-api-20260522-152138`;开发分支:`feature/cloudsearch-drive-api-20260522-152138`
## 验证命令
```bash
python3 -m unittest discover -s cloudsearch_transfer/tests -p 'test_*.py' -v
python3 /tmp/verify_compile.py
docker compose ps
curl -fsS http://127.0.0.1:3000/health
```
+1 -1
View File
@@ -1 +1 @@
0.5.4 0.5.6
@@ -0,0 +1,58 @@
# CloudSearch 单容器网盘能力 P0 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 将 UC 网盘转存能力和主应用云盘 driver 分发能力集成进 `source_clean`,由现有 `CloudSearch_App` 单容器构建和运行,不新增长期 transfer 容器。
**架构:** 在 Node/TypeScript 主应用内新增 `UcDriver` 和轻量 driver factory。`saveFromShare()`、凭据验证、连接测试仍走现有 `cloud_configs``save_records``/api/save` 路径,只扩展 `uc` 类型,不改变前端主流程。
**技术栈:** Node 20 fetch、TypeScript commonjs、Express、better-sqlite3、Vitest、Docker Compose。
---
## 文件结构
- 创建:`src/cloud/drivers/uc-api.ts` — UC API 常量、请求头、URL 解析、任务轮询辅助。
- 创建:`src/cloud/drivers/uc.driver.ts` — UC Cookie 验证、7 步转存、创建新分享,对齐现有 driver 返回结构。
- 创建:`src/cloud/drivers/uc.driver.test.ts` — UC driver 红绿回归测试,mock fetch 验证 API 流程。
- 创建:`src/cloud/driver-factory.ts` — 根据 `cloud_type` 创建 driver,避免 `credential.service.ts``cloud.service.ts` 分散 switch。
- 创建:`src/cloud/driver-factory.test.ts` — 验证 `uc` 使用 `UcDriver`,未知类型返回 `null`
- 修改:`src/cloud/cloud.service.ts` — 保存入口支持 `uc`,复用统一 driver factory。
- 修改:`src/cloud/credential.service.ts` — Cookie 测试和运行前凭据验证支持 `uc`
## 任务 1:写失败测试
- [ ] 编写 `src/cloud/driver-factory.test.ts`:断言 `createCloudDriver(uc, {cookie})` 产物 class 为 `UcDriver``unknown``null`
- [ ] 编写 `src/cloud/drivers/uc.driver.test.ts`:断言 UC URL 解析、Cookie 长度校验、mock fetch 的完整转存链路。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
- [ ] 预期:失败,原因是 `driver-factory``uc.driver` 不存在。
## 任务 2:实现 UC driver 和 factory
- [ ] 新增 `uc-api.ts``UC_API_BASE=https://pc-api.uc.cn``UC_SHARE_API``getUcHeaders()``parseUcShareUrl()`
- [ ] 新增 `uc.driver.ts`:实现 `validate()``saveFromShare()`,流程为 stoken、detail、save、poll save task、create share、poll share task、set password。
- [ ] 新增 `driver-factory.ts`:支持 `quark``baidu``aliyun``uc`
- [ ] 运行同一组测试,预期通过。
## 任务 3:接入主保存和凭据验证路径
- [ ] 修改 `cloud.service.ts`:用 factory 创建 driver`uc` 调用 `saveFromShare()`,返回字段写入现有 `save_records`
- [ ] 修改 `credential.service.ts``testCloudConnectionWithCookie(uc)``getAndValidateCredential(uc)` 使用 `UcDriver.validate()`
- [ ] 新增或扩展测试覆盖 factory 接入。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
## 任务 4:构建与单容器部署验证
- [ ] 运行:`npm run build`
- [ ] 运行:`docker compose build app`
- [ ] 运行:`docker compose up -d app`
- [ ] 验证:`docker compose ps` 只有 `app/redis/pansou`,没有 transfer 容器。
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/health`
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/api/admin/cloud-types` 能看到 `uc`
## 验收标准
- `CloudSearch_App` 单容器内的主应用构建包含 `UcDriver`
- `/api/save``target_cloud=uc` 不再返回“暂不支持”。
- 无长期 `cloudsearch-transfer``cloud-auto-save-test` 容器。
- 没有收益上报、签到、独立 transfer 服务集成。
@@ -0,0 +1,24 @@
# CloudSearch 测试服治理说明
## 当前测试服定位
`/root/cloudsearch_deploy` 是当前 Docker Compose 实际运行目录。`CloudSearch_App` 的 compose label 与挂载均指向该目录。
## 健康检查口径
`Video Parser` 当前仍是待开发/未部署能力。测试服健康判断应区分核心必需组件与可选/开发中能力。
源码中的 `computeOverallHealth()` 支持通过 `VIDEO_PARSER_REQUIRED=true` 将 Video Parser 纳入必需健康判定;未设置时按可选能力处理。
## 版本来源
根目录 `VERSION``source_clean/VERSION` 当前均为 `0.5.5`。构建脚本会从根目录 `VERSION` 复制到 `source_clean/VERSION` 再构建镜像。
## 验证命令
```bash
cd /root/cloudsearch_deploy/source_clean
npm run build
npm test -- --run src/health/health.test.ts
curl -fsS http://127.0.0.1:9527/health
```
+39 -4
View File
@@ -3,16 +3,51 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="CloudSearch - 网盘资源搜索" />
<meta property="og:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta property="og:url" content="https://zy.hk.timxx.cn/" />
<meta property="og:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="CloudSearch - 网盘资源搜索" />
<meta name="twitter:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="twitter:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<link rel="canonical" href="https://zy.hk.timxx.cn/" />
<title>CloudSearch - 网盘资源搜索</title> <title>CloudSearch - 网盘资源搜索</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script> <script>
(function() { (function() {
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ function setMeta(selector, attr, value) {
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索'; var el = document.querySelector(selector);
}).catch(function(){}); if (!el) return;
el.setAttribute(attr, value);
}
function sync(cfg) {
var origin = window.location.origin;
var siteName = cfg.site_name || 'CloudSearch';
var title = siteName + ' - 网盘资源搜索';
var description = cfg.site_disclaimer || '随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。';
var image = cfg.site_logo ? new URL(cfg.site_logo, origin).href : origin + '/api/uploads/logo/site_logo.png';
var canonical = origin + '/';
document.title = title;
setMeta('meta[name="description"]', 'content', description);
setMeta('meta[name="robots"]', 'content', 'index,follow');
setMeta('meta[property="og:title"]', 'content', title);
setMeta('meta[property="og:description"]', 'content', description);
setMeta('meta[property="og:url"]', 'content', canonical);
setMeta('meta[property="og:image"]', 'content', image);
setMeta('meta[name="twitter:title"]', 'content', title);
setMeta('meta[name="twitter:description"]', 'content', description);
setMeta('meta[name="twitter:image"]', 'content', image);
setMeta('link[rel="canonical"]', 'href', canonical);
}
sync({});
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ sync(cfg || {}); }).catch(function(){});
})(); })();
</script> </script>
</head> </head>
@@ -20,4 +55,4 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+387
View File
@@ -23,6 +23,7 @@
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
}, },
@@ -869,6 +870,121 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.15", "version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -1086,6 +1202,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1121,6 +1246,15 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1141,6 +1275,31 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1194,6 +1353,23 @@
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true "dev": true
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decamelize": { "node_modules/decamelize": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -1202,6 +1378,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1294,6 +1479,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1362,6 +1553,15 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1568,6 +1768,12 @@
"lodash-es": "*" "lodash-es": "*"
} }
}, },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1623,6 +1829,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1698,6 +1910,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1845,6 +2072,12 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1853,6 +2086,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1877,6 +2122,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
@@ -1960,6 +2244,93 @@
} }
} }
}, },
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -2060,6 +2431,22 @@
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -24,6 +25,7 @@
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
} }
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://zy.hk.timxx.cn/sitemap.xml
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://zy.hk.timxx.cn/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/disclaimer/</loc>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/admin/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/user/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
</urlset>
+43
View File
@@ -3,6 +3,49 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { applySeo, resolveSeo } from './seo'
interface SiteConfig {
site_name?: string
site_logo?: string
site_disclaimer?: string
}
const route = useRoute()
let currentSiteName = 'CloudSearch'
let currentSiteLogo = ''
let currentSiteDisclaimer = ''
function refreshSeo() {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
applySeo(resolveSeo(route.path, route.query as Record<string, unknown>, currentSiteName, origin, currentSiteLogo, currentSiteDisclaimer))
}
async function loadSiteConfig() {
try {
const resp = await fetch('/api/site-config')
if (!resp.ok) return
const cfg = (await resp.json()) as SiteConfig
if (cfg.site_name) currentSiteName = cfg.site_name
if (cfg.site_logo) currentSiteLogo = cfg.site_logo
if (cfg.site_disclaimer) currentSiteDisclaimer = cfg.site_disclaimer
} catch {
// keep defaults
}
}
onMounted(async () => {
await loadSiteConfig()
refreshSeo()
})
watch(
() => route.fullPath,
() => refreshSeo(),
{ immediate: true },
)
</script> </script>
<style> <style>
@@ -0,0 +1,42 @@
<template>
<main class="disclaimer-page">
<section class="disclaimer-card">
<h1>免责声明</h1>
<p>CloudSearch 仅聚合和展示公开网络资源线索不存储不上传不分发任何网盘文件内容</p>
<p>搜索结果来自第三方公开页面或接口资源有效性合法性与安全性需由用户自行判断</p>
<p>请勿使用本站搜索保存或传播侵权违法违规内容如权利人认为相关信息侵犯权益请联系站点管理员处理</p>
<router-link class="back-link" to="/">返回首页</router-link>
</section>
</main>
</template>
<style scoped>
.disclaimer-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f8fafc;
}
.disclaimer-card {
max-width: 760px;
padding: 32px;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
color: #334155;
line-height: 1.8;
}
.disclaimer-card h1 {
margin: 0 0 20px;
color: #0f172a;
}
.back-link {
display: inline-flex;
margin-top: 20px;
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
</style>
@@ -1,12 +1,13 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<div class="hero-section"> <div class="hero-section">
<h1 class="sr-only">网盘资源搜索</h1>
<template v-if="configLoaded"> <template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" /> <img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div> <div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template> </template>
<div class="search-box"> <div class="search-box">
<el-input v-model="query" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch"> <el-input v-model="query" aria-label="搜索网盘资源" name="q" placeholder="搜索网盘资源..." size="large" clearable @keyup.enter="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button> <el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button>
@@ -27,6 +28,7 @@
<div class="footer-inner">{{ siteDisclaimer }}</div> <div class="footer-inner">{{ siteDisclaimer }}</div>
<el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button> <el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -35,6 +37,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api' import { getCategorizedRankings, getSiteConfig } from '../api'
import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers'
const router = useRouter() const router = useRouter()
const query = ref('') const query = ref('')
@@ -50,9 +53,9 @@ const QS = ['学而时习之,不亦说乎。', '温故而知新,可以为师
const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7'] const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7']
const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12] const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12]
function handleSearch() { const q = query.value.trim(); if(q) router.push('/search?q='+encodeURIComponent(q)) } function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) }
function searchTag(t:string) { router.push('/search?q='+encodeURIComponent(t)) } function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) }
function openDisclaimer() { window.open('/disclaimer/', '_blank') } function openDisclaimer() { window.location.assign(createDisclaimerTarget()) }
onMounted(async () => { onMounted(async () => {
currentQuote.value = QS[Math.floor(Math.random()*QS.length)] currentQuote.value = QS[Math.floor(Math.random()*QS.length)]
@@ -81,6 +84,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.home-page{min-height:100vh;background:#f5f7fa} .home-page{min-height:100vh;background:#f5f7fa}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa} .hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa}
.logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px} .logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px}
.logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px} .logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px}
@@ -0,0 +1,27 @@
<template>
<main class="not-found-page">
<section class="not-found-card">
<p class="eyebrow">404</p>
<h1>页面不存在</h1>
<p class="description">你访问的页面不存在或已被移动</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</section>
</main>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<style scoped>
.not-found-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f7fa;padding:24px}
.not-found-card{width:min(460px,100%);padding:40px 32px;text-align:center;background:#fff;border-radius:18px;box-shadow:0 12px 32px rgba(31,45,61,.08)}
.eyebrow{margin:0 0 8px;color:#409eff;font-size:14px;font-weight:700;letter-spacing:4px}
h1{margin:0 0 12px;font-size:28px;color:#1d2129}
.description{margin:0 0 24px;color:#606266;line-height:1.7}
</style>
+14 -3
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
const routes = [ export const routes = [
{ {
path: '/', path: '/',
name: 'home', name: 'home',
@@ -16,6 +16,12 @@ const routes = [
name: 'result-detail', name: 'result-detail',
component: () => import('./pages/ResultDetail.vue'), component: () => import('./pages/ResultDetail.vue'),
}, },
{
path: '/disclaimer',
alias: '/disclaimer/',
name: 'disclaimer',
component: () => import('./pages/Disclaimer.vue'),
},
{ {
path: '/admin/login', path: '/admin/login',
name: 'admin-login', name: 'admin-login',
@@ -71,10 +77,15 @@ const routes = [
path: '/user', path: '/user',
redirect: '/user/login', redirect: '/user/login',
}, },
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('./pages/NotFound.vue'),
},
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
routes, routes,
}) })
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveSeo } from './seo'
describe('resolveSeo', () => {
it('builds home page seo for the site root', () => {
const seo = resolveSeo('/', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('随便下 | 资源小站 - 网盘资源搜索')
expect(seo.description).toContain('网盘资源搜索')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/')
expect(seo.robots).toBe('index,follow')
})
it('builds search page seo from the q parameter', () => {
const seo = resolveSeo('/search', { q: '阿凡达' }, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('阿凡达 - 搜索结果 | 随便下 | 资源小站')
expect(seo.description).toContain('阿凡达')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/search?q=%E9%98%BF%E5%87%A1%E8%BE%BE')
})
it('marks admin pages as noindex', () => {
const seo = resolveSeo('/admin/login', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.robots).toBe('noindex,nofollow')
expect(seo.title).toContain('管理后台')
})
})
+125
View File
@@ -0,0 +1,125 @@
export interface SeoState {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
}
function getSearchQuery(query: Record<string, unknown>): string {
const q = query.q;
if (typeof q === 'string') return q.trim();
const input = query.input;
if (typeof input === 'string') return input.trim();
return '';
}
function normalizePath(pathname: string): string {
if (!pathname) return '/';
if (!pathname.startsWith('/')) return `/${pathname}`;
return pathname;
}
function buildCanonical(origin: string, pathname: string, query: Record<string, unknown>): string {
const base = origin ? origin.replace(/\/+$/, '') : '';
const normalizedPath = normalizePath(pathname);
const params = new URLSearchParams();
if (normalizedPath.startsWith('/search')) {
const q = getSearchQuery(query);
if (q) params.set('q', q);
}
const suffix = params.toString() ? `?${params.toString()}` : '';
return `${base}${normalizedPath}${suffix}` || normalizedPath;
}
export function resolveSeo(
pathname: string,
query: Record<string, unknown> = {},
siteName = 'CloudSearch',
origin = '',
siteLogo = '',
siteDisclaimer = '',
): SeoState {
const normalizedPath = normalizePath(pathname);
const q = getSearchQuery(query);
const titleSuffix = `${siteName} - 网盘资源搜索`;
let title = titleSuffix;
let description = `${siteName} 提供网盘资源搜索、热门榜单和内容聚合。`;
let robots = 'index,follow';
if (normalizedPath.startsWith('/search')) {
if (q) {
title = `${q} - 搜索结果 | ${siteName}`;
description = `搜索 ${q} 的网盘资源结果,查看链接、分类和保存记录。`;
} else {
title = `搜索结果 | ${siteName}`;
description = `${siteName} 的网盘资源搜索结果页。`;
}
} else if (normalizedPath.startsWith('/result/')) {
title = `资源详情 | ${siteName}`;
description = `查看资源详情、链接信息和关联内容。`;
} else if (normalizedPath.startsWith('/disclaimer')) {
title = `免责声明 | ${siteName}`;
description = siteDisclaimer || `查看 ${siteName} 的免责声明和使用说明。`;
} else if (normalizedPath.startsWith('/admin')) {
title = `管理后台 | ${siteName}`;
description = `访问 ${siteName} 的管理后台。`;
robots = 'noindex,nofollow';
} else if (normalizedPath.startsWith('/user')) {
title = `用户中心 | ${siteName}`;
description = `访问 ${siteName} 的用户中心。`;
robots = 'noindex,nofollow';
}
const canonical = buildCanonical(origin, normalizedPath, query);
const image = siteLogo
? new URL(siteLogo, origin || 'https://zy.hk.timxx.cn').href
: `${origin ? origin.replace(/\/+$/, '') : ''}/api/uploads/logo/site_logo.png` || '/api/uploads/logo/site_logo.png';
return {
title,
description,
canonical,
image,
robots,
};
}
function setMeta(selector: string, attr: string, value: string): void {
if (typeof document === 'undefined') return;
let el = document.querySelector(selector) as HTMLMetaElement | HTMLLinkElement | null;
if (!el) {
el = document.createElement(selector.startsWith('link') ? 'link' : 'meta');
if (selector.startsWith('meta[name="')) {
const name = selector.match(/meta\[name="([^"]+)"\]/)?.[1];
if (name) (el as HTMLMetaElement).setAttribute('name', name);
}
if (selector.startsWith('meta[property="')) {
const property = selector.match(/meta\[property="([^"]+)"\]/)?.[1];
if (property) (el as HTMLMetaElement).setAttribute('property', property);
}
if (selector.startsWith('link[')) {
const rel = selector.match(/link\[rel="([^"]+)"\]/)?.[1];
if (rel) (el as HTMLLinkElement).setAttribute('rel', rel);
}
document.head.appendChild(el);
}
el.setAttribute(attr, value);
}
export function applySeo(seo: SeoState): void {
if (typeof document === 'undefined') return;
document.title = seo.title;
setMeta('meta[name="description"]', 'content', seo.description);
setMeta('meta[name="robots"]', 'content', seo.robots);
setMeta('meta[property="og:title"]', 'content', seo.title);
setMeta('meta[property="og:description"]', 'content', seo.description);
setMeta('meta[property="og:type"]', 'content', 'website');
setMeta('meta[property="og:url"]', 'content', seo.canonical);
setMeta('meta[property="og:image"]', 'content', seo.image);
setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
setMeta('meta[name="twitter:title"]', 'content', seo.title);
setMeta('meta[name="twitter:description"]', 'content', seo.description);
setMeta('meta[name="twitter:image"]', 'content', seo.image);
setMeta('link[rel="canonical"]', 'href', seo.canonical);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { createDisclaimerTarget, createSearchPath, getHealthDisplay } from './home-helpers';
describe('home helpers', () => {
it('builds an encoded search path from trimmed user input', () => {
expect(createSearchPath(' 周杰伦 演唱会 ')).toBe('/search?q=%E5%91%A8%E6%9D%B0%E4%BC%A6%20%E6%BC%94%E5%94%B1%E4%BC%9A');
expect(createSearchPath(' ')).toBeNull();
});
it('uses same-tab navigation for the full disclaimer page', () => {
expect(createDisclaimerTarget()).toBe('/disclaimer');
});
it('marks optional video parser gaps as a test-environment note, not a core outage', () => {
const display = getHealthDisplay('ok', {
videoParser: 'unreachable',
videoParserRequired: false,
});
expect(display.isCoreHealthy).toBe(true);
expect(display.label).toBe('核心服务正常');
expect(display.note).toContain('视频解析');
expect(display.note).toContain('测试环境');
});
});
@@ -0,0 +1,35 @@
export interface HealthDisplayComponents {
videoParser?: string;
videoParserRequired?: boolean;
}
export interface HealthDisplay {
label: string;
isCoreHealthy: boolean;
note: string;
}
export function createSearchPath(query: string): string | null {
const trimmed = query.trim();
if (!trimmed) return null;
return `/search?q=${encodeURIComponent(trimmed)}`;
}
export function createDisclaimerTarget(path = '/disclaimer'): string {
return path;
}
export function getHealthDisplay(status: string, components: HealthDisplayComponents = {}): HealthDisplay {
const isCoreHealthy = status === 'ok' || (!components.videoParserRequired && status === 'degraded');
const label = isCoreHealthy ? '核心服务正常' : status === 'degraded' ? '部分服务降级' : '服务异常';
const videoParserOptionalGap = !components.videoParserRequired
&& components.videoParser
&& components.videoParser !== 'ok';
return {
label,
isCoreHealthy,
note: videoParserOptionalGap ? '视频解析仍在开发中,当前测试环境未启用,不影响核心搜索服务。' : '',
};
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { createRouter, createMemoryHistory } from 'vue-router';
import { routes } from '../router';
describe('application routes', () => {
function makeRouter() {
return createRouter({ history: createMemoryHistory(), routes });
}
it('resolves public and admin routes before the catch-all not-found route', () => {
const router = makeRouter();
expect(router.resolve('/').name).toBe('home');
expect(router.resolve('/search?q=test').name).toBe('search');
expect(router.resolve('/result/abc123').name).toBe('result-detail');
expect(router.resolve('/admin/login').name).toBe('admin-login');
expect(router.resolve('/admin/dashboard').matched.some(route => route.name === 'admin-dashboard')).toBe(true);
});
it('has an explicit disclaimer route instead of relying on the not-found fallback', () => {
const router = makeRouter();
expect(router.resolve('/disclaimer').name).toBe('disclaimer');
expect(router.resolve('/disclaimer/').name).toBe('disclaimer');
});
it('resolves unknown frontend routes to the not-found page', () => {
const router = makeRouter();
expect(router.resolve('/nonexistent-ui-audit').name).toBe('not-found');
expect(router.resolve('/unknown/path').name).toBe('not-found');
});
});
@@ -0,0 +1,14 @@
const KNOWN_PUBLIC_ROUTE_PATTERNS = [
/^\/$/,
/^\/search\/?$/,
/^\/result\/[^/]+\/?$/,
/^\/admin\/login\/?$/,
/^\/user\/login\/?$/,
/^\/user\/dashboard\/?$/,
/^\/user\/?$/,
];
export function isKnownPublicRoute(path: string): boolean {
const cleanPath = path.split('?')[0].split('#')[0] || '/';
return KNOWN_PUBLIC_ROUTE_PATTERNS.some(pattern => pattern.test(cleanPath));
}
+7 -17
View File
@@ -1,8 +1,7 @@
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { localTimestamp, formatLocalDateTime } from '../utils/time'; import { localTimestamp, formatLocalDateTime } from '../utils/time';
import { getSystemConfig } from '../admin/system-config.service'; import { getSystemConfig } from '../admin/system-config.service';
import { QuarkDriver } from './drivers/quark.driver'; import { createCloudDriver, supportsSaveFromShare } from './driver-factory';
import { BaiduDriver } from './drivers/baidu.driver';
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
import { lookupIpLocation } from './ip-lookup'; import { lookupIpLocation } from './ip-lookup';
import { notifyConfigEvent } from './notification.service'; import { notifyConfigEvent } from './notification.service';
@@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
try { try {
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string }; let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
switch (cloudType) { const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname });
case 'quark': { if (!supportsSaveFromShare(driver)) {
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname }); return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
driverResult = await driver.saveFromShare(shareUrl, sourceTitle, retrySave);
break;
}
case 'baidu': {
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break;
}
case 'aliyun':
return { success: false, message: '阿里云盘保存功能暂未实现' };
default:
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
} }
driverResult = cloudType === 'quark'
? await driver.saveFromShare(shareUrl, sourceTitle, retrySave)
: await driver.saveFromShare(shareUrl, sourceTitle);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
+37 -20
View File
@@ -1,4 +1,5 @@
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api'; import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
import { createCloudDriver, supportsValidate } from './driver-factory';
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
@@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{
let storageUsed = config.storage_used || ''; let storageUsed = config.storage_used || '';
let storageTotal = config.storage_total || ''; let storageTotal = config.storage_total || '';
if (config.cloud_type === 'baidu') { const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any;
const { BaiduDriver } = require('./drivers/baidu.driver'); if (supportsValidate(driver)) {
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate(); valid = await driver.validate();
if (valid) { }
if (valid) {
if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') {
const info = await driver.getUserInfo(); const info = await driver.getUserInfo();
if (info) { if (info) {
nickname = config.nickname || info.nickname || '百度网盘'; nickname = config.nickname || info.nickname || '百度网盘';
@@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{
storageUsed = fmt(info.usedBytes); storageUsed = fmt(info.usedBytes);
storageTotal = fmt(info.totalBytes); storageTotal = fmt(info.totalBytes);
} }
} } else if (config.cloud_type === 'quark') {
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
valid = await driver.validate();
if (valid) {
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘'; nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
const storage = await driver.getStorageInfoQuick(); if (typeof driver.getStorageInfoQuick === 'function') {
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || ''); const storage = await driver.getStorageInfoQuick();
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || ''); storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
}
} else if (config.cloud_type === 'uc') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || 'UC网盘';
} else if (config.cloud_type === 'aliyun') {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || '阿里云盘';
} }
} }
if (valid && !nickname) {
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || config.cloud_type;
}
const db = getDb(); const db = getDb();
if (!valid) { if (!valid) {
db.prepare( db.prepare(
@@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
message: nickname ? '连接成功' : '连接成功(无法获取昵称)', message: nickname ? '连接成功' : '连接成功(无法获取昵称)',
nickname: nickname || '阿里云盘', nickname: nickname || '阿里云盘',
}; };
} else if (cloudType === 'uc') {
const driver = createCloudDriver('uc', { cookie, nickname: '' }) as any;
const valid = supportsValidate(driver) ? await driver.validate() : false;
if (!valid) {
return { success: false, message: '连接失败:UC Cookie 无效或长度不足' };
}
const nickname = typeof driver.getNickname === 'function' ? await driver.getNickname() : 'UC网盘';
return {
success: true,
message: '连接成功',
nickname: nickname || 'UC网盘',
};
} else { } else {
return { return {
success: true, success: true,
@@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
try { try {
let cookieValid = false; let cookieValid = false;
if (cloudType === 'baidu') { const driver = createCloudDriver(cloudType, { cookie: cookie, nickname: config.nickname });
const { BaiduDriver } = require('./drivers/baidu.driver'); if (supportsValidate(driver)) {
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate();
} else {
const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
cookieValid = await driver.validate(); cookieValid = await driver.validate();
} else if (cloudType === 'aliyun') {
cookieValid = true;
} }
if (!cookieValid) { if (!cookieValid) {
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
let tempDir = '';
async function loadCredentialService() {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudsearch-credential-'));
process.env.DB_PATH = path.join(tempDir, 'cloudsearch.db');
process.env.DATA_DIR = tempDir;
process.env.COOKIE_ENCRYPTION_KEY = 'test-cookie-encryption-key-000000000000';
const service = await import('./credential.service');
return service;
}
describe('credential service UC integration', () => {
beforeEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
});
afterEach(async () => {
await import('vitest').then(({ vi }) => vi.resetModules());
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
delete process.env.DB_PATH;
delete process.env.DATA_DIR;
delete process.env.COOKIE_ENCRYPTION_KEY;
});
it('tests a saved Xunlei cloud config through the Xunlei driver instead of the unsupported fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'xunlei',
cookie: 'r'.repeat(32),
nickname: 'Xunlei Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'Xunlei Account',
});
});
it('tests a saved UC cloud config through the UC driver instead of the Quark fallback', async () => {
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
const saved = saveCloudConfig({
cloud_type: 'uc',
cookie: 'k=' + 'x'.repeat(80),
nickname: 'UC Account',
is_active: 1,
});
const result = await testCloudConnection(saved.id);
expect(result).toMatchObject({
success: true,
message: '连接成功',
nickname: 'UC Account',
});
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
const cloudDir = path.resolve(__dirname);
const ucDir = path.join(cloudDir, 'drivers', 'uc');
const xunleiDir = path.join(cloudDir, 'drivers', 'xunlei');
const legacyUcFiles = [
path.join(cloudDir, 'drivers', 'uc.driver.ts'),
path.join(cloudDir, 'drivers', 'uc-api.ts'),
];
function readTsFiles(dir: string): Array<{ file: string; content: string }> {
if (!fs.existsSync(dir)) return [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: Array<{ file: string; content: string }> = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...readTsFiles(full));
if (entry.isFile() && full.endsWith('.ts')) files.push({ file: full, content: fs.readFileSync(full, 'utf8') });
}
return files;
}
describe('cloud driver module boundaries', () => {
it('keeps UC implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(ucDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(ucDir, 'api.ts'))).toBe(true);
});
it('does not keep UC implementation in the flat drivers directory', () => {
for (const file of legacyUcFiles) {
if (!fs.existsSync(file)) continue;
const content = fs.readFileSync(file, 'utf8');
expect(content.trim()).toMatch(/^export \* from '\.\/uc\//);
}
});
it('does not let the UC module import or require Quark modules', () => {
const offenders = readTsFiles(ucDir).filter(({ content }) => /from ['"].*quark|require\(['"].*quark/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
it('keeps Xunlei implementation in its own driver folder', () => {
expect(fs.existsSync(path.join(xunleiDir, 'driver.ts'))).toBe(true);
expect(fs.existsSync(path.join(xunleiDir, 'api.ts'))).toBe(true);
});
it('does not let the Xunlei module import or require Quark/UC modules', () => {
const offenders = readTsFiles(xunleiDir).filter(({ content }) => /from ['"].*(quark|uc)|require\(['"].*(quark|uc)/.test(content));
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { createCloudDriver } from './driver-factory';
import { UcDriver } from './drivers/uc/driver';
import { XunleiDriver } from './drivers/xunlei/driver';
describe('createCloudDriver', () => {
it('creates a UC driver inside the main app process', () => {
const driver = createCloudDriver('uc', { cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' });
expect(driver).toBeInstanceOf(UcDriver);
});
it('creates a Xunlei driver inside the main app process', () => {
const driver = createCloudDriver('xunlei', { refreshToken: 'r'.repeat(32), nickname: 'xl-test' });
expect(driver).toBeInstanceOf(XunleiDriver);
});
it('returns null for unsupported cloud types', () => {
expect(createCloudDriver('unsupported-drive', { cookie: 'x' })).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { QuarkDriver, QuarkConfig } from './drivers/quark.driver';
import { BaiduDriver, BaiduConfig } from './drivers/baidu.driver';
import { AliyunDriver, AliyunConfig } from './drivers/aliyun.driver';
import { UcDriver, UcConfig } from './drivers/uc/driver';
import { XunleiDriver, XunleiConfig } from './drivers/xunlei/driver';
export type CloudDriver = QuarkDriver | BaiduDriver | AliyunDriver | UcDriver | XunleiDriver;
export type CloudDriverConfig = QuarkConfig | BaiduConfig | AliyunConfig | UcConfig | XunleiConfig;
export function createCloudDriver(cloudType: string, config: CloudDriverConfig): CloudDriver | null {
switch (cloudType) {
case 'quark':
return new QuarkDriver(config as QuarkConfig);
case 'baidu':
return new BaiduDriver(config as BaiduConfig);
case 'aliyun':
return new AliyunDriver(config as AliyunConfig);
case 'uc':
return new UcDriver(config as UcConfig);
case 'xunlei':
return new XunleiDriver(config as XunleiConfig);
default:
return null;
}
}
export function supportsSaveFromShare(driver: CloudDriver | null): driver is CloudDriver & { saveFromShare: (...args: any[]) => Promise<any> } {
return !!driver && typeof (driver as any).saveFromShare === 'function';
}
export function supportsValidate(driver: CloudDriver | null): driver is CloudDriver & { validate: () => Promise<boolean> } {
return !!driver && typeof (driver as any).validate === 'function';
}
+1
View File
@@ -0,0 +1 @@
export * from './uc/api';
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseUcShareUrl, UC_API_BASE, UC_SHARE_API } from './uc/api';
import { UcDriver } from './uc/driver';
class MockResponse {
ok = true;
status = 200;
constructor(private payload: any) {}
async json() { return this.payload; }
}
describe('UcDriver', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('parses UC share ids from drive.uc.cn links', () => {
expect(parseUcShareUrl('https://drive.uc.cn/s/abcdef')).toBe('abcdef');
expect(parseUcShareUrl('https://example.com/s/abcdef')).toBeNull();
});
it('validates cookie presence with the same lightweight rule used by transfer reference', async () => {
await expect(new UcDriver({ cookie: 'too-short' }).validate()).resolves.toBe(false);
await expect(new UcDriver({ cookie: 'k=' + 'x'.repeat(80) }).validate()).resolves.toBe(true);
});
it('runs the UC save/share API flow and returns CloudSearch SaveResult shape', async () => {
const calls: Array<{ url: string; method?: string; body?: any }> = [];
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: any, init: any = {}) => {
const url = String(input);
const body = init.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url, method: init.method, body });
if (url.startsWith(`${UC_SHARE_API}/sharepage/v2/detail`)) {
return new MockResponse({ status: 0, data: { token_info: { stoken: 'stoken-1' } } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/detail`)) {
return new MockResponse({ status: 0, data: { title: 'Demo Folder', fid: 'src-fid', share_fid_token: 'src-token' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/sharepage/save`)) {
expect(body).toMatchObject({
fid_list: ['src-fid'],
fid_token_list: ['src-token'],
to_pdir_fid: '0',
pwd_id: 'abcdef',
stoken: 'stoken-1',
});
return new MockResponse({ status: 0, data: { task_id: 'save-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('save-task')) {
return new MockResponse({ status: 200, data: { status: 2, save_as: { save_as_top_fids: ['new-fid'] } } }) as any;
}
if (url === UC_SHARE_API) {
expect(body).toMatchObject({ fid_list: ['new-fid'], title: 'Demo Folder' });
return new MockResponse({ status: 0, data: { task_id: 'share-task' } }) as any;
}
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('share-task')) {
return new MockResponse({ status: 200, data: { status: 2, share_id: 'new-share-id' } }) as any;
}
if (url.startsWith(`${UC_SHARE_API}/password`)) {
expect(body).toMatchObject({ share_id: 'new-share-id' });
return new MockResponse({ status: 0, data: { share_url: 'https://drive.uc.cn/s/newshare', passcode: 'pw12' } }) as any;
}
throw new Error(`Unexpected fetch: ${url}`);
});
const result = await new UcDriver({ cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' }).saveFromShare('https://drive.uc.cn/s/abcdef');
expect(result).toMatchObject({
success: true,
message: '转存成功',
shareUrl: 'https://drive.uc.cn/s/newshare',
sharePwd: 'pw12',
folderName: 'Demo Folder',
fileCount: 1,
folderCount: 0,
});
expect(fetchMock).toHaveBeenCalledTimes(7);
expect(calls.map(c => c.url)).toContain(UC_SHARE_API);
});
});
@@ -0,0 +1 @@
export * from './uc/driver';
+44
View File
@@ -0,0 +1,44 @@
// UC 网盘 API 常量与轻量工具
export const UC_API_BASE = 'https://pc-api.uc.cn';
export const UC_WEB_HOST = 'https://drive.uc.cn';
export const UC_SHARE_API = `${UC_API_BASE}/1/clouddrive/share`;
export const UC_EP = {
SHARE_PAGE_TOKEN: '/sharepage/v2/detail',
SHARE_PAGE_DETAIL: '/sharepage/detail',
SHARE_PAGE_SAVE: '/sharepage/save',
SHARE_PASSWORD: '/password',
TASK: '/1/clouddrive/task',
} as const;
export function getUcHeaders(cookie: string): Record<string, string> {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cookie': cookie,
'Referer': `${UC_WEB_HOST}/`,
'Origin': UC_WEB_HOST,
};
}
export function parseUcShareUrl(shareUrl: string): string | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('drive.uc.cn')) return null;
const match = url.pathname.match(/\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
} catch {
const match = shareUrl.match(/drive\.uc\.cn\/s\/([a-zA-Z0-9_-]+)/);
return match?.[1] || null;
}
}
export function getUcCommonParams(extra: Record<string, string | number> = {}): URLSearchParams {
return new URLSearchParams({
pr: 'UCBrowser',
fr: 'pc',
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, String(v)])),
});
}
+207
View File
@@ -0,0 +1,207 @@
import { getUcCommonParams, getUcHeaders, parseUcShareUrl, UC_API_BASE, UC_EP, UC_SHARE_API } from './api';
export interface UcConfig {
cookie?: string;
nickname?: string;
}
interface UcDetail {
title?: string;
fid?: string;
fid_list?: string[];
share_fid_token?: string;
fid_token_list?: string[];
[key: string]: any;
}
interface UcDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
export class UcDriver {
private config: UcConfig;
constructor(config: UcConfig = {}) {
this.config = { ...config };
}
private get cookie(): string {
return this.config.cookie || '';
}
async validate(): Promise<boolean> {
return this.cookie.length >= 50;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || 'UC网盘';
}
private headers(json = false): Record<string, string> {
return {
...getUcHeaders(this.cookie),
...(json ? { 'Content-Type': 'application/json' } : {}),
};
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) {
throw new Error(`${context}失败: HTTP ${response.status}`);
}
return response.json();
}
private apiOk(data: any): boolean {
return data?.status === 0 || data?.status === 200 || data?.code === 0;
}
private async getStoken(pwdId: string, passcode = ''): Promise<string> {
const url = `${UC_SHARE_API}${UC_EP.SHARE_PAGE_TOKEN}?${getUcCommonParams().toString()}`;
const response = await fetch(url, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ passcode, pwd_id: pwdId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC stoken');
const stoken = data?.data?.token_info?.stoken;
if (!stoken) throw new Error(`获取UC stoken失败: ${data?.message || 'stoken缺失'}`);
return stoken;
}
private async getDetail(pwdId: string, stoken: string): Promise<UcDetail> {
const params = getUcCommonParams({ pwd_id: pwdId, stoken, _fetch_share: '1' });
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_DETAIL}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取UC分享详情');
if (!this.apiOk(data) || !data?.data) {
throw new Error(`获取UC分享详情失败: ${data?.message || '详情为空'}`);
}
return data.data;
}
private normalizeDetailLists(detail: UcDetail): { fidList: string[]; fidTokenList: string[] } {
let fidList = detail.fid_list || (detail.fid ? [detail.fid] : []);
let fidTokenList = detail.fid_token_list || (detail.share_fid_token ? [detail.share_fid_token] : []);
if (!Array.isArray(fidList)) fidList = fidList ? [fidList] : [];
if (!Array.isArray(fidTokenList)) fidTokenList = fidTokenList ? [fidTokenList] : [];
return { fidList, fidTokenList };
}
private async initSave(pwdId: string, stoken: string, detail: UcDetail, toPdirFid = '0'): Promise<string> {
const { fidList, fidTokenList } = this.normalizeDetailLists(detail);
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_SAVE}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({
fid_list: fidList,
fid_token_list: fidTokenList,
to_pdir_fid: toPdirFid,
pwd_id: pwdId,
stoken,
pdir_fid: '0',
scene: 'link',
}),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起UC转存');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`发起UC转存失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async pollTask(taskId: string, mode: 'save' | 'share'): Promise<any> {
for (let retryIndex = 0; retryIndex < 50; retryIndex++) {
const params = getUcCommonParams({ task_id: taskId, retry_index: retryIndex });
const response = await fetch(`${UC_API_BASE}${UC_EP.TASK}?${params.toString()}`, {
headers: this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询UC任务');
const status = data?.data?.status;
if (status === 2) return data.data;
if (status === -1 || status === 3) throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务失败: ${data?.message || taskId}`);
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务超时: ${taskId}`);
}
private async initShare(fileIds: string[], title: string): Promise<string> {
const response = await fetch(UC_SHARE_API, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ fid_list: fileIds, title: title || '分享', expired_type: 1 }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建UC分享');
if (!this.apiOk(data) || !data?.data?.task_id) {
throw new Error(`创建UC分享失败: ${data?.message || 'task_id缺失'}`);
}
return data.data.task_id;
}
private async setPassword(shareId: string, password = ''): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PASSWORD}`, {
method: 'POST',
headers: this.headers(true),
body: JSON.stringify({ share_id: shareId }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '设置UC分享密码');
if (!this.apiOk(data)) {
throw new Error(`设置UC分享密码失败: ${data?.message || 'API错误'}`);
}
return {
shareUrl: data?.data?.share_url || `https://drive.uc.cn/s/${shareId}`,
passcode: data?.data?.passcode || password || '',
};
}
async saveFromShare(shareUrl: string, _sourceTitle?: string): Promise<UcDriverResult> {
if (!(await this.validate())) {
return { success: false, message: 'UC Cookie 无效或长度不足' };
}
const pwdId = parseUcShareUrl(shareUrl);
if (!pwdId) {
return { success: false, message: '无法解析UC分享链接' };
}
try {
const stoken = await this.getStoken(pwdId);
const detail = await this.getDetail(pwdId, stoken);
const saveTaskId = await this.initSave(pwdId, stoken, detail, '0');
const saveTask = await this.pollTask(saveTaskId, 'save');
const newFileIds: string[] = saveTask?.save_as?.save_as_top_fids || [];
if (!newFileIds.length) throw new Error('UC转存完成但未返回文件ID');
const title = detail.title || _sourceTitle || '分享';
const shareTaskId = await this.initShare(newFileIds, title);
const shareTask = await this.pollTask(shareTaskId, 'share');
const shareId = shareTask?.share_id || shareTask?.result?.share_id;
if (!shareId) throw new Error('UC分享任务完成但未返回share_id');
const shared = await this.setPassword(shareId);
return {
success: true,
message: '转存成功',
shareUrl: shared.shareUrl,
sharePwd: shared.passcode,
folderName: title,
fileCount: newFileIds.length,
folderCount: 0,
};
} catch (err: any) {
return { success: false, message: err?.message || 'UC转存失败' };
}
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { parseXunleiShareUrl, getXunleiHeaders } from './xunlei/api';
import { XunleiDriver } from './xunlei/driver';
describe('Xunlei cloud driver', () => {
it('parses common Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz?pwd=7k9m')).toEqual({ shareId: 'VNabc123xyz', passcode: '7k9m' });
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz')).toEqual({ shareId: 'VNabc123xyz', passcode: '' });
});
it('rejects non-Xunlei share URLs', () => {
expect(parseXunleiShareUrl('https://drive.uc.cn/s/abcdef')).toBeNull();
});
it('builds bearer headers from refresh-token derived access tokens', () => {
expect(getXunleiHeaders('access-token-1').Authorization).toBe('Bearer access-token-1');
});
it('validates refresh token presence without doing network IO', async () => {
await expect(new XunleiDriver({ refreshToken: '' }).validate()).resolves.toBe(false);
await expect(new XunleiDriver({ refreshToken: 'r'.repeat(32), nickname: 'xl-test' }).validate()).resolves.toBe(true);
});
});
@@ -0,0 +1 @@
export * from './xunlei/driver';
@@ -0,0 +1,50 @@
// 迅雷网盘 API 常量与轻量工具
export const XUNLEI_PAN_API = 'https://api-pan.xunlei.com';
export const XUNLEI_AUTH_API = 'https://xluser-ssl.xunlei.com';
export const XUNLEI_WEB_HOST = 'https://pan.xunlei.com';
export const XUNLEI_CLIENT_ID = 'Xqp0kJBXWhwaTpB6';
export const XUNLEI_DEVICE_ID = '925b7631473a13716b791d7f28289cad';
export const XUNLEI_EP = {
SHARE_INFO: '/drive/v1/share',
SHARE_RESTORE: '/drive/v1/share/restore',
TASK: '/drive/v1/tasks',
CREATE_SHARE: '/drive/v1/share',
FILES: '/drive/v1/files',
TOKEN: '/v1/auth/token',
} as const;
export interface XunleiShareParts {
shareId: string;
passcode: string;
}
export function parseXunleiShareUrl(shareUrl: string): XunleiShareParts | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('pan.xunlei.com')) return null;
const match = url.pathname.match(/\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
return { shareId: match[1], passcode: url.searchParams.get('pwd') || '' };
} catch {
const match = shareUrl.match(/pan\.xunlei\.com\/s\/([A-Za-z0-9_-]+)/);
if (!match) return null;
const pwd = shareUrl.match(/[?&]pwd=([A-Za-z0-9_-]+)/)?.[1] || '';
return { shareId: match[1], passcode: pwd };
}
}
export function getXunleiHeaders(accessToken = '', captchaToken = ''): Record<string, string> {
const headers: Record<string, string> = {
'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',
'x-client-id': XUNLEI_CLIENT_ID,
'x-device-id': XUNLEI_DEVICE_ID,
};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
if (captchaToken) headers['x-captcha-token'] = captchaToken;
return headers;
}
@@ -0,0 +1,179 @@
import {
getXunleiHeaders,
parseXunleiShareUrl,
XUNLEI_AUTH_API,
XUNLEI_CLIENT_ID,
XUNLEI_EP,
XUNLEI_PAN_API,
} from './api';
export interface XunleiConfig {
refreshToken?: string;
cookie?: string;
nickname?: string;
}
interface XunleiDriverResult {
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
fileCount?: number;
folderCount?: number;
}
interface XunleiShareInfo {
pass_code_token?: string;
files?: Array<{ id?: string; file_id?: string; name?: string; kind?: string; is_dir?: boolean }>;
title?: string;
share_name?: string;
[key: string]: any;
}
export class XunleiDriver {
private config: XunleiConfig;
private accessToken = '';
private accessTokenExpiresAt = 0;
constructor(config: XunleiConfig = {}) {
this.config = { ...config };
}
private get refreshToken(): string {
return (this.config.refreshToken || this.config.cookie || '').trim();
}
async validate(): Promise<boolean> {
// 测试环境先做离线格式校验,真实 API 调用在 saveFromShare 时换取 access_token。
return this.refreshToken.length >= 20;
}
async getNickname(): Promise<string | null> {
return this.config.nickname || '迅雷网盘';
}
private async readJson(response: Response, context: string): Promise<any> {
if (!response.ok) throw new Error(`${context}失败: HTTP ${response.status}`);
return response.json();
}
private apiOk(data: any): boolean {
return data?.errcode === 0 || data?.error_code === 0 || data?.code === 0 || (!data?.errcode && !data?.error_code && !data?.code);
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 60000) return this.accessToken;
if (!(await this.validate())) throw new Error('迅雷 refresh_token 无效或长度不足');
const response = await fetch(`${XUNLEI_AUTH_API}${XUNLEI_EP.TOKEN}`, {
method: 'POST',
headers: getXunleiHeaders(),
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: XUNLEI_CLIENT_ID }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '刷新迅雷 access_token');
if (!data?.access_token) throw new Error(`刷新迅雷 access_token失败: ${data?.message || data?.error || 'access_token缺失'}`);
this.accessToken = data.access_token;
this.accessTokenExpiresAt = Date.now() + Number(data.expires_in || 7200) * 1000;
if (data.refresh_token) this.config.refreshToken = data.refresh_token;
return this.accessToken;
}
private async headers(): Promise<Record<string, string>> {
return getXunleiHeaders(await this.getAccessToken());
}
private async getShareInfo(shareId: string, passcode = ''): Promise<XunleiShareInfo> {
const params = new URLSearchParams({ share_id: shareId });
if (passcode) params.set('pass_code', passcode);
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_INFO}?${params.toString()}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '获取迅雷分享详情');
if (!this.apiOk(data)) throw new Error(`获取迅雷分享详情失败: ${data?.message || data?.error || 'API错误'}`);
const files = data.files || data.data?.files || [];
if (!files.length) throw new Error('迅雷分享内容为空');
return { ...data, ...(data.data || {}), files };
}
private extractFileIds(files: XunleiShareInfo['files']): string[] {
return (files || []).map(file => file.id || file.file_id || '').filter(Boolean);
}
private async restoreFiles(shareId: string, passCodeToken: string, fileIds: string[]): Promise<string> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_RESTORE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, pass_code_token: passCodeToken, share_id: shareId, parent_id: '', specify_parent_id: true }),
signal: AbortSignal.timeout(30000),
});
const data = await this.readJson(response, '发起迅雷转存');
if (!this.apiOk(data)) throw new Error(`发起迅雷转存失败: ${data?.message || data?.error || 'API错误'}`);
const taskId = data.restore_task_id || data.task_id || data.data?.restore_task_id || data.data?.task_id;
if (!taskId) throw new Error('发起迅雷转存失败: task_id缺失');
return taskId;
}
private parseTraceFileIds(trace: any): Record<string, string> {
if (!trace) return {};
const parsed = typeof trace === 'string' ? JSON.parse(trace || '{}') : trace;
return Object.fromEntries(Object.entries(parsed).map(([oldId, value]) => [oldId, typeof value === 'string' ? value : (value as any)?.id || (value as any)?.file_id || '']).filter(([, newId]) => !!newId));
}
private async pollRestoreTask(taskId: string): Promise<string[]> {
for (let attempt = 0; attempt < 50; attempt++) {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.TASK}/${taskId}`, {
headers: await this.headers(),
signal: AbortSignal.timeout(10000),
});
const data = await this.readJson(response, '查询迅雷转存任务');
if (data.status === 'failed' || data.status === 'error') throw new Error(`迅雷转存任务失败: ${taskId}`);
if (data.progress === 100 || data.status === 'success' || data.status === 'complete') {
const mapping = this.parseTraceFileIds(data.params?.trace_file_ids || data.trace_file_ids || data.data?.params?.trace_file_ids);
const ids = Object.values(mapping);
if (ids.length) return ids;
if (Array.isArray(data.file_ids)) return data.file_ids;
if (Array.isArray(data.data?.file_ids)) return data.data.file_ids;
throw new Error('迅雷转存完成但未返回文件ID');
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`迅雷转存任务超时: ${taskId}`);
}
private async createShare(fileIds: string[], title: string): Promise<{ shareUrl: string; passcode: string }> {
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.CREATE_SHARE}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify({ file_ids: fileIds, title: title || '分享' }),
signal: AbortSignal.timeout(15000),
});
const data = await this.readJson(response, '创建迅雷分享');
if (!this.apiOk(data)) throw new Error(`创建迅雷分享失败: ${data?.message || data?.error || 'API错误'}`);
const payload = data.data || data;
const shareUrl = payload.share_url || payload.url;
if (!shareUrl) throw new Error('创建迅雷分享失败: share_url缺失');
return { shareUrl, passcode: payload.pass_code || payload.passcode || '' };
}
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<XunleiDriverResult> {
if (!(await this.validate())) return { success: false, message: '迅雷 refresh_token 无效或长度不足' };
const parsed = parseXunleiShareUrl(shareUrl);
if (!parsed) return { success: false, message: '无法解析迅雷分享链接' };
try {
const info = await this.getShareInfo(parsed.shareId, parsed.passcode);
const sourceFileIds = this.extractFileIds(info.files);
if (!sourceFileIds.length) throw new Error('无法从迅雷分享中提取文件ID');
const restoreTaskId = await this.restoreFiles(parsed.shareId, info.pass_code_token || '', sourceFileIds);
const savedFileIds = await this.pollRestoreTask(restoreTaskId);
const title = info.title || info.share_name || sourceTitle || '分享';
const shared = await this.createShare(savedFileIds, title);
return { success: true, message: '转存成功', shareUrl: shared.shareUrl, sharePwd: shared.passcode, folderName: title, fileCount: savedFileIds.length, folderCount: 0 };
} catch (err: any) {
return { success: false, message: err?.message || '迅雷转存失败' };
}
}
}
+2
View File
@@ -25,6 +25,7 @@ export interface Config {
uploadDir: string; uploadDir: string;
chromiumPath: string; chromiumPath: string;
dbPath: string; dbPath: string;
slowQueryThresholdMs: number;
} }
const DEFAULT_JWT_SECRETS = ['CHANGEME-jwt-placeholder-1', 'CHANGEME-jwt-placeholder-2']; const DEFAULT_JWT_SECRETS = ['CHANGEME-jwt-placeholder-1', 'CHANGEME-jwt-placeholder-2'];
@@ -94,6 +95,7 @@ const config: Config = {
uploadDir: process.env.UPLOAD_DIR || '/app/uploads', uploadDir: process.env.UPLOAD_DIR || '/app/uploads',
chromiumPath: process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser", chromiumPath: process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser",
dbPath: process.env.DB_PATH || './data/cloudsearch.db', dbPath: process.env.DB_PATH || './data/cloudsearch.db',
slowQueryThresholdMs: parseInt(process.env.SLOW_QUERY_THRESHOLD_MS || '100', 10),
}; };
// Startup validation done by startup-validator // Startup validation done by startup-validator
+3
View File
@@ -3,6 +3,8 @@ import path from 'path';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import config from '../config'; import config from '../config';
import { formatLocalDateTime } from '../utils/time'; import { formatLocalDateTime } from '../utils/time';
import { createSlowQueryObserver } from '../observability/slow-query';
import { instrumentDatabaseSlowQueries } from '../observability/database-instrumentation';
let db: Database.Database | null = null; let db: Database.Database | null = null;
@@ -16,6 +18,7 @@ export function getDb(): Database.Database {
} }
db = new Database(config.dbPath); db = new Database(config.dbPath);
instrumentDatabaseSlowQueries(db, createSlowQueryObserver({ thresholdMs: config.slowQueryThresholdMs }));
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { computeOverallHealth } from './health';
describe('computeOverallHealth', () => {
it('returns ok when core services are healthy and the video parser is optional', () => {
const status = computeOverallHealth({
dbOk: true,
redisStatus: 'connected',
pansouStatus: 'ok',
videoParserStatus: 'unreachable',
});
expect(status).toBe('ok');
});
it('returns degraded when the video parser is required but unavailable', () => {
const status = computeOverallHealth(
{
dbOk: true,
redisStatus: 'connected',
pansouStatus: 'ok',
videoParserStatus: 'unreachable',
},
{ videoParserRequired: true },
);
expect(status).toBe('degraded');
});
});
+32
View File
@@ -0,0 +1,32 @@
export type HealthStatus = 'ok' | 'degraded' | 'unhealthy';
export interface HealthInputs {
dbOk: boolean;
redisStatus: string;
pansouStatus: string;
videoParserStatus: string;
}
export interface HealthOptions {
videoParserRequired?: boolean;
}
export function computeOverallHealth(
{ dbOk, redisStatus, pansouStatus, videoParserStatus }: HealthInputs,
options: HealthOptions = {},
): HealthStatus {
const videoParserRequired = options.videoParserRequired ?? false;
if (dbOk && redisStatus === 'connected' && pansouStatus === 'ok') {
if (!videoParserRequired || videoParserStatus === 'ok') {
return 'ok';
}
return 'degraded';
}
if (dbOk && redisStatus !== 'unknown' && pansouStatus !== 'unreachable') {
return 'degraded';
}
return 'unhealthy';
}
+12 -5
View File
@@ -6,6 +6,7 @@ import morgan from 'morgan';
import config from './config'; import config from './config';
import { VERSION as version } from "./version"; import { VERSION as version } from "./version";
import { checkStartup } from './config/startup-validator'; import { checkStartup } from './config/startup-validator';
import { computeOverallHealth } from './health/health';
import { getDb } from './database/database'; import { getDb } from './database/database';
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache'; import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
import rateLimiter from './middleware/rate-limit'; import rateLimiter from './middleware/rate-limit';
@@ -14,6 +15,7 @@ import userRoutes from './user/routes';
import { pansouWebProxy } from './proxy/pansou-web'; import { pansouWebProxy } from './proxy/pansou-web';
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service'; import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
import { refreshAllStorageInfo } from './cloud/cloud.service'; import { refreshAllStorageInfo } from './cloud/cloud.service';
import { readPsiSummary } from './observability/psi';
const app = express(); const app = express();
@@ -104,11 +106,15 @@ app.get('/health', async (_req, res) => {
} catch { return 'unreachable'; } } catch { return 'unreachable'; }
})(); })();
const overall = dbOk && redisStatus === 'connected' && pansouStatus === 'ok' && videoParserStatus === 'ok' const overall = computeOverallHealth(
? 'ok' {
: dbOk && redisStatus !== 'unknown' && pansouStatus !== 'unreachable' dbOk,
? 'degraded' redisStatus,
: 'unhealthy'; pansouStatus,
videoParserStatus,
},
{ videoParserRequired: process.env.VIDEO_PARSER_REQUIRED === 'true' },
);
res.json({ res.json({
version, version,
@@ -122,6 +128,7 @@ app.get('/health', async (_req, res) => {
pansou: pansouStatus, pansou: pansouStatus,
videoParser: videoParserStatus, videoParser: videoParserStatus,
}, },
pressure: readPsiSummary(),
}); });
}); });
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { instrumentDatabaseSlowQueries } from "./database-instrumentation";
describe("database slow-query instrumentation", () => {
it("wraps prepared statement methods and records their duration", () => {
const record = vi.fn();
const statement = {
get: vi.fn(() => ({ ok: true })),
all: vi.fn(() => [1, 2]),
run: vi.fn(() => ({ changes: 1 })),
};
const db = {
prepare: vi.fn(() => statement),
};
instrumentDatabaseSlowQueries(db as any, { record, now: (() => {
const ticks = [100, 155, 200, 204];
return () => ticks.shift() ?? 204;
})() });
const wrapped = (db.prepare as any)("SELECT * FROM search_stats WHERE keyword = ?");
expect(wrapped.get("x")).toEqual({ ok: true });
expect(wrapped.all()).toEqual([1, 2]);
expect(record).toHaveBeenCalledWith({
sql: "SELECT * FROM search_stats WHERE keyword = ?",
method: "get",
durationMs: 55,
});
expect(record).toHaveBeenCalledWith({
sql: "SELECT * FROM search_stats WHERE keyword = ?",
method: "all",
durationMs: 4,
});
});
it("is idempotent", () => {
const record = vi.fn();
const statement = { get: vi.fn(() => 1) };
const originalPrepare = vi.fn(() => statement);
const db = { prepare: originalPrepare };
instrumentDatabaseSlowQueries(db as any, { record, now: () => 1 });
const wrappedPrepare = db.prepare;
instrumentDatabaseSlowQueries(db as any, { record, now: () => 1 });
(db.prepare as any)("SELECT 1").get();
expect(db.prepare).toBe(wrappedPrepare);
expect(originalPrepare).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,62 @@
export interface StatementLike {
get?: (...args: any[]) => any;
all?: (...args: any[]) => any;
run?: (...args: any[]) => any;
iterate?: (...args: any[]) => any;
[key: string]: any;
}
export interface DatabaseLike {
prepare: (sql: string, ...args: any[]) => StatementLike;
[key: string]: any;
}
export interface QueryRecorder {
record(record: { sql: string; method: string; durationMs: number }): void;
}
export interface InstrumentationOptions {
record: QueryRecorder["record"];
now?: () => number;
}
const INSTRUMENTED = Symbol.for("cloudsearch.db.slowQueryInstrumented");
function wrapStatementMethod(
statement: StatementLike,
sql: string,
method: "get" | "all" | "run" | "iterate",
options: Required<InstrumentationOptions>,
): void {
const original = statement[method];
if (typeof original !== "function") return;
statement[method] = function wrappedStatementMethod(this: StatementLike, ...args: any[]) {
const start = options.now();
try {
return original.apply(this, args);
} finally {
options.record({ sql, method, durationMs: options.now() - start });
}
};
}
export function instrumentDatabaseSlowQueries(db: DatabaseLike, options: InstrumentationOptions): void {
if ((db as any)[INSTRUMENTED]) return;
(db as any)[INSTRUMENTED] = true;
const completeOptions: Required<InstrumentationOptions> = {
record: options.record,
now: options.now ?? (() => Number(process.hrtime.bigint()) / 1_000_000),
};
const originalPrepare = db.prepare.bind(db);
db.prepare = ((sql: string, ...args: any[]) => {
const statement = originalPrepare(sql, ...args);
wrapStatementMethod(statement, sql, "get", completeOptions);
wrapStatementMethod(statement, sql, "all", completeOptions);
wrapStatementMethod(statement, sql, "run", completeOptions);
wrapStatementMethod(statement, sql, "iterate", completeOptions);
return statement;
}) as DatabaseLike["prepare"];
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { parsePressureFile, summarizePsi } from "./psi";
describe("PSI parser", () => {
it("parses Linux pressure stall information", () => {
const parsed = parsePressureFile("some avg10=0.12 avg60=0.34 avg300=1.23 total=4567\nfull avg10=0.01 avg60=0.02 avg300=0.03 total=89\n");
expect(parsed.some).toEqual({ avg10: 0.12, avg60: 0.34, avg300: 1.23, total: 4567 });
expect(parsed.full).toEqual({ avg10: 0.01, avg60: 0.02, avg300: 0.03, total: 89 });
});
it("summarizes missing PSI files as unavailable instead of throwing", () => {
const summary = summarizePsi({
cpu: "some avg10=0.00 avg60=0.00 avg300=0.00 total=1\n",
memory: null,
io: null,
});
expect(summary.available).toBe(false);
expect(summary.cpu?.some?.avg10).toBe(0);
expect(summary.memory).toBeUndefined();
expect(summary.io).toBeUndefined();
});
it("marks pressure as warn when avg10 crosses warning thresholds", () => {
const summary = summarizePsi(
{
cpu: "some avg10=25.00 avg60=1.00 avg300=0.50 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
memory: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
io: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
},
{ cpuSomeAvg10Warn: 20, memorySomeAvg10Warn: 5, ioSomeAvg10Warn: 10 },
);
expect(summary.status).toBe("warn");
expect(summary.warnings).toContain("cpu.some.avg10 25 >= 20");
});
it("keeps pressure ok when all avg10 values are below thresholds", () => {
const summary = summarizePsi(
{
cpu: "some avg10=0.10 avg60=0.00 avg300=0.00 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
memory: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
io: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n",
},
{ cpuSomeAvg10Warn: 20, memorySomeAvg10Warn: 5, ioSomeAvg10Warn: 10 },
);
expect(summary.status).toBe("ok");
expect(summary.warnings).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import fs from "fs";
export interface PsiLine {
avg10: number;
avg60: number;
avg300: number;
total: number;
}
export interface PressureStats {
some?: PsiLine;
full?: PsiLine;
}
export interface PsiThresholds {
cpuSomeAvg10Warn?: number;
memorySomeAvg10Warn?: number;
ioSomeAvg10Warn?: number;
}
export interface PsiSummary {
available: boolean;
status: "ok" | "warn" | "unavailable";
warnings: string[];
cpu?: PressureStats;
memory?: PressureStats;
io?: PressureStats;
}
const DEFAULT_THRESHOLDS: Required<PsiThresholds> = {
cpuSomeAvg10Warn: Number(process.env.PSI_CPU_SOME_AVG10_WARN || 20),
memorySomeAvg10Warn: Number(process.env.PSI_MEMORY_SOME_AVG10_WARN || 5),
ioSomeAvg10Warn: Number(process.env.PSI_IO_SOME_AVG10_WARN || 10),
};
function parsePsiLine(line: string): ["some" | "full", PsiLine] | null {
const parts = line.trim().split(/\s+/);
const type = parts.shift();
if (type !== "some" && type !== "full") return null;
const values: Record<string, number> = {};
for (const part of parts) {
const [key, raw] = part.split("=");
const value = Number(raw);
if (Number.isFinite(value)) values[key] = value;
}
return [type, {
avg10: values.avg10 ?? 0,
avg60: values.avg60 ?? 0,
avg300: values.avg300 ?? 0,
total: values.total ?? 0,
}];
}
export function parsePressureFile(content: string): PressureStats {
const stats: PressureStats = {};
for (const line of content.split("\n")) {
if (!line.trim()) continue;
const parsed = parsePsiLine(line);
if (parsed) stats[parsed[0]] = parsed[1];
}
return stats;
}
function evaluatePressure(summary: PsiSummary, thresholds: Required<PsiThresholds>): void {
const checks: Array<{ label: string; value?: number; threshold: number }> = [
{ label: "cpu.some.avg10", value: summary.cpu?.some?.avg10, threshold: thresholds.cpuSomeAvg10Warn },
{ label: "memory.some.avg10", value: summary.memory?.some?.avg10, threshold: thresholds.memorySomeAvg10Warn },
{ label: "io.some.avg10", value: summary.io?.some?.avg10, threshold: thresholds.ioSomeAvg10Warn },
];
for (const check of checks) {
if (check.value !== undefined && check.value >= check.threshold) {
summary.warnings.push(`${check.label} ${check.value} >= ${check.threshold}`);
}
}
summary.status = summary.available ? (summary.warnings.length > 0 ? "warn" : "ok") : "unavailable";
}
export function summarizePsi(
files: { cpu?: string | null; memory?: string | null; io?: string | null },
thresholds: PsiThresholds = {},
): PsiSummary {
const summary: PsiSummary = { available: true, status: "ok", warnings: [] };
for (const key of ["cpu", "memory", "io"] as const) {
const content = files[key];
if (!content) {
summary.available = false;
continue;
}
summary[key] = parsePressureFile(content);
}
evaluatePressure(summary, { ...DEFAULT_THRESHOLDS, ...thresholds });
return summary;
}
export function readPsiSummary(basePath = "/proc/pressure"): PsiSummary {
const read = (name: string): string | null => {
try {
return fs.readFileSync(`${basePath}/${name}`, "utf8");
} catch {
return null;
}
};
return summarizePsi({ cpu: read("cpu"), memory: read("memory"), io: read("io") });
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { createSlowQueryObserver, normalizeSqlForLog } from "./slow-query";
describe("slow query observer", () => {
it("normalizes SQL before logging", () => {
expect(normalizeSqlForLog("SELECT *\nFROM search_stats WHERE keyword = ? LIMIT 1")).toBe(
"SELECT * FROM search_stats WHERE keyword = ? LIMIT 1",
);
});
it("does not log queries below threshold", () => {
const warn = vi.fn();
const observer = createSlowQueryObserver({ thresholdMs: 50, warn });
observer.record({ sql: "SELECT 1", method: "get", durationMs: 49.4 });
expect(warn).not.toHaveBeenCalled();
});
it("logs queries at or above threshold with method, duration and SQL", () => {
const warn = vi.fn();
const observer = createSlowQueryObserver({ thresholdMs: 50, warn });
observer.record({ sql: "SELECT *\nFROM search_stats WHERE keyword = ?", method: "all", durationMs: 51.2 });
expect(warn).toHaveBeenCalledTimes(1);
const entry = JSON.parse(warn.mock.calls[0][0]);
expect(entry).toMatchObject({
event: "slow_query",
method: "all",
durationMs: 51.2,
thresholdMs: 50,
sql: "SELECT * FROM search_stats WHERE keyword = ?",
});
});
it("emits structured JSON for slow query logs", () => {
const warn = vi.fn();
const observer = createSlowQueryObserver({ thresholdMs: 50, warn });
observer.record({ sql: "SELECT * FROM search_stats WHERE keyword = ?", method: "get", durationMs: 75 });
const entry = JSON.parse(warn.mock.calls[0][0]);
expect(entry).toMatchObject({
event: "slow_query",
method: "get",
durationMs: 75,
thresholdMs: 50,
sql: "SELECT * FROM search_stats WHERE keyword = ?",
});
expect(entry.timestamp).toEqual(expect.any(String));
});
});
@@ -0,0 +1,35 @@
export type SlowQueryMethod = "get" | "all" | "run" | "iterate" | string;
export interface SlowQueryRecord {
sql: string;
method: SlowQueryMethod;
durationMs: number;
}
export interface SlowQueryObserverOptions {
thresholdMs?: number;
warn?: (message: string) => void;
}
export function normalizeSqlForLog(sql: string): string {
return sql.replace(/\s+/g, " ").trim();
}
export function createSlowQueryObserver(options: SlowQueryObserverOptions = {}) {
const thresholdMs = options.thresholdMs ?? Number(process.env.SLOW_QUERY_THRESHOLD_MS || 100);
const warn = options.warn ?? console.warn;
return {
record({ sql, method, durationMs }: SlowQueryRecord): void {
if (durationMs < thresholdMs) return;
warn(JSON.stringify({
event: "slow_query",
timestamp: new Date().toISOString(),
method,
durationMs: Number(durationMs.toFixed(1)),
thresholdMs,
sql: normalizeSqlForLog(sql),
}));
},
};
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
mergeNotifySettingsUpdate,
mergePushUserNotifyConfigUpdate,
mergeSystemConfigEntriesUpdate,
} from './admin-config-helpers';
describe('admin config update helpers', () => {
it('preserves existing secrets when redacted system config values are submitted', () => {
const existing = [
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'Old name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } }) },
];
const incoming = [
{ key: 'tmdb_api_token', value: '***REDACTED***' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } }) },
];
const merged = mergeSystemConfigEntriesUpdate(incoming, existing);
expect(merged).toEqual([
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'site_name', value: 'New name' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } } }) },
]);
});
it('preserves existing per-cloud notification secrets from redacted placeholders', () => {
const existing = { channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: true } } };
const incoming = { channels: { webhook: { webhook_url: '***REDACTED***', enabled: false } } };
expect(mergeNotifySettingsUpdate(incoming, existing)).toEqual({
channels: { webhook: { webhook_url: 'https://hook.invalid/real', enabled: false } },
});
});
it('preserves existing push-user notification secrets from redacted placeholders', () => {
const existing = { notify_config: JSON.stringify({ channels: { bark: { token: 'real-token', enabled: true } } }) };
const incoming = { notify_config: { channels: { bark: { token: '***REDACTED***', enabled: false } } } };
expect(mergePushUserNotifyConfigUpdate(incoming, existing)).toEqual({
channels: { bark: { token: 'real-token', enabled: false } },
});
});
});
@@ -0,0 +1,46 @@
import { SystemConfigEntry } from '../admin/system-config.service';
import { mergeRedactedUpdate } from '../utils/redact';
function parseJsonObject(value: unknown): any {
if (!value) return {};
if (typeof value === 'object') return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function mergeJsonStringUpdate(incoming: string, existing: string | undefined): string {
try {
const parsedIncoming = JSON.parse(incoming);
const parsedExisting = parseJsonObject(existing);
return JSON.stringify(mergeRedactedUpdate(parsedIncoming, parsedExisting));
} catch {
return mergeRedactedUpdate(incoming, existing);
}
}
export function mergeSystemConfigEntriesUpdate(
entries: Array<{ key: string; value: string }>,
existing: SystemConfigEntry[],
): Array<{ key: string; value: string }> {
const existingByKey = new Map(existing.map(entry => [entry.key, entry.value]));
return entries.map(entry => {
const oldValue = existingByKey.get(entry.key);
return { ...entry, value: mergeJsonStringUpdate(entry.value, oldValue) };
});
}
export function mergeNotifySettingsUpdate(incoming: any, existing: any): any {
return mergeRedactedUpdate(incoming || {}, existing || {});
}
export function mergePushUserNotifyConfigUpdate(incoming: { notify_config?: any }, existing?: { notify_config?: string }): any {
return mergeNotifySettingsUpdate(
parseJsonObject(incoming?.notify_config),
parseJsonObject(existing?.notify_config),
);
}
+29 -18
View File
@@ -19,6 +19,8 @@ import { reconnectRedis, testRedisConnection } from '../middleware/cache';
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
import { BaiduDriver } from '../cloud/drivers/baidu.driver'; import { BaiduDriver } from '../cloud/drivers/baidu.driver';
import { testProxyConnection } from '../utils/proxy-agent'; import { testProxyConnection } from '../utils/proxy-agent';
import { mergeRedactedUpdate, redactSensitive } from '../utils/redact';
import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers';
const router = Router(); const router = Router();
@@ -147,7 +149,7 @@ router.use('/admin', authMiddleware);
router.get('/admin/cloud-configs', (_req: Request, res: Response) => { router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
try { try {
const configs = getCloudConfigs(); const configs = getCloudConfigs();
res.json(configs); res.json(redactSensitive(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
} }
@@ -182,7 +184,7 @@ router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
} }
} }
res.json(saved); res.json(redactSensitive(saved));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' }); res.status(500).json({ error: err.message || 'Failed to save cloud config' });
} }
@@ -197,8 +199,9 @@ router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
res.status(404).json({ error: 'Cloud config not found' }); res.status(404).json({ error: 'Cloud config not found' });
return; return;
} }
const saved = saveCloudConfig({ ...req.body, id }); const update = mergeRedactedUpdate(req.body, existing);
res.json(saved); const saved = saveCloudConfig({ ...update, id });
res.json(redactSensitive(saved));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update cloud config' }); res.status(500).json({ error: err.message || 'Failed to update cloud config' });
} }
@@ -368,7 +371,7 @@ router.get('/admin/save-records', (req: Request, res: Response) => {
router.get('/admin/system-configs', (_req: Request, res: Response) => { router.get('/admin/system-configs', (_req: Request, res: Response) => {
try { try {
const configs = getAllSystemConfigs(); const configs = getAllSystemConfigs();
res.json(configs); res.json(redactSensitive(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get system configs' }); res.status(500).json({ error: err.message || 'Failed to get system configs' });
} }
@@ -382,7 +385,8 @@ router.put('/admin/system-configs', (req: Request, res: Response) => {
res.status(400).json({ error: 'entries array is required' }); res.status(400).json({ error: 'entries array is required' });
return; return;
} }
updateSystemConfigs(entries); const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs());
updateSystemConfigs(mergedEntries);
res.json({ success: true }); res.json({ success: true });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to update system configs' }); res.status(500).json({ error: err.message || 'Failed to update system configs' });
@@ -479,7 +483,7 @@ router.get('/admin/db-status', async (_req: Request, res: Response) => {
db_path: dbFile, db_path: dbFile,
...counts, ...counts,
redis_status, redis_status,
redis_url, redis_url: redis_url ? '***REDACTED***' : redis_url,
}); });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get DB status' }); res.status(500).json({ error: err.message || 'Failed to get DB status' });
@@ -720,7 +724,7 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try { try {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const settings = getConfigNotifySettingsJSON(id); const settings = getConfigNotifySettingsJSON(id);
res.json(settings); res.json(redactSensitive(settings));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to get notification settings' }); res.status(400).json({ error: err.message || 'Failed to get notification settings' });
} }
@@ -730,7 +734,8 @@ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
try { try {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const settings = req.body; const existing = getConfigNotifySettingsJSON(id);
const settings = mergeNotifySettingsUpdate(req.body, existing);
saveConfigNotifySettings(id, settings); saveConfigNotifySettings(id, settings);
res.json({ success: true, message: 'Push config saved' }); res.json({ success: true, message: 'Push config saved' });
} catch (err: any) { } catch (err: any) {
@@ -764,7 +769,7 @@ router.get('/admin/notify/providers', (_req: Request, res: Response) => {
router.get('/admin/notify/global-config', (_req, res) => { router.get('/admin/notify/global-config', (_req, res) => {
try { try {
const cfg = getGlobalNotifyConfig(); const cfg = getGlobalNotifyConfig();
res.json(cfg); res.json(redactSensitive(cfg));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get global config' }); res.status(500).json({ error: err.message || 'Failed to get global config' });
} }
@@ -778,7 +783,9 @@ router.put('/admin/notify/global-config', (req, res) => {
res.status(400).json({ error: 'Invalid config object' }); res.status(400).json({ error: 'Invalid config object' });
return; return;
} }
updateSystemConfig('global_notify_config', JSON.stringify(cfg)); const existing = getGlobalNotifyConfig();
const merged = mergeNotifySettingsUpdate(cfg, existing);
updateSystemConfig('global_notify_config', JSON.stringify(merged));
res.json({ success: true }); res.json({ success: true });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save global config' }); res.status(500).json({ error: err.message || 'Failed to save global config' });
@@ -793,7 +800,7 @@ router.get('/admin/push-users', (_req: Request, res: Response) => {
...u, ...u,
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
})); }));
res.json(parsed); res.json(redactSensitive(parsed));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to list push users' }); res.status(500).json({ error: err.message || 'Failed to list push users' });
} }
@@ -804,9 +811,11 @@ router.post('/admin/push-users', (req: Request, res: Response) => {
try { try {
const { account, notify_config } = req.body; const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' }); if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const existing = getAllPushUsers().find(u => u.account === account);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = upsertPushUser(account, configStr); const user = upsertPushUser(account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to save push user' }); res.status(400).json({ error: err.message || 'Failed to save push user' });
} }
@@ -818,9 +827,11 @@ router.put('/admin/push-users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id as string); const id = parseInt(req.params.id as string);
const { account, notify_config } = req.body; const { account, notify_config } = req.body;
if (!account) return res.status(400).json({ error: 'account is required' }); if (!account) return res.status(400).json({ error: 'account is required' });
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const existing = getAllPushUsers().find(u => u.id === id);
const mergedConfig = mergePushUserNotifyConfigUpdate({ notify_config }, existing);
const configStr = JSON.stringify(mergedConfig);
const user = updatePushUser(id, account, configStr); const user = updatePushUser(id, account, configStr);
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
} catch (err: any) { } catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to update push user' }); res.status(400).json({ error: err.message || 'Failed to update push user' });
} }
@@ -849,7 +860,7 @@ router.get('/admin/daily-report/config', (_req, res) => {
try { try {
const { getDailyReportConfig } = require('../services/daily-report.service'); const { getDailyReportConfig } = require('../services/daily-report.service');
const cfg = getDailyReportConfig(); const cfg = getDailyReportConfig();
res.json(cfg); res.json(redactSensitive(cfg));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to get daily report config' }); res.status(500).json({ error: err.message || 'Failed to get daily report config' });
} }
@@ -861,7 +872,7 @@ router.put('/admin/daily-report/config', (req, res) => {
const { saveDailyReportConfig } = require('../services/daily-report.service'); const { saveDailyReportConfig } = require('../services/daily-report.service');
saveDailyReportConfig(req.body); saveDailyReportConfig(req.body);
const { getDailyReportConfig } = require('../services/daily-report.service'); const { getDailyReportConfig } = require('../services/daily-report.service');
res.json(getDailyReportConfig()); res.json(redactSensitive(getDailyReportConfig()));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save daily report config' }); res.status(500).json({ error: err.message || 'Failed to save daily report config' });
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import adminRoutes from "./admin.routes";
import uploadRoutes from "./upload.routes"; import uploadRoutes from "./upload.routes";
import cleanupRoutes from "./cleanup.routes"; import cleanupRoutes from "./cleanup.routes";
import { getAllSystemConfigs } from "../admin/system-config.service"; import { getAllSystemConfigs } from "../admin/system-config.service";
import { publicSystemConfigs } from "../utils/redact";
const router = Router(); const router = Router();
@@ -11,7 +12,7 @@ const router = Router();
router.get("/system-configs", (_req, res) => { router.get("/system-configs", (_req, res) => {
try { try {
const configs = getAllSystemConfigs(); const configs = getAllSystemConfigs();
res.json(configs); res.json(publicSystemConfigs(configs));
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message || "Failed to get system configs" }); res.status(500).json({ error: err.message || "Failed to get system configs" });
} }
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
describe('isPublicRankingKeyword', () => {
it('rejects script probes and html-like tags from public rankings', () => {
expect(isPublicRankingKeyword('<script>alert(1)</script>')).toBe(false);
expect(isPublicRankingKeyword('<img src=x onerror=alert(1)>')).toBe(false);
});
it('rejects obvious test keywords from public rankings', () => {
expect(isPublicRankingKeyword('test')).toBe(false);
expect(isPublicRankingKeyword('测试')).toBe(false);
expect(isPublicRankingKeyword(' 测试数据 ')).toBe(false);
});
it('keeps normal user search keywords', () => {
expect(isPublicRankingKeyword('庆余年')).toBe(true);
expect(isPublicRankingKeyword('4K 纪录片')).toBe(true);
});
it('keeps normal business keywords that start with testing-related words', () => {
expect(isPublicRankingKeyword('测试工程师教程')).toBe(true);
expect(isPublicRankingKeyword('测试开发面试')).toBe(true);
});
it('fills public ranking rows after filtering junk from a larger candidate set', () => {
const rows = [
{ keyword: 'test', count: 999 },
{ keyword: '<script>alert(1)</script>', count: 998 },
{ keyword: '庆余年', count: 10 },
{ keyword: '繁花', count: 9 },
];
expect(filterPublicRankingRows(rows, 2).map(row => row.keyword)).toEqual(['庆余年', '繁花']);
});
});
@@ -0,0 +1,24 @@
const TEST_KEYWORDS = new Set(['test', '测试', '测试数据']);
const HTML_TAG_PATTERN = /<\s*\/?\s*[a-z][^>]*>/i;
const SCRIPTISH_PATTERN = /(javascript\s*:|on\w+\s*=|<\s*script\b|alert\s*\()/i;
export interface PublicRankingRow {
keyword: string | null | undefined;
[key: string]: any;
}
export function isPublicRankingKeyword(keyword: string | null | undefined): boolean {
const normalized = String(keyword ?? '').trim();
if (!normalized) return false;
const lower = normalized.toLowerCase();
if (TEST_KEYWORDS.has(lower)) return false;
if (/^测试(?:\s*\d+|数据\d*)$/i.test(normalized)) return false;
if (HTML_TAG_PATTERN.test(normalized)) return false;
if (SCRIPTISH_PATTERN.test(normalized)) return false;
return true;
}
export function filterPublicRankingRows<T extends PublicRankingRow>(rows: T[], limit: number): T[] {
return rows.filter(row => isPublicRankingKeyword(row.keyword)).slice(0, limit);
}
+9 -6
View File
@@ -6,6 +6,7 @@
*/ */
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { formatLocalDateTime } from '../utils/time'; import { formatLocalDateTime } from '../utils/time';
import { filterPublicRankingRows, isPublicRankingKeyword } from './keyword-quality';
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
@@ -131,9 +132,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
const totalSearches = stats?.totalSearches || 0; const totalSearches = stats?.totalSearches || 0;
// 2. 热榜 Top 20 // 2. 热榜 Top 20
const hotRows = db.prepare( const rawHotRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 500'
).all() as any[]; ).all() as any[];
const hotRows = filterPublicRankingRows(rawHotRows, 20);
const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1; const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1;
@@ -151,9 +153,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
}); });
// 3. 最新搜索 Top 15 // 3. 最新搜索 Top 15
const recentRows = db.prepare( const rawRecentRows = db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 500'
).all() as any[]; ).all() as any[];
const recentRows = filterPublicRankingRows(rawRecentRows, 15);
const recentList: HotKeyword[] = recentRows.map((row: any) => { const recentList: HotKeyword[] = recentRows.map((row: any) => {
const genre = detectGenre(row.keyword); const genre = detectGenre(row.keyword);
@@ -169,9 +172,9 @@ async function generateSearchPulse(): Promise<SearchPulse> {
}); });
// 4. 按分类聚合 // 4. 按分类聚合
const allKeywords = db.prepare( const allKeywords = (db.prepare(
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC' 'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords WHERE search_count >= 3 ORDER BY search_count DESC'
).all() as any[]; ).all() as any[]).filter(row => isPublicRankingKeyword(row.keyword));
const categoryMap = new Map<string, HotKeyword[]>(); const categoryMap = new Map<string, HotKeyword[]>();
for (const row of allKeywords) { for (const row of allKeywords) {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { mergeRedactedUpdate, publicSystemConfigs, redactSensitive } from './redact';
describe('redactSensitive', () => {
it('redacts sensitive fields recursively while preserving non-sensitive data', () => {
const input = {
id: 1,
cookie: 'raw-cookie-secret',
password_hash: '$2a$secret-hash',
nested: {
token: 'secret-token',
webhook_url: 'https://example.invalid/hook/key',
keep: 'ok',
},
arr: [{ password: 'secret-password', value: 1 }],
};
expect(redactSensitive(input)).toEqual({
id: 1,
cookie: '***REDACTED***',
password_hash: '***REDACTED***',
nested: {
token: '***REDACTED***',
webhook_url: '***REDACTED***',
keep: 'ok',
},
arr: [{ password: '***REDACTED***', value: 1 }],
});
});
it('redacts system config values based on their config key while preserving key names', () => {
const input = [
{ key: 'tmdb_api_token', value: 'real-token', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
];
expect(redactSensitive(input)).toEqual([
{ key: 'tmdb_api_token', value: '***REDACTED***', description: 'TMDB token' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
it('preserves existing secret values when redacted placeholders are submitted in updates', () => {
const existing = {
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: true,
},
nickname: 'old',
};
const incoming = {
cookie: '***REDACTED***',
notify_config: {
webhook_url: '***REDACTED***',
enabled: false,
},
nickname: 'new',
};
expect(mergeRedactedUpdate(incoming, existing)).toEqual({
cookie: 'real-cookie',
notify_config: {
webhook_url: 'https://example.invalid/hook/real',
enabled: false,
},
nickname: 'new',
});
});
it('redacts sensitive values inside JSON string system config values', () => {
const input = [
{
key: 'global_notify_config',
value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real', token: 'real-token' } } }),
},
];
const output = redactSensitive(input);
const parsed = JSON.parse(output[0].value);
expect(parsed.channels.webhook.webhook_url).toBe('***REDACTED***');
expect(parsed.channels.webhook.token).toBe('***REDACTED***');
});
it('returns only safe public system config entries', () => {
const input = [
{ key: 'site_name', value: 'CloudSearch' },
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'tmdb_api_token', value: 'real-token' },
{ key: 'global_notify_config', value: JSON.stringify({ channels: { webhook: { webhook_url: 'https://hook.invalid/real' } } }) },
];
expect(publicSystemConfigs(input)).toEqual([
{ key: 'site_disclaimer', value: 'For search only' },
{ key: 'site_name', value: 'CloudSearch' },
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
export const REDACTED_PLACEHOLDER = '***REDACTED***';
const SENSITIVE_FIELD_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|redis_url|proxy_url)/i;
const SENSITIVE_CONFIG_KEY_RE = /(cookie|password|password_hash|token|secret|authorization|webhook_url|access_token|refresh_token|api[_-]?key|redis_url|proxy_url)/i;
const PUBLIC_SYSTEM_CONFIG_KEYS = new Set([
'site_name',
'site_logo',
'site_disclaimer',
'search_placeholder',
'search_fallback_image',
'whitelist_dirs',
'custom_footer',
]);
export interface SystemConfigLike {
key: string;
value: string;
description?: string;
updated_at?: string;
}
function shouldRedactField(key: string): boolean {
return SENSITIVE_FIELD_RE.test(key);
}
function shouldRedactConfigValue(obj: Record<string, any>): boolean {
return typeof obj.key === 'string' && SENSITIVE_CONFIG_KEY_RE.test(obj.key);
}
function redactJsonString(value: string): string {
const trimmed = value.trim();
if (!trimmed || !(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
try {
return JSON.stringify(redactSensitive(JSON.parse(value)));
} catch {
return value;
}
}
export function isRedactedPlaceholder(value: unknown): boolean {
return value === REDACTED_PLACEHOLDER;
}
export function redactSensitive(value: any): any {
if (Array.isArray(value)) return value.map(item => redactSensitive(item));
if (!value || typeof value !== 'object') return value;
const input = value as Record<string, any>;
const out: any = {};
const redactConfigValue = shouldRedactConfigValue(input);
for (const [key, val] of Object.entries(input)) {
if (key === 'value' && redactConfigValue) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else if (key === 'value' && typeof val === 'string') {
out[key] = redactJsonString(val);
} else if (shouldRedactField(key)) {
out[key] = val ? REDACTED_PLACEHOLDER : val;
} else {
out[key] = redactSensitive(val);
}
}
return out;
}
export function mergeRedactedUpdate<T = any>(incoming: T, existing: any): T {
if (isRedactedPlaceholder(incoming)) return existing;
if (Array.isArray(incoming)) {
if (!Array.isArray(existing)) return incoming;
return incoming.map((item, index) => mergeRedactedUpdate(item, existing[index])) as T;
}
if (!incoming || typeof incoming !== 'object') return incoming;
const out: any = { ...(incoming as any) };
for (const [key, val] of Object.entries(incoming as any)) {
out[key] = mergeRedactedUpdate(val, existing?.[key]);
}
return out;
}
export function publicSystemConfigs(configs: SystemConfigLike[]): SystemConfigLike[] {
return configs
.filter(cfg => PUBLIC_SYSTEM_CONFIG_KEYS.has(cfg.key))
.map(cfg => ({ key: cfg.key, value: cfg.value, ...(cfg.description !== undefined ? { description: cfg.description } : {}) }))
.sort((a, b) => a.key.localeCompare(b.key));
}