Files
archived-MoviePilot-Plugins/plugins.v2/quarksharesaver/__init__.py
2026-05-10 10:39:59 +08:00

1114 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hmac
import json
import random
import re
import time
from datetime import datetime
from hashlib import md5
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlparse, urlencode
from urllib.request import Request as UrlRequest, urlopen
from fastapi import Request
from app.log import logger
from app.plugins import _PluginBase
try:
from app.core.config import settings
except Exception:
settings = None
try:
from app.schemas import NotificationType
except Exception:
NotificationType = None
try:
from app.utils.crypto import CryptoJsUtils
except Exception:
CryptoJsUtils = None
class QuarkShareSaver(_PluginBase):
plugin_name = "夸克分享转存"
plugin_desc = "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/quark.ico"
plugin_version = "0.1.0"
plugin_author = "liuyuexi1987"
plugin_level = 1
author_url = "https://github.com/liuyuexi1987"
plugin_config_prefix = "quarksharesaver_"
plugin_order = 32
auth_level = 1
_enabled = False
_notify = True
_cookie = ""
_default_target_path = "/飞书"
_timeout = 30
_auto_import_cookiecloud = True
_import_cookiecloud_once = False
_share_url = ""
_access_code = ""
_target_path = ""
_transfer_once = False
_last_transfer_key = "last_transfer"
_last_error_key = "last_error"
_path_cache: Dict[str, str] = {"/": "0"}
@staticmethod
def _clean_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
@staticmethod
def _safe_int(value: Any, default: int) -> int:
try:
return int(value)
except Exception:
return default
@staticmethod
def _normalize_path(value: Any) -> str:
text = str(value or "").strip()
if not text:
return "/"
if not text.startswith("/"):
text = f"/{text}"
text = re.sub(r"/+", "/", text)
return text.rstrip("/") or "/"
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = {
"enabled": self._enabled,
"notify": self._notify,
"cookie": self._cookie,
"default_target_path": self._default_target_path,
"timeout": self._timeout,
"auto_import_cookiecloud": self._auto_import_cookiecloud,
"import_cookiecloud_once": self._import_cookiecloud_once,
"share_url": self._share_url,
"access_code": self._access_code,
"target_path": self._target_path,
"transfer_once": self._transfer_once,
}
if overrides:
config.update(overrides)
return config
def _tz_now(self) -> datetime:
if settings is not None:
try:
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
except Exception:
pass
return datetime.now()
def _save_state(self, key: str, value: Any) -> None:
try:
self.save_data(key=key, value=value)
except Exception as exc:
logger.warning(f"[QuarkShareSaver] 保存状态失败 {key}: {exc}")
def _load_state(self, key: str, default: Any = None) -> Any:
try:
value = self.get_data(key)
return default if value is None else value
except Exception as exc:
logger.warning(f"[QuarkShareSaver] 读取状态失败 {key}: {exc}")
return default
def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None:
self._save_state(
self._last_error_key,
{
"action": action,
"message": message,
"payload": payload or {},
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
},
)
def _notify_message(self, title: str, text: str) -> None:
if not self._notify or not hasattr(self, "post_message"):
return
try:
if NotificationType is not None:
self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text)
else:
self.post_message(title=title, text=text)
except Exception as exc:
logger.warning(f"[QuarkShareSaver] 发送通知失败: {exc}")
def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]:
if settings is None:
return "", "未获取到系统设置"
if CryptoJsUtils is None:
return "", "运行环境缺少 CookieCloud 解密依赖"
key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", ""))
password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", ""))
cookie_path = getattr(settings, "COOKIE_PATH", None)
if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)):
return "", "未启用本地 CookieCloud"
if not key or not password or not cookie_path:
return "", "CookieCloud 参数不完整"
file_path = Path(cookie_path) / f"{key}.json"
if not file_path.exists():
return "", f"未找到 CookieCloud 文件: {file_path.name}"
try:
encrypted_data = json.loads(file_path.read_text(encoding="utf-8"))
encrypted = encrypted_data.get("encrypted")
if not encrypted:
return "", "CookieCloud 文件缺少 encrypted 字段"
crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8")
decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8")
payload = json.loads(decrypted)
except Exception as exc:
return "", f"CookieCloud 解密失败: {exc}"
contents = payload.get("cookie_data") if isinstance(payload, dict) else None
if not isinstance(contents, dict):
contents = payload if isinstance(payload, dict) else {}
merged: Dict[str, str] = {}
for cookie_items in contents.values():
if not isinstance(cookie_items, list):
continue
for item in cookie_items:
if not isinstance(item, dict):
continue
domain = self._clean_text(item.get("domain")).lower()
name = self._clean_text(item.get("name"))
value = self._clean_text(item.get("value"))
if "quark.cn" not in domain or not name:
continue
merged[name] = value
if not merged:
return "", "CookieCloud 中没有 quark.cn 的 Cookie"
return "; ".join(f"{name}={value}" for name, value in merged.items() if value), ""
def _try_import_cookiecloud_cookie(self, *, force: bool = False) -> Tuple[bool, str]:
if self._cookie and not force:
return True, "已存在 Cookie跳过自动导入"
cookie, message = self._load_cookiecloud_quark_cookie()
if not cookie:
logger.info(f"[QuarkShareSaver] CookieCloud 导入未命中: {message}")
return False, message
self._cookie = cookie
logger.info(f"[QuarkShareSaver] 已从 CookieCloud 导入夸克 Cookie长度: {len(cookie)}")
return True, "已从 CookieCloud 导入夸克 Cookie"
@staticmethod
def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str:
header = str(request.headers.get("Authorization") or "").strip()
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
if body:
token = str(body.get("apikey") or body.get("api_key") or "").strip()
if token:
return token
return str(request.query_params.get("apikey") or "").strip()
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "")
if not expected:
return False, "服务端未配置 API Token"
actual = self._extract_apikey(request, body)
if not hmac.compare_digest(actual, expected):
return False, "API Token 无效"
return True, ""
@staticmethod
def _extract_url(raw_text: str) -> str:
match = re.search(r"https?://[^\s<>\"']+", raw_text)
if match:
return match.group(0).rstrip(".,);]")
return ""
def _extract_share_info(self, share_text: str, access_code: str = "") -> Tuple[str, str, str]:
raw = self._clean_text(share_text)
share_url = self._extract_url(raw) or raw
parsed = urlparse(share_url)
pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path)
pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else ""
code = self._clean_text(access_code)
if not code:
query = dict(parse_qsl(parsed.query))
code = self._clean_text(query.get("pwd") or query.get("passcode") or query.get("code"))
if not code and raw:
for token in raw.replace(share_url, " ").split():
text = token.strip()
if not text:
continue
if "=" in text:
key, value = text.split("=", 1)
if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}:
code = self._clean_text(value)
break
elif len(text) <= 8 and not text.startswith("/"):
code = text
break
return share_url, pwd_id, code
@staticmethod
def _is_quark_share_url(share_url: str) -> bool:
hostname = urlparse(share_url).hostname or ""
hostname = hostname.lower().strip(".")
return hostname.endswith("quark.cn")
def _validate_share_url(self, share_url: str) -> Tuple[bool, str]:
if not share_url:
return False, "未识别到有效夸克分享链接"
if self._is_quark_share_url(share_url):
return True, ""
hostname = urlparse(share_url).hostname or "未知域名"
return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接"
def _build_headers(self) -> Dict[str, str]:
return {
"Cookie": self._cookie,
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/137.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Origin": "https://pan.quark.cn",
"Referer": "https://pan.quark.cn/",
"Content-Type": "application/json;charset=UTF-8",
}
def _request(
self,
method: str,
url: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
allow_cookiecloud_retry: bool = True,
) -> Tuple[bool, Dict[str, Any], str]:
final_url = url
if params:
query = urlencode([(key, "" if value is None else value) for key, value in params.items()])
final_url = f"{url}?{query}" if query else url
payload = None
if json_body is not None:
payload = json.dumps(json_body).encode("utf-8")
try:
request = UrlRequest(
url=final_url,
data=payload,
headers=self._build_headers(),
method=method.upper(),
)
with urlopen(request, timeout=self._timeout) as response:
status_code = getattr(response, "status", 200)
raw_body = response.read()
except HTTPError as exc:
status_code = exc.code
raw_body = exc.read() if hasattr(exc, "read") else b""
except URLError as exc:
return False, {}, f"请求失败: {exc.reason}"
except Exception as exc:
return False, {}, f"请求失败: {exc}"
try:
data = json.loads(raw_body.decode("utf-8"))
except Exception:
text = raw_body.decode("utf-8", errors="ignore")[:300]
return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}"
if status_code == 401 and allow_cookiecloud_retry and self._auto_import_cookiecloud:
imported, _ = self._try_import_cookiecloud_cookie(force=True)
if imported:
return self._request(
method,
url,
params=params,
json_body=json_body,
allow_cookiecloud_retry=False,
)
if status_code != 200:
return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}"
if isinstance(data, dict):
message = str(data.get("message") or data.get("msg") or "").strip()
ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok"
if ok:
return True, data, ""
return False, data, message or "接口返回失败"
return False, {}, "接口返回格式错误"
@staticmethod
def _common_params() -> Dict[str, Any]:
now = int(time.time() * 1000)
return {
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"__dt": random.randint(100, 9999),
"__t": now,
}
def _get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]:
ok, data, message = self._request(
"POST",
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token",
params=self._common_params(),
json_body={"pwd_id": pwd_id, "passcode": access_code or ""},
)
if not ok:
return False, "", message
stoken = self._clean_text((data.get("data") or {}).get("stoken"))
if not stoken:
return False, "", "未获取到 stoken可能是提取码错误或 Cookie 失效"
return True, stoken, ""
def _get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]:
items: List[Dict[str, Any]] = []
page = 1
while True:
params = self._common_params()
params.update(
{
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"force": "0",
"_page": str(page),
"_size": "50",
"_sort": "file_type:asc,updated_at:desc",
}
)
ok, data, message = self._request(
"GET",
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail",
params=params,
)
if not ok:
return False, [], message
payload = data.get("data") or {}
meta = data.get("metadata") or {}
current = payload.get("list") or []
for item in current:
items.append(
{
"fid": str(item.get("fid") or ""),
"file_name": str(item.get("file_name") or ""),
"dir": bool(item.get("dir")),
"file_type": item.get("file_type"),
"pdir_fid": str(item.get("pdir_fid") or ""),
"share_fid_token": str(item.get("share_fid_token") or ""),
}
)
total = self._safe_int(meta.get("_total"), 0)
count = self._safe_int(meta.get("_count"), len(current))
size = max(1, self._safe_int(meta.get("_size"), 50))
if total <= len(items) or count < size:
break
page += 1
if not items:
return False, [], "分享链接为空,或当前账号无权查看内容"
return True, items, ""
def _list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]:
page = 1
result: List[Dict[str, Any]] = []
while True:
params = {
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"pdir_fid": parent_fid,
"_page": page,
"_size": 100,
"_fetch_total": 1,
"_fetch_sub_dirs": 0,
"_sort": "file_type:asc,updated_at:desc",
}
ok, data, message = self._request(
"GET",
"https://drive-pc.quark.cn/1/clouddrive/file/sort",
params=params,
)
if not ok:
return False, [], message
current = ((data.get("data") or {}).get("list")) or []
for item in current:
result.append(
{
"fid": str(item.get("fid") or ""),
"name": str(item.get("file_name") or ""),
"dir": int(item.get("file_type") or 0) == 0,
"size": item.get("size") or 0,
"updated_at": item.get("updated_at") or 0,
}
)
if len(current) < 100:
break
page += 1
return True, result, ""
def _find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
ok, items, message = self._list_children(parent_fid)
if not ok:
return False, "", message
for item in items:
if item.get("dir") and item.get("name") == name:
return True, str(item.get("fid") or ""), ""
return True, "", ""
def _create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
ok, data, message = self._request(
"POST",
"https://pan.quark.cn/1/clouddrive/file/create",
json_body={
"pdir_fid": parent_fid,
"file_name": name,
"dir_path": "",
"dir_init_lock": False,
},
)
if not ok:
return False, "", message
folder = data.get("data") or {}
folder_id = self._clean_text(folder.get("fid") or folder.get("file_id"))
if not folder_id:
return False, "", "创建目录成功但未返回 fid"
return True, folder_id, ""
def _ensure_target_dir(self, path: str) -> Tuple[bool, str, str]:
normalized = self._normalize_path(path or self._default_target_path)
if normalized == "/":
return True, "0", normalized
cached = self._path_cache.get(normalized)
if cached:
return True, cached, normalized
current_fid = "0"
built = ""
for part in [segment for segment in normalized.split("/") if segment]:
built = f"{built}/{part}" if built else f"/{part}"
cached = self._path_cache.get(built)
if cached:
current_fid = cached
continue
ok, found_fid, message = self._find_child_dir(current_fid, part)
if not ok:
return False, "", message
if not found_fid:
ok, found_fid, message = self._create_folder(current_fid, part)
if not ok:
return False, "", f"创建目录失败 {built}: {message}"
self._path_cache[built] = found_fid
current_fid = found_fid
return True, current_fid, normalized
def _resolve_existing_dir(self, path: str) -> Tuple[bool, str, str]:
normalized = self._normalize_path(path)
if normalized == "/":
return True, "0", normalized
cached = self._path_cache.get(normalized)
if cached:
return True, cached, normalized
current_fid = "0"
built = ""
for part in [segment for segment in normalized.split("/") if segment]:
built = f"{built}/{part}" if built else f"/{part}"
cached = self._path_cache.get(built)
if cached:
current_fid = cached
continue
ok, found_fid, message = self._find_child_dir(current_fid, part)
if not ok:
return False, "", message
if not found_fid:
return False, "", f"目录不存在: {built}"
self._path_cache[built] = found_fid
current_fid = found_fid
return True, current_fid, normalized
def _create_save_task(
self,
pwd_id: str,
stoken: str,
items: List[Dict[str, Any]],
to_pdir_fid: str,
) -> Tuple[bool, str, str]:
fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")]
fid_token_list = [
str(item.get("share_fid_token") or "")
for item in items
if item.get("fid") and item.get("share_fid_token")
]
if not fid_list or len(fid_list) != len(fid_token_list):
return False, "", "分享内容缺少 fid 或 share_fid_token无法转存"
params = self._common_params()
ok, data, message = self._request(
"POST",
"https://drive.quark.cn/1/clouddrive/share/sharepage/save",
params=params,
json_body={
"fid_list": fid_list,
"fid_token_list": fid_token_list,
"to_pdir_fid": to_pdir_fid,
"pwd_id": pwd_id,
"stoken": stoken,
"pdir_fid": "0",
"scene": "link",
},
)
if not ok:
return False, "", message
task_id = self._clean_text((data.get("data") or {}).get("task_id"))
if not task_id:
return False, "", "未获取到转存任务 ID"
return True, task_id, ""
def _wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]:
for index in range(retry):
time.sleep(1.0 if index == 0 else 1.5)
params = {
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"task_id": task_id,
"retry_index": index,
"__dt": 21192,
"__t": int(time.time() * 1000),
}
ok, data, message = self._request(
"GET",
"https://drive-pc.quark.cn/1/clouddrive/task",
params=params,
)
if not ok:
return False, {}, message
task = data.get("data") or {}
status = self._safe_int(task.get("status"), -1)
if status == 2:
return True, task, ""
if status in {3, 4, 5, 6, 7}:
return False, task, self._clean_text(task.get("message")) or "夸克任务执行失败"
return False, {}, "等待夸克转存任务超时"
def _check_cookie(self) -> Tuple[bool, str]:
ok, _, message = self._list_children("0")
if ok:
return True, ""
return False, message or "Cookie 校验失败"
def transfer_share(
self,
share_text: str,
access_code: str = "",
target_path: str = "",
*,
remember: bool = True,
trigger: str = "插件 API",
) -> Tuple[bool, Dict[str, Any], str]:
share_url, pwd_id, final_code = self._extract_share_info(share_text, access_code)
ok, message = self._validate_share_url(share_url)
if not ok:
return False, {}, message
if not pwd_id:
return False, {}, "未识别到有效夸克分享链接"
if not self._enabled:
return False, {}, "插件未启用"
if not self._cookie:
return False, {}, "未配置夸克 Cookie"
ok, stoken, message = self._get_stoken(pwd_id, final_code)
if not ok:
self._remember_error("get_stoken", message, {"pwd_id": pwd_id})
return False, {}, message
ok, share_items, message = self._get_share_items(pwd_id, stoken)
if not ok:
self._remember_error("get_share_items", message, {"pwd_id": pwd_id})
return False, {}, message
ok, target_fid, normalized_path = self._ensure_target_dir(target_path or self._default_target_path)
if not ok:
self._remember_error("ensure_target_dir", target_fid, {"path": target_path or self._default_target_path})
return False, {}, target_fid
ok, task_id, message = self._create_save_task(pwd_id, stoken, share_items, target_fid)
if not ok:
self._remember_error("create_save_task", message, {"pwd_id": pwd_id, "path": normalized_path})
return False, {}, message
ok, task, message = self._wait_task(task_id)
if not ok:
self._remember_error("wait_task", message, {"task_id": task_id})
return False, {"task_id": task_id}, message
item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")]
result = {
"share_url": share_url,
"pwd_id": pwd_id,
"access_code": final_code,
"target_path": normalized_path,
"target_fid": target_fid,
"task_id": task_id,
"saved_count": len(share_items),
"items": item_names[:20],
"task": task,
"trigger": trigger,
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
}
if remember:
self._save_state(self._last_transfer_key, result)
self._notify_message(
"夸克分享转存完成",
(
f"保存目录:{normalized_path}\n"
f"任务ID{task_id}\n"
f"顶层条目:{len(share_items)}"
),
)
return True, result, "success"
def init_plugin(self, config: dict = None):
config = config or {}
self._enabled = bool(config.get("enabled"))
self._notify = bool(config.get("notify", True))
self._cookie = self._clean_text(config.get("cookie"))
self._default_target_path = self._normalize_path(config.get("default_target_path") or "/飞书")
self._timeout = max(10, self._safe_int(config.get("timeout"), 30))
self._auto_import_cookiecloud = bool(config.get("auto_import_cookiecloud", True))
self._import_cookiecloud_once = bool(config.get("import_cookiecloud_once"))
self._share_url = self._clean_text(config.get("share_url"))
self._access_code = self._clean_text(config.get("access_code"))
self._target_path = self._normalize_path(config.get("target_path") or self._default_target_path)
self._transfer_once = bool(config.get("transfer_once"))
self._path_cache = {"/": "0"}
if self._import_cookiecloud_once or (self._auto_import_cookiecloud and not self._cookie):
imported_cookie, message = self._try_import_cookiecloud_cookie(force=self._import_cookiecloud_once)
if self._import_cookiecloud_once:
self._import_cookiecloud_once = False
self.update_config(self._build_config({"cookie": self._cookie, "import_cookiecloud_once": False}))
elif imported_cookie:
self.update_config(self._build_config({"cookie": self._cookie}))
if imported_cookie and self._notify:
self._notify_message("夸克 Cookie 已导入", message)
if self._transfer_once:
self._transfer_once = False
self.update_config(self._build_config({"transfer_once": False}))
if self._enabled and self._share_url:
ok, _, message = self.transfer_share(
self._share_url,
access_code=self._access_code,
target_path=self._target_path,
remember=True,
trigger="插件页面立即转存",
)
if not ok:
self._notify_message("夸克分享转存失败", message)
def get_state(self) -> bool:
return self._enabled and bool(self._cookie)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
return []
def get_api(self) -> List[Dict[str, Any]]:
return [
{"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查 Cookie 与默认目录状态"},
{"path": "/folders", "endpoint": self.api_folders, "methods": ["GET"], "summary": "列出夸克网盘目录"},
{"path": "/share/info", "endpoint": self.api_share_info, "methods": ["POST"], "summary": "解析夸克分享链接顶层条目"},
{"path": "/transfer", "endpoint": self.api_transfer, "methods": ["POST"], "summary": "把夸克分享链接转存到指定目录"},
]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
"component": "VForm",
"content": [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{"component": "VSwitch", "props": {"model": "notify", "label": "发送站内通知"}}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{"component": "VTextField", "props": {"model": "timeout", "label": "请求超时(秒)", "type": "number"}}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {"model": "auto_import_cookiecloud", "label": "Cookie 为空时自动从 CookieCloud 导入"}
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {"model": "import_cookiecloud_once", "label": "立即从 CookieCloud 重新导入一次"}
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextarea",
"props": {
"model": "cookie",
"label": "夸克 Cookie",
"rows": 4,
"placeholder": "浏览器登录 pan.quark.cn 后复制完整 Cookie",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextField",
"props": {
"model": "default_target_path",
"label": "默认保存目录",
"placeholder": "/来自分享/夸克",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": (
"推荐给智能体或飞书调用的接口:\n"
"POST /api/v1/plugin/QuarkShareSaver/transfer\n"
"参数url, access_code, path。\n"
"飞书建议命令:夸克转存 分享链接 pwd=提取码 path=/最新动画\n"
"如果你启用了本地 CookieCloud插件可以自动导入 quark.cn Cookie。"
),
},
}
],
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{"component": "VSwitch", "props": {"model": "transfer_once", "label": "立即转存一次"}}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 8},
"content": [
{
"component": "VTextField",
"props": {
"model": "target_path",
"label": "本次保存目录",
"placeholder": "/来自分享/夸克",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextField",
"props": {
"model": "share_url",
"label": "夸克分享链接",
"placeholder": "https://pan.quark.cn/s/xxxx",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextField",
"props": {
"model": "access_code",
"label": "提取码(可留空)",
"placeholder": "abcd",
},
}
],
}
],
},
],
}
], self._build_config()
def get_page(self) -> List[dict]:
last_transfer = self._load_state(self._last_transfer_key, default={}) or {}
last_error = self._load_state(self._last_error_key, default={}) or {}
transfer_lines = [
f"最近一次:{last_transfer.get('time') or '暂无'}",
f"保存目录:{last_transfer.get('target_path') or '-'}",
f"任务ID{last_transfer.get('task_id') or '-'}",
f"顶层条目:{last_transfer.get('saved_count') or 0}",
]
if last_transfer.get("items"):
transfer_lines.append("示例条目:" + ", ".join(last_transfer.get("items")[:5]))
error_lines = [
f"最近错误动作:{last_error.get('action') or '暂无'}",
f"错误时间:{last_error.get('time') or '-'}",
f"错误信息:{last_error.get('message') or '-'}",
]
return [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VCard",
"props": {"variant": "tonal"},
"content": [
{
"component": "VCardText",
"text": (
"夸克分享转存插件负责做一件事:把夸克分享链接稳定转存到自己的夸克网盘。"
"推荐让智能体和飞书只调用这一个稳定入口,不要自己拼夸克接口。"
),
}
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"content": [
{"component": "VCardTitle", "text": "最近转存"},
{"component": "VCardText", "text": "\n".join(transfer_lines)},
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"content": [
{"component": "VCardTitle", "text": "最近错误"},
{"component": "VCardText", "text": "\n".join(error_lines)},
],
}
],
},
],
}
]
def get_service(self) -> List[Dict[str, Any]]:
return []
def stop_service(self):
pass
async def api_health(self, request: Request) -> Dict[str, Any]:
allowed, message = self._check_api_access(request)
if not allowed:
return {"success": False, "message": message, "data": {}}
ok = False
message = ""
if self._enabled and self._cookie:
ok, message = self._check_cookie()
return {
"success": ok if self._enabled and self._cookie else False,
"message": "success" if ok else (message or "插件未启用或未配置 Cookie"),
"data": {
"plugin_enabled": self._enabled,
"cookie_configured": bool(self._cookie),
"default_target_path": self._default_target_path,
"timeout": self._timeout,
},
}
async def api_folders(self, request: Request) -> Dict[str, Any]:
allowed, message = self._check_api_access(request)
if not allowed:
return {"success": False, "message": message, "data": {}}
path = self._normalize_path(request.query_params.get("path") or "/")
if not self._enabled or not self._cookie:
return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"path": path, "items": []}}
ok, folder_id, normalized = self._resolve_existing_dir(path)
if not ok:
return {"success": False, "message": folder_id or "目录不存在", "data": {"path": path, "items": []}}
ok, items, message = self._list_children(folder_id)
dirs = [
{"fid": item.get("fid"), "name": item.get("name"), "path": f"{normalized.rstrip('/')}/{item.get('name')}".replace("//", "/")}
for item in items
if item.get("dir")
]
return {"success": ok, "message": "success" if ok else message, "data": {"path": normalized, "items": dirs}}
async def api_share_info(self, request: Request) -> Dict[str, Any]:
try:
body = await request.json()
except Exception:
body = {}
allowed, message = self._check_api_access(request, body)
if not allowed:
return {"success": False, "message": message, "data": {}}
share_url = body.get("url") or body.get("share_url") or ""
access_code = body.get("access_code") or body.get("pwd") or ""
share_url, pwd_id, final_code = self._extract_share_info(share_url, access_code)
ok, message = self._validate_share_url(share_url)
if not ok:
return {"success": False, "message": message, "data": {}}
if not pwd_id:
return {"success": False, "message": "未识别到有效夸克分享链接", "data": {}}
if not self._enabled or not self._cookie:
return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"pwd_id": pwd_id}}
ok, stoken, message = self._get_stoken(pwd_id, final_code)
if not ok:
return {"success": False, "message": message, "data": {"pwd_id": pwd_id}}
ok, items, message = self._get_share_items(pwd_id, stoken)
return {
"success": ok,
"message": "success" if ok else message,
"data": {
"pwd_id": pwd_id,
"access_code": final_code,
"items": items[:20],
"count": len(items),
},
}
async def api_transfer(self, request: Request) -> Dict[str, Any]:
try:
body = await request.json()
except Exception:
body = {}
allowed, message = self._check_api_access(request, body)
if not allowed:
return {"success": False, "message": message, "data": {}}
ok, result, message = self.transfer_share(
share_text=body.get("url") or body.get("share_url") or "",
access_code=body.get("access_code") or body.get("pwd") or "",
target_path=body.get("path") or body.get("target_path") or self._default_target_path,
remember=True,
trigger="插件 API",
)
return {"success": ok, "message": message, "data": result}