deploy: sync server CloudSearch 0.5.6
This commit is contained in:
@@ -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
|
||||
@@ -2,10 +2,13 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
COPY cloudsearch_transfer/requirements.txt ./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 TRANSFER_CONFIG_PATH=/data/transfer_config.json
|
||||
@@ -13,6 +16,6 @@ ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
|
||||
EXPOSE 9528
|
||||
|
||||
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"]
|
||||
|
||||
@@ -15,8 +15,8 @@ import logging
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
|
||||
from ..config import PlatformConfig, TransferConfig
|
||||
from ..errors import TransferError, TransferErrorCode
|
||||
from ...config import PlatformConfig, TransferConfig
|
||||
from ...errors import TransferError, TransferErrorCode
|
||||
|
||||
from .credential import AliyunCredentialManager
|
||||
from .transfer import AliyunTransfer
|
||||
@@ -47,23 +47,29 @@ class AliyunAdapter(BaseCloudDriveAdapter):
|
||||
"Referer": "https://aliyundrive.com",
|
||||
}
|
||||
|
||||
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
|
||||
super().__init__(config, transfer_config)
|
||||
capabilities: Dict[str, bool] = {
|
||||
**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 ""
|
||||
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
|
||||
|
||||
# 初始化 drive_id
|
||||
self._drive_id = ""
|
||||
|
||||
# 创建子模块
|
||||
self._transfer: Optional[AliyunTransfer] = None
|
||||
self._cleanup: Optional[AliyunCleanup] = None
|
||||
super().__init__(config, transfer_config)
|
||||
|
||||
def _setup_session(self):
|
||||
"""初始化 session 和凭证"""
|
||||
if self._credential.refresh_token:
|
||||
refresh_token = getattr(self._credential, "refresh_token", "")
|
||||
if refresh_token:
|
||||
# 验证 refresh_token 并获取 drive_id
|
||||
if self._credential.validate():
|
||||
self._drive_id = self._credential.get_drive_id()
|
||||
@@ -164,8 +170,10 @@ class AliyunAdapter(BaseCloudDriveAdapter):
|
||||
platform=self.PLATFORM_KEY,
|
||||
)
|
||||
|
||||
# 确定目标目录
|
||||
# 确定目标目录:路径先解析/创建为 file_id。
|
||||
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()
|
||||
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:
|
||||
"""
|
||||
清理文件(移入回收站),返回详细结果。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
from ..base import BaseCloudDriveAdapter, FileInfo
|
||||
from ...config import PlatformConfig, TransferConfig
|
||||
@@ -36,6 +36,15 @@ class BaiduAdapter(BaseCloudDriveAdapter):
|
||||
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):
|
||||
super().__init__(config, transfer_config)
|
||||
|
||||
@@ -123,11 +132,13 @@ class BaiduAdapter(BaseCloudDriveAdapter):
|
||||
fs_ids = detail["fs_ids"]
|
||||
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
|
||||
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 使用
|
||||
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:
|
||||
"""便捷删除方法(直接调用 cleanup)"""
|
||||
return self._cleanup.delete_files(paths)
|
||||
|
||||
@@ -8,7 +8,7 @@ PLATFORM_KEY = 'xunlei'
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
|
||||
from ..base import (
|
||||
BaseCloudDriveAdapter,
|
||||
@@ -32,11 +32,24 @@ class XunleiAdapter(BaseCloudDriveAdapter):
|
||||
PLATFORM_KEY = "xunlei"
|
||||
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):
|
||||
super().__init__(config, transfer_config)
|
||||
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
|
||||
# state must exist before super().__init__.
|
||||
self._credential = XunleiCredentialManager(config)
|
||||
self._transfer_engine: Optional[XunleiTransfer] = None
|
||||
self._cleanup = XunleiCleanup()
|
||||
self._cleanup = XunleiCleanup(self._credential)
|
||||
super().__init__(config, transfer_config)
|
||||
|
||||
def _setup_session(self):
|
||||
"""初始化 session 认证头"""
|
||||
@@ -54,27 +67,35 @@ class XunleiAdapter(BaseCloudDriveAdapter):
|
||||
"""懒加载转存引擎"""
|
||||
if self._transfer_engine is None:
|
||||
self._transfer_engine = XunleiTransfer(
|
||||
self.session,
|
||||
self._credential,
|
||||
self.config,
|
||||
self.transfer_config,
|
||||
credential=self._credential,
|
||||
timeout=self.transfer_config.request_timeout,
|
||||
poll_interval=self.transfer_config.task_poll_interval,
|
||||
poll_max_attempts=self.transfer_config.task_poll_max_attempts,
|
||||
)
|
||||
self._transfer_engine.session = self.session
|
||||
return self._transfer_engine
|
||||
|
||||
# ─── 抽象方法实现 ──────────────────────────────
|
||||
|
||||
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
|
||||
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]:
|
||||
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,
|
||||
password: str = "") -> Tuple[str, str]:
|
||||
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]:
|
||||
files = detail.get("files", [])
|
||||
@@ -94,13 +115,80 @@ class XunleiAdapter(BaseCloudDriveAdapter):
|
||||
|
||||
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
|
||||
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:
|
||||
self._ensure_auth()
|
||||
return self._cleanup.delete_files(
|
||||
self.session, self._credential, file_ids
|
||||
)
|
||||
return self._cleanup.delete_files(file_ids)
|
||||
|
||||
def _get_banned_keywords(self) -> List[str]:
|
||||
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>
|
||||
@@ -88,6 +88,8 @@ class XunleiTransfer:
|
||||
"""
|
||||
url = f"{XUNLEI_PAN_API}/drive/v1/share"
|
||||
params: Dict[str, str] = {"share_id": share_id}
|
||||
if passcode:
|
||||
params["pass_code"] = passcode
|
||||
headers = self.credential.get_headers()
|
||||
|
||||
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
|
||||
@@ -303,6 +305,7 @@ class XunleiTransfer:
|
||||
def _create_share(
|
||||
self,
|
||||
file_ids: List[str],
|
||||
password: str = "",
|
||||
expiration_days: str = "-1",
|
||||
) -> Tuple[str, str]:
|
||||
"""步骤④:创建新分享链接。
|
||||
@@ -331,6 +334,8 @@ class XunleiTransfer:
|
||||
"file_ids": file_ids,
|
||||
"expiration_days": expiration_days,
|
||||
}
|
||||
if password:
|
||||
body["pass_code"] = password
|
||||
# share 操作可能需要 captcha_token
|
||||
headers = self.credential.get_headers_with_captcha(action="share")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
@@ -367,7 +372,7 @@ class XunleiTransfer:
|
||||
share_url,
|
||||
pass_code,
|
||||
)
|
||||
return share_url, pass_code
|
||||
return share_url, pass_code or password
|
||||
|
||||
# ─── 公开入口 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -150,8 +150,8 @@ class ConfigManager:
|
||||
"platforms": {
|
||||
name: {
|
||||
"enabled": cfg.enabled,
|
||||
"cookie": cfg.cookie[:20] + "..." if cfg.cookie else "",
|
||||
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "",
|
||||
"cookie": cfg.cookie,
|
||||
"refresh_token": cfg.refresh_token,
|
||||
"account_name": cfg.account_name,
|
||||
"save_dir": cfg.save_dir,
|
||||
"share_password": cfg.share_password,
|
||||
|
||||
@@ -7,8 +7,8 @@ import os
|
||||
import uuid
|
||||
import logging
|
||||
from flask import Flask, request, jsonify
|
||||
from config import ConfigManager
|
||||
from orchestration.transfer import TransferOrchestrator
|
||||
from cloudsearch_transfer.config import ConfigManager
|
||||
from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator
|
||||
|
||||
# ─── 初始化 ────────────────────────────────────────────
|
||||
|
||||
@@ -149,6 +149,18 @@ def 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"])
|
||||
@@ -169,14 +181,17 @@ def get_platforms():
|
||||
@app.route("/api/config/platforms/<name>", methods=["PUT"])
|
||||
def update_platform(name):
|
||||
"""更新平台配置"""
|
||||
auth_error = require_config_auth()
|
||||
if auth_error:
|
||||
return auth_error
|
||||
data = request.get_json() or {}
|
||||
if name not in config.platforms:
|
||||
from config import PlatformConfig
|
||||
from cloudsearch_transfer.config import PlatformConfig
|
||||
config.platforms[name] = PlatformConfig()
|
||||
|
||||
cfg = config.platforms[name]
|
||||
if "enabled" in data:
|
||||
cfg.enabled = data["enabled"]
|
||||
cfg.enabled = bool(data["enabled"])
|
||||
if "cookie" in data:
|
||||
cfg.cookie = data["cookie"]
|
||||
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,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()
|
||||
@@ -1 +1 @@
|
||||
0.5.5
|
||||
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 服务集成。
|
||||
@@ -3,16 +3,51 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="canonical" href="https://zy.hk.timxx.cn/" />
|
||||
<title>CloudSearch - 网盘资源搜索</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script>
|
||||
(function() {
|
||||
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
|
||||
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
|
||||
}).catch(function(){});
|
||||
function setMeta(selector, attr, value) {
|
||||
var el = document.querySelector(selector);
|
||||
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>
|
||||
</head>
|
||||
@@ -20,4 +55,4 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+387
@@ -23,6 +23,7 @@
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
},
|
||||
@@ -869,6 +870,121 @@
|
||||
"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": {
|
||||
"version": "2.4.15",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1121,6 +1246,15 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1141,6 +1275,31 @@
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
@@ -1194,6 +1353,23 @@
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
@@ -1202,6 +1378,15 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1294,6 +1479,12 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
@@ -1568,6 +1768,12 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1623,6 +1829,12 @@
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -1698,6 +1910,21 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1853,6 +2086,18 @@
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -1877,6 +2122,45 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +25,7 @@
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.9",
|
||||
"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>
|
||||
@@ -3,6 +3,49 @@
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div class="home-page">
|
||||
<div class="hero-section">
|
||||
<h1 class="sr-only">网盘资源搜索</h1>
|
||||
<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='' }" />
|
||||
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
|
||||
</template>
|
||||
<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>
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">搜索</el-button>
|
||||
@@ -27,6 +28,7 @@
|
||||
<div class="footer-inner">{{ siteDisclaimer }}</div>
|
||||
<el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +37,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getCategorizedRankings, getSiteConfig } from '../api'
|
||||
import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers'
|
||||
|
||||
const router = useRouter()
|
||||
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 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 searchTag(t:string) { router.push('/search?q='+encodeURIComponent(t)) }
|
||||
function openDisclaimer() { window.open('/disclaimer/', '_blank') }
|
||||
function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) }
|
||||
function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) }
|
||||
function openDisclaimer() { window.location.assign(createDisclaimerTarget()) }
|
||||
|
||||
onMounted(async () => {
|
||||
currentQuote.value = QS[Math.floor(Math.random()*QS.length)]
|
||||
@@ -81,6 +84,7 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.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}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
@@ -16,6 +16,12 @@ const routes = [
|
||||
name: 'result-detail',
|
||||
component: () => import('./pages/ResultDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/disclaimer',
|
||||
alias: '/disclaimer/',
|
||||
name: 'disclaimer',
|
||||
component: () => import('./pages/Disclaimer.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
@@ -71,10 +77,15 @@ const routes = [
|
||||
path: '/user',
|
||||
redirect: '/user/login',
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('./pages/NotFound.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
|
||||
@@ -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('管理后台')
|
||||
})
|
||||
})
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { createCloudDriver, supportsSaveFromShare } from './driver-factory';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
import { notifyConfigEvent } from './notification.service';
|
||||
@@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
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} 的保存功能` };
|
||||
const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname });
|
||||
if (!supportsSaveFromShare(driver)) {
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
}
|
||||
driverResult = cloudType === 'quark'
|
||||
? await driver.saveFromShare(shareUrl, sourceTitle, retrySave)
|
||||
: await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
|
||||
import { createCloudDriver, supportsValidate } from './driver-factory';
|
||||
import { getDb } from '../database/database';
|
||||
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
|
||||
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
@@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{
|
||||
let storageUsed = config.storage_used || '';
|
||||
let storageTotal = config.storage_total || '';
|
||||
|
||||
if (config.cloud_type === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
|
||||
const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any;
|
||||
if (supportsValidate(driver)) {
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') {
|
||||
const info = await driver.getUserInfo();
|
||||
if (info) {
|
||||
nickname = config.nickname || info.nickname || '百度网盘';
|
||||
@@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{
|
||||
storageUsed = fmt(info.usedBytes);
|
||||
storageTotal = fmt(info.totalBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
} else if (config.cloud_type === 'quark') {
|
||||
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
if (typeof driver.getStorageInfoQuick === 'function') {
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
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();
|
||||
if (!valid) {
|
||||
db.prepare(
|
||||
@@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
|
||||
message: 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 {
|
||||
return {
|
||||
success: true,
|
||||
@@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
|
||||
|
||||
try {
|
||||
let cookieValid = false;
|
||||
if (cloudType === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.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 });
|
||||
const driver = createCloudDriver(cloudType, { cookie: cookie, nickname: config.nickname });
|
||||
if (supportsValidate(driver)) {
|
||||
cookieValid = await driver.validate();
|
||||
} else if (cloudType === 'aliyun') {
|
||||
cookieValid = true;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)])),
|
||||
});
|
||||
}
|
||||
@@ -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 || '迅雷转存失败' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { reconnectRedis, testRedisConnection } from '../middleware/cache';
|
||||
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||
import { testProxyConnection } from '../utils/proxy-agent';
|
||||
import { mergeRedactedUpdate, redactSensitive } from '../utils/redact';
|
||||
import { mergeNotifySettingsUpdate, mergePushUserNotifyConfigUpdate, mergeSystemConfigEntriesUpdate } from './admin-config-helpers';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -147,7 +149,7 @@ router.use('/admin', authMiddleware);
|
||||
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const configs = getCloudConfigs();
|
||||
res.json(configs);
|
||||
res.json(redactSensitive(configs));
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
const saved = saveCloudConfig({ ...req.body, id });
|
||||
res.json(saved);
|
||||
const update = mergeRedactedUpdate(req.body, existing);
|
||||
const saved = saveCloudConfig({ ...update, id });
|
||||
res.json(redactSensitive(saved));
|
||||
} catch (err: any) {
|
||||
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) => {
|
||||
try {
|
||||
const configs = getAllSystemConfigs();
|
||||
res.json(configs);
|
||||
res.json(redactSensitive(configs));
|
||||
} catch (err: any) {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
updateSystemConfigs(entries);
|
||||
const mergedEntries = mergeSystemConfigEntriesUpdate(entries, getAllSystemConfigs());
|
||||
updateSystemConfigs(mergedEntries);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
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,
|
||||
...counts,
|
||||
redis_status,
|
||||
redis_url,
|
||||
redis_url: redis_url ? '***REDACTED***' : redis_url,
|
||||
});
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const settings = getConfigNotifySettingsJSON(id);
|
||||
res.json(settings);
|
||||
res.json(redactSensitive(settings));
|
||||
} catch (err: any) {
|
||||
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) => {
|
||||
try {
|
||||
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);
|
||||
res.json({ success: true, message: 'Push config saved' });
|
||||
} catch (err: any) {
|
||||
@@ -764,7 +769,7 @@ router.get('/admin/notify/providers', (_req: Request, res: Response) => {
|
||||
router.get('/admin/notify/global-config', (_req, res) => {
|
||||
try {
|
||||
const cfg = getGlobalNotifyConfig();
|
||||
res.json(cfg);
|
||||
res.json(redactSensitive(cfg));
|
||||
} catch (err: any) {
|
||||
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' });
|
||||
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 });
|
||||
} catch (err: any) {
|
||||
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,
|
||||
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
|
||||
}));
|
||||
res.json(parsed);
|
||||
res.json(redactSensitive(parsed));
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
const { account, notify_config } = req.body;
|
||||
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);
|
||||
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
|
||||
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
|
||||
} catch (err: any) {
|
||||
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 { account, notify_config } = req.body;
|
||||
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);
|
||||
res.json({ ...user, notify_config: JSON.parse(user!.notify_config) });
|
||||
res.json(redactSensitive({ ...user, notify_config: JSON.parse(user!.notify_config) }));
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
const { getDailyReportConfig } = require('../services/daily-report.service');
|
||||
const cfg = getDailyReportConfig();
|
||||
res.json(cfg);
|
||||
res.json(redactSensitive(cfg));
|
||||
} catch (err: any) {
|
||||
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');
|
||||
saveDailyReportConfig(req.body);
|
||||
const { getDailyReportConfig } = require('../services/daily-report.service');
|
||||
res.json(getDailyReportConfig());
|
||||
res.json(redactSensitive(getDailyReportConfig()));
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to save daily report config' });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import adminRoutes from "./admin.routes";
|
||||
import uploadRoutes from "./upload.routes";
|
||||
import cleanupRoutes from "./cleanup.routes";
|
||||
import { getAllSystemConfigs } from "../admin/system-config.service";
|
||||
import { publicSystemConfigs } from "../utils/redact";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -11,7 +12,7 @@ const router = Router();
|
||||
router.get("/system-configs", (_req, res) => {
|
||||
try {
|
||||
const configs = getAllSystemConfigs();
|
||||
res.json(configs);
|
||||
res.json(publicSystemConfigs(configs));
|
||||
} catch (err: any) {
|
||||
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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { getDb } from '../database/database';
|
||||
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;
|
||||
|
||||
// 2. 热榜 Top 20
|
||||
const hotRows = db.prepare(
|
||||
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||
const rawHotRows = db.prepare(
|
||||
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 500'
|
||||
).all() as any[];
|
||||
const hotRows = filterPublicRankingRows(rawHotRows, 20);
|
||||
|
||||
const maxHotCount = hotRows.length > 0 ? hotRows[0].count : 1;
|
||||
|
||||
@@ -151,9 +153,10 @@ async function generateSearchPulse(): Promise<SearchPulse> {
|
||||
});
|
||||
|
||||
// 3. 最新搜索 Top 15
|
||||
const recentRows = db.prepare(
|
||||
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 15'
|
||||
const rawRecentRows = db.prepare(
|
||||
'SELECT keyword, search_count as count, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 500'
|
||||
).all() as any[];
|
||||
const recentRows = filterPublicRankingRows(rawRecentRows, 15);
|
||||
|
||||
const recentList: HotKeyword[] = recentRows.map((row: any) => {
|
||||
const genre = detectGenre(row.keyword);
|
||||
@@ -169,9 +172,9 @@ async function generateSearchPulse(): Promise<SearchPulse> {
|
||||
});
|
||||
|
||||
// 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'
|
||||
).all() as any[];
|
||||
).all() as any[]).filter(row => isPublicRankingKeyword(row.keyword));
|
||||
|
||||
const categoryMap = new Map<string, HotKeyword[]>();
|
||||
for (const row of allKeywords) {
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user