import json import random import re import time from datetime import datetime from typing import Any, Callable, 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 app.log import logger try: from app.core.config import settings except Exception: settings = None class QuarkTransferService: """ Reusable execution layer migrated out of QuarkShareSaver. This service intentionally focuses on transfer execution and directory resolution. UI, plugin form logic, and entry adapters stay outside. """ def __init__( self, *, cookie: str = "", timeout: int = 30, default_target_path: str = "/飞书", auto_import_cookiecloud: bool = True, cookie_refresh_callback: Optional[Callable[[], str]] = None, ) -> None: self.cookie = self.clean_text(cookie) self.timeout = max(10, self.safe_int(timeout, 30)) self.default_target_path = self.normalize_path(default_target_path or "/飞书") self.auto_import_cookiecloud = auto_import_cookiecloud self.cookie_refresh_callback = cookie_refresh_callback self.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 "/" @staticmethod def extract_url(raw_text: str) -> str: match = re.search(r"https?://[^\s<>\"']+", raw_text) if match: return match.group(0).rstrip(".,);]") return "" @classmethod def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]: raw = cls.clean_text(share_text) share_url = cls.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 = cls.clean_text(access_code) if not code: query = dict(parse_qsl(parsed.query)) code = cls.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 = cls.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") @classmethod def validate_share_url(cls, share_url: str) -> Tuple[bool, str]: if not share_url: return False, "未识别到有效夸克分享链接" if cls.is_quark_share_url(share_url): return True, "" hostname = urlparse(share_url).hostname or "未知域名" return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" def set_cookie(self, cookie: str) -> None: self.cookie = self.clean_text(cookie) 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 _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", } @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 _refresh_cookie(self) -> bool: if not self.auto_import_cookiecloud or not self.cookie_refresh_callback: return False try: cookie = self.clean_text(self.cookie_refresh_callback()) except Exception as exc: logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}") return False if not cookie: return False self.cookie = cookie return True def _request( self, method: str, url: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, allow_cookie_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 in {401, 403} and allow_cookie_retry and self._refresh_cookie(): return self._request( method, url, params=params, json_body=json_body, allow_cookie_retry=False, ) if status_code != 200: if isinstance(data, dict): code = self.clean_text(data.get("code")) detail = self.clean_text(data.get("message") or data.get("msg")) if detail: if code: return False, data, f"HTTP {status_code} [{code}]: {detail}" return False, data, f"HTTP {status_code}: {detail}" 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, {}, "接口返回格式错误" 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, "raw": item, } ) if len(current) < 100: break page += 1 return True, result, "" def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: source_items = [item for item in (items or []) if isinstance(item, dict)] def build_fids(candidates: List[Dict[str, Any]]) -> List[str]: result: List[str] = [] for item in candidates: fid = self.clean_text(item.get("fid")) if fid: result.append(fid) return result def item_label(item: Dict[str, Any]) -> str: return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid")) def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: fids = build_fids(candidates) if not fids: return False, {}, "默认目录当前层没有可删除项目" payloads = [ { "action_type": 2, "exclude_fids": [], "filelist": [{"fid": fid} for fid in fids], }, { "action_type": 2, "exclude_fids": [], "filelist": fids, }, { # Some web scripts historically used this misspelled key. "actoin_type": 2, "exclude_fids": [], "filelist": fids, }, ] last_data: Dict[str, Any] = {} last_message = "" for index, payload in enumerate(payloads, start=1): ok, data, message = self._request( "POST", "https://drive-pc.quark.cn/1/clouddrive/file/delete", params={ "pr": "ucpro", "fr": "pc", "uc_param_str": "", }, json_body=payload, ) if ok: if isinstance(data, dict): data["delete_payload_variant"] = index return True, data, "" last_data = data if isinstance(data, dict) else {} last_message = message or last_message return False, last_data, last_message or "夸克删除失败" filelist: List[Dict[str, Any]] = [] for item in source_items: fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else "" if fid: filelist.append({"fid": fid}) if not filelist: return False, {}, "默认目录当前层没有可删除项目" ok, data, message = call_delete(source_items) if ok: data["deleted_count"] = len(filelist) data["delete_mode"] = "batch" return True, data, "" if len(source_items) <= 1: return False, data, message or "夸克删除失败" deleted_count = 0 failed_items: List[Dict[str, Any]] = [] for item in source_items: single_ok, single_data, single_message = call_delete([item]) if single_ok: deleted_count += 1 continue failed_items.append({ "fid": self.clean_text(item.get("fid")), "name": item_label(item), "message": single_message or "删除失败", "result": single_data, }) result = { "deleted_count": deleted_count, "failed_count": len(failed_items), "failed_items": failed_items[:20], "delete_mode": "single_fallback", "batch_error": message or "夸克批量删除失败", "batch_result": data, } if failed_items: return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败" return True, result, "" def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path) if not ok: return False, {}, target_fid or "定位夸克目录失败" ok, children, message = self.list_children(target_fid) if not ok: return False, {}, message or "读取夸克目录失败" files = [item for item in children if not bool(item.get("dir"))] folders = [item for item in children if bool(item.get("dir"))] if not children: return True, { "target_path": normalized_path, "target_fid": target_fid, "removed_count": 0, "file_count": 0, "folder_count": 0, "items": [], "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), }, "默认目录当前层为空" ok, delete_result, message = self.delete_items(children) removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0) if not ok: return False, { "target_path": normalized_path, "target_fid": target_fid, "file_count": len(files), "folder_count": len(folders), "removed_count": removed_count, "items": [self.clean_text(item.get("name")) for item in children[:20]], "failed_items": (delete_result or {}).get("failed_items") or [], "delete_result": delete_result, }, message or "夸克清空默认目录失败" return True, { "target_path": normalized_path, "target_fid": target_fid, "removed_count": removed_count, "file_count": len(files), "folder_count": len(folders), "items": [self.clean_text(item.get("name")) for item in children[:20]], "delete_result": delete_result, "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), }, "success" 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 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 = "", *, trigger: str = "Agent影视助手", ) -> 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.cookie: self._refresh_cookie() if not self.cookie: return False, {}, "未配置夸克 Cookie" ok, stoken, message = self.get_stoken(pwd_id, final_code) if not ok: return False, {}, message ok, share_items, message = self.get_share_items(pwd_id, stoken) if not ok: return False, {}, message ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path) if not ok: return False, {}, target_fid ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid) if not ok: return False, {}, message ok, task, message = self.wait_task(task_id) if not ok: 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"), } return True, result, "success"