mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-24 07:26:52 +00:00
1114 lines
45 KiB
Python
1114 lines
45 KiB
Python
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}
|