from datetime import datetime import base64 import json import re from typing import Any, Dict, List, Optional, Tuple from urllib.parse import quote from zoneinfo import ZoneInfo import requests try: from app.chain.media import MediaChain except Exception: MediaChain = None try: from app.core.config import settings except Exception: settings = None class HDHiveOpenApiService: """Reusable HDHive execution layer for Agent影视助手.""" _signin_action_name = "checkIn" _signin_router_tree = ["", {"children": ["(app)", {"children": ["__PAGE__", {}, None, None]}, None, None]}, None, None, True] _login_api_candidates = [ "/api/customer/user/login", "/api/customer/auth/login", ] _login_page = "/login" _login_action_router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D' _login_action_fallback = "602b5a3af7ab2e93be6a14001ca83c1be491ccecea" def __init__( self, *, api_key: str = "", base_url: str = "https://hdhive.com", timeout: int = 30, ) -> None: self.api_key = self.normalize_text(api_key) self.base_url = (self.normalize_text(base_url) or "https://hdhive.com").rstrip("/") self.timeout = self.safe_int(timeout, 30) self._login_action_id = "" @staticmethod def safe_int(value: Any, default: int) -> int: try: return int(value) except Exception: return default @staticmethod def normalize_text(value: Any) -> str: if value is None: return "" return str(value).strip() @staticmethod def normalize_slug(value: Any) -> str: return str(value or "").strip().replace("-", "") @staticmethod def normalize_pan_path(value: Any) -> str: text = str(value or "").strip() if not text: return "" if not text.startswith("/"): text = f"/{text}" return text.rstrip("/") or "/" @staticmethod def media_type_text(value: Any) -> str: if value is None: return "" raw = str(getattr(value, "value", value)).strip().lower() mapping = { "电影": "movie", "movie": "movie", "电视剧": "tv", "tv": "tv", } return mapping.get(raw, raw) def tz_now(self) -> datetime: if settings is not None: try: return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) except Exception: pass return datetime.now() def base_headers(self) -> Dict[str, str]: return { "X-API-Key": self.api_key, "Accept": "application/json", "Content-Type": "application/json", "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", } def api_url(self, path: str) -> str: return f"{self.base_url.rstrip('/')}{path}" def tmdb_web_search_url(self, media_type: str, keyword: str) -> str: query = quote(keyword) if media_type == "movie": return f"https://www.themoviedb.org/search/movie?query={query}" if media_type == "tv": return f"https://www.themoviedb.org/search/tv?query={query}" return f"https://www.themoviedb.org/search?query={query}" def tmdb_web_search_headers(self) -> Dict[str, str]: return { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", } @staticmethod def extract_year_from_release(value: Any) -> str: match = re.search(r"(19|20)\d{2}", str(value or "")) return match.group(0) if match else "" def tmdb_web_search_candidates( self, keyword: str, media_type: str = "auto", year: str = "", candidate_limit: int = 10, ) -> Tuple[List[Dict[str, Any]], str]: keyword = self.normalize_text(keyword) media_type = self.normalize_text(media_type).lower() or "auto" year = self.normalize_text(year) candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) search_order = [media_type] if media_type in {"movie", "tv"} else ["tv", "movie"] pattern = re.compile( r'href="/(?Ptv|movie)/(?P\d+)"[^>]*>\s*' r']*>\s*' r'(?P<title>[^]*srcset="(?P[^"]*)"[^>]*src="(?P[^"]+)"[^>]*>' r'.*?(?P[^<]+)', re.S, ) candidates: List[Dict[str, Any]] = [] seen_ids: set[str] = set() errors: List[str] = [] for search_type in search_order: try: response = requests.get( self.tmdb_web_search_url(search_type, keyword), headers=self.tmdb_web_search_headers(), timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) response.raise_for_status() except Exception as exc: errors.append(f"{search_type}:{exc}") continue html = response.text or "" for match in pattern.finditer(html): item_type = self.normalize_text(match.group("media_type")).lower() tmdb_id = self.normalize_text(match.group("tmdb_id")) if not tmdb_id or tmdb_id in seen_ids: continue item_year = self.extract_year_from_release(match.group("release")) if year and item_year and item_year != year: continue seen_ids.add(tmdb_id) candidates.append( { "title": self.normalize_text(match.group("title")), "year": item_year, "media_type": item_type or search_type, "tmdb_id": tmdb_id, "poster_path": self.normalize_text(match.group("src")), } ) if len(candidates) >= candidate_limit: return candidates, "" return candidates, ";".join(errors) def request( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, payload: Optional[Dict[str, Any]] = None, timeout: Optional[int] = None, ) -> Tuple[bool, Dict[str, Any], str, int]: if not self.api_key: return False, {}, "未配置影巢 API Key", 400 try: response = requests.request( method=method.upper(), url=self.api_url(path), headers=self.base_headers(), params=params, json=payload if payload is not None else None, timeout=timeout or self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) except Exception as exc: return False, {}, f"请求异常: {exc}", 0 try: result = response.json() except Exception: result = { "success": False, "message": response.text[:300] if response.text else f"HTTP {response.status_code}", "description": "接口未返回有效 JSON", } if response.ok and isinstance(result, dict) and result.get("success", True): return True, result, "", response.status_code message = "" if isinstance(result, dict): message = ( result.get("description") or result.get("message") or result.get("code") or f"HTTP {response.status_code}" ) if not message: message = f"HTTP {response.status_code}" return False, result if isinstance(result, dict) else {}, message, response.status_code def resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: pan = str(item.get("pan_type") or "").lower() points = item.get("unlock_points") try: points_value = int(points) if points is not None and str(points) != "" else 0 except Exception: points_value = 9999 validate = str(item.get("validate_status") or "").lower() resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] sources = [str(v) for v in (item.get("source") or [])] pan_rank = 0 if pan == "115" else 1 if pan == "quark" else 2 points_rank = 0 if points_value <= 0 else 1 validate_rank = 0 if validate in {"valid", ""} else 1 resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) async def resolve_candidates_by_keyword( self, keyword: str, media_type: str = "auto", year: str = "", candidate_limit: int = 10, ) -> Tuple[bool, Dict[str, Any], str]: keyword = self.normalize_text(keyword) media_type = self.normalize_text(media_type).lower() or "auto" type_filter = "" if media_type in {"auto", "all", "*"} else media_type year = self.normalize_text(year) candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) if not keyword: return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" if type_filter and type_filter not in {"movie", "tv"}: return False, {"message": "媒体类型必须是 movie、tv 或 auto", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie、tv 或 auto" chain_error = "" medias = [] if MediaChain is None: chain_error = "MoviePilot MediaChain 不可用" else: try: _, medias = await MediaChain().async_search(title=keyword) except Exception as exc: chain_error = f"TMDB 解析失败: {exc}" try: medias = list(medias or []) except Exception: medias = [] candidates: List[Dict[str, Any]] = [] for media in medias: item_type = self.media_type_text(getattr(media, "type", "")) item_year = self.normalize_text(getattr(media, "year", "")) if type_filter and item_type and item_type != type_filter: continue if year and item_year and item_year != year: continue tmdb_id = getattr(media, "tmdb_id", None) if not tmdb_id: continue candidates.append( { "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", "year": item_year, "media_type": item_type or type_filter or "movie", "tmdb_id": tmdb_id, "poster_path": getattr(media, "poster_path", "") or "", } ) if len(candidates) >= candidate_limit: break fallback_used = False fallback_message = "" if not candidates: web_candidates, web_error = self.tmdb_web_search_candidates( keyword=keyword, media_type=media_type, year=year, candidate_limit=candidate_limit, ) if web_candidates: candidates = web_candidates fallback_used = True else: fallback_message = web_error result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": bool(candidates), "status_code": 200 if candidates else 404, "message": "success" if candidates else "未找到可用于影巢搜索的 TMDB 候选", "query": {"keyword": keyword, "media_type": media_type, "year": year}, "candidates": candidates, "meta": { "total": len(candidates), "candidate_source": "tmdb_web_search" if fallback_used else "mediainfo_chain", }, } if fallback_used: result["fallback_reason"] = chain_error or "MediaChain 未返回候选" elif chain_error: result["chain_warning"] = chain_error if not candidates and fallback_message: result["fallback_error"] = fallback_message if chain_error: result["message"] = f"{chain_error};TMDB 网页搜索兜底也未命中" elif not candidates and chain_error: result["message"] = chain_error return bool(candidates), result, result["message"] def search_resources(self, media_type: str, tmdb_id: str) -> Tuple[bool, Dict[str, Any], str]: media_type = (media_type or "").strip().lower() tmdb_id = self.normalize_text(tmdb_id) if media_type not in {"movie", "tv"}: return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" if not tmdb_id: return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" ok, payload, message, status_code = self.request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "query": {"media_type": media_type, "tmdb_id": tmdb_id}, "data": payload.get("data") if isinstance(payload, dict) else [], "meta": payload.get("meta") if isinstance(payload, dict) else {}, } return ok, result, message async def search_resources_by_keyword( self, keyword: str, media_type: str = "auto", year: str = "", candidate_limit: int = 10, result_limit: int = 12, ) -> Tuple[bool, Dict[str, Any], str]: result_limit = min(50, max(1, self.safe_int(result_limit, 12))) ok, candidate_result, candidate_message = await self.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=candidate_limit, ) if not ok: result = dict(candidate_result) result["data"] = [] return False, result, candidate_message candidates = candidate_result.get("candidates") or [] merged_items: List[Dict[str, Any]] = [] seen_slugs: set[str] = set() last_status = 200 for candidate in candidates: ok, payload, message = self.search_resources( media_type=candidate["media_type"] or media_type, tmdb_id=str(candidate["tmdb_id"]), ) last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status if not ok: continue for resource in payload.get("data") or []: slug = self.normalize_slug(resource.get("slug")) if not slug or slug in seen_slugs: continue seen_slugs.add(slug) annotated = dict(resource) annotated["matched_tmdb_id"] = candidate["tmdb_id"] annotated["matched_title"] = candidate["title"] annotated["matched_year"] = candidate["year"] merged_items.append(annotated) merged_items.sort(key=self.resource_sort_key) merged_items = merged_items[:result_limit] result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": bool(merged_items), "status_code": last_status, "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", "query": {"keyword": keyword, "media_type": media_type, "year": year}, "candidates": candidates, "data": merged_items, "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, } return bool(merged_items), result, result["message"] def unlock_resource(self, slug: str) -> Tuple[bool, Dict[str, Any], str]: slug = self.normalize_slug(slug) if not slug: return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" ok, payload, message, status_code = self.request( "POST", "/api/open/resources/unlock", payload={"slug": slug}, ) result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "slug": slug, "data": payload.get("data") if isinstance(payload, dict) else {}, } return ok, result, message def fetch_me(self) -> Tuple[bool, Dict[str, Any], str]: ok, payload, message, status_code = self.request("GET", "/api/open/me") result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "data": payload.get("data") if isinstance(payload, dict) else {}, } return ok, result, message def fetch_quota(self) -> Tuple[bool, Dict[str, Any], str]: ok, payload, message, status_code = self.request("GET", "/api/open/quota") result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "data": payload.get("data") if isinstance(payload, dict) else {}, } return ok, result, message def fetch_usage_today(self) -> Tuple[bool, Dict[str, Any], str]: ok, payload, message, status_code = self.request("GET", "/api/open/usage/today") result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "data": payload.get("data") if isinstance(payload, dict) else {}, } return ok, result, message def fetch_weekly_free_quota(self) -> Tuple[bool, Dict[str, Any], str]: ok, payload, message, status_code = self.request("GET", "/api/open/vip/weekly-free-quota") result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "message": payload.get("message") if ok else message, "data": payload.get("data") if isinstance(payload, dict) else {}, } return ok, result, message def perform_checkin( self, *, is_gambler: Optional[bool] = None, trigger: str = "手动", ) -> Tuple[bool, Dict[str, Any], str]: gambler_mode = bool(is_gambler) payload = {"is_gambler": True} if gambler_mode else None ok, result_payload, message, status_code = self.request("POST", "/api/open/checkin", payload=payload) data = result_payload.get("data") if isinstance(result_payload, dict) else {} checked_in = bool((data or {}).get("checked_in")) if ok else False if ok: status_text = "签到成功" if checked_in else "今日已签到" else: status_text = "签到失败" result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": ok, "status_code": status_code, "trigger": trigger, "is_gambler": gambler_mode, "status": status_text, "message": (data or {}).get("message") or result_payload.get("message") or message, "data": data or {}, } return ok, result, message @staticmethod def parse_cookie_string(cookie_str: Optional[str]) -> Dict[str, str]: cookies: Dict[str, str] = {} if not cookie_str: return cookies for cookie_item in str(cookie_str).split(";"): if "=" in cookie_item: name, value = cookie_item.strip().split("=", 1) cookies[name] = value return cookies @staticmethod def _decode_token_user_id(token: str) -> str: if not token or "." not in token: return "" try: payload = token.split(".", 2)[1] padding = "=" * (-len(payload) % 4) decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8", "ignore") data = json.loads(decoded) return str(data.get("user_id") or data.get("sub") or data.get("id") or "").strip() except Exception: return "" @staticmethod def _cookie_string_from_mapping(cookies: Dict[str, str]) -> str: token_cookie = str((cookies or {}).get("token") or "").strip() csrf_cookie = str((cookies or {}).get("csrf_access_token") or "").strip() if not token_cookie: return "" cookie_items = [f"token={token_cookie}"] if csrf_cookie: cookie_items.append(f"csrf_access_token={csrf_cookie}") return "; ".join(cookie_items) @classmethod def _extract_login_action_id_from_text(cls, text: str) -> str: patterns = [ r'next-action"\s*:\s*"([a-fA-F0-9]{16,64})"', r'name="next-action"\s+value="([a-fA-F0-9]{16,64})"', r'createServerReference\("([a-f0-9]{40,})"[^\\n]+?"login"\)', ] for pattern in patterns: match = re.search(pattern, text or "") if match: return str(match.group(1) or "").strip() return "" def _discover_login_action_id(self, warm_text: str, scraper: Any) -> str: if self._login_action_id: return self._login_action_id action_id = self._extract_login_action_id_from_text(warm_text) if action_id: self._login_action_id = action_id return action_id script_paths = re.findall( r']+src="([^"]+/app/\(auth\)/login/page-[^"]+\.js)"', warm_text or "", ) for script_path in script_paths: script_url = script_path if script_path.startswith("http") else f"{self.base_url}{script_path}" try: resp = scraper.get( script_url, headers={ "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Referer": f"{self.base_url}{self._login_page}", "Accept": "*/*", }, timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) except Exception: continue action_id = self._extract_login_action_id_from_text(getattr(resp, "text", "") or "") if action_id: self._login_action_id = action_id return action_id self._login_action_id = self._login_action_fallback return self._login_action_id @staticmethod def _parse_server_action_error(response_text: str) -> str: if not response_text: return "" try: for line in response_text.splitlines(): line = line.strip() if not line.startswith("1:"): continue payload = json.loads(line[2:]) error = payload.get("error") or {} message = str(error.get("message") or "").strip() description = str(error.get("description") or "").strip() if message or description: return f"{message} ({description})" if description and description != message else (message or description) except Exception: return "" return "" def login_for_cookie(self, *, username: str, password: str) -> Tuple[bool, str, str]: username = self.normalize_text(username) password = self.normalize_text(password) if not username or not password: return False, "", "未配置影巢用户名或密码,无法自动刷新 Cookie" try: import cloudscraper scraper = cloudscraper.create_scraper() except Exception: scraper = requests login_url = f"{self.base_url}{self._login_page}" warm_text = "" try: resp_warm = scraper.get( login_url, timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) warm_text = getattr(resp_warm, "text", "") or "" except Exception: pass if "系统维护中" in warm_text or "maintenance" in warm_text.lower(): return False, "", "影巢站点当前处于维护页,暂时无法自动登录刷新 Cookie" for path in self._login_api_candidates: url = f"{self.base_url}{path}" headers = { "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "application/json, text/plain, */*", "Origin": self.base_url, "Referer": login_url, "Content-Type": "application/json", } payload = {"username": username, "password": password} try: resp = scraper.post( url, headers=headers, json=payload, timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) except Exception: continue cookies_dict: Dict[str, str] = {} try: cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} except Exception: cookies_dict = {} cookie_string = self._cookie_string_from_mapping(cookies_dict) if cookie_string: return True, cookie_string, "API 登录成功" try: data = resp.json() except Exception: data = {} meta = (data.get("meta") or {}) if isinstance(data, dict) else {} access_token = str(meta.get("access_token") or "").strip() refresh_token = str(meta.get("refresh_token") or "").strip() if access_token: cookie_items = [f"token={access_token}"] if refresh_token: cookie_items.append(f"refresh_token={refresh_token}") return True, "; ".join(cookie_items), "API 登录成功" action_id = self._discover_login_action_id(warm_text, scraper) if action_id: headers = { "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "text/x-component", "Origin": self.base_url, "Referer": login_url, "Content-Type": "text/plain;charset=UTF-8", "next-action": action_id, "next-router-state-tree": self._login_action_router_state, } body = json.dumps([{"username": username, "password": password}, "/"], separators=(",", ":")) try: resp = scraper.post( login_url, headers=headers, data=body, timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, ) except Exception as exc: resp = None server_action_message = f"Server Action 登录请求异常: {exc}" else: server_action_message = "" if resp is not None: try: cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} except Exception: cookies_dict = {} cookie_string = self._cookie_string_from_mapping(cookies_dict) if cookie_string: return True, cookie_string, "Server Action 登录成功" action_error = self._parse_server_action_error(getattr(resp, "text", "") or "") if action_error: server_action_message = action_error else: server_action_message = "未解析到登录 Action" try: from playwright.sync_api import sync_playwright except Exception: return False, "", server_action_message or "自动登录失败,且 Playwright 不可用" try: proxy = None try: proxy_config = getattr(settings, "PROXY", None) if settings is not None else None server = (proxy_config or {}).get("http") or (proxy_config or {}).get("https") if server: proxy = {"server": server} except Exception: proxy = None with sync_playwright() as pw: browser = pw.chromium.launch(headless=True, proxy=proxy) if proxy else pw.chromium.launch(headless=True) context = browser.new_context() page = context.new_page() page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout * 1000) for selector in [ "input[name='username']", "input[name='email']", "input[type='email']", "input[placeholder*='邮箱']", "input[placeholder*='email']", "input[placeholder*='用户名']", ]: try: if page.query_selector(selector): page.fill(selector, username) break except Exception: continue for selector in [ "input[name='password']", "input[type='password']", "input[placeholder*='密码']", ]: try: if page.query_selector(selector): page.fill(selector, password) break except Exception: continue try: button = ( page.query_selector("button[type='submit']") or page.query_selector("button:has-text('登录')") or page.query_selector("button:has-text('Login')") ) if button: button.click() else: page.keyboard.press("Enter") except Exception: page.keyboard.press("Enter") try: page.wait_for_load_state("networkidle", timeout=10000) except Exception: pass cookies = context.cookies() context.close() browser.close() except Exception as exc: return False, "", f"Playwright 自动登录失败: {exc}" cookie_map = {str(item.get("name") or ""): str(item.get("value") or "") for item in cookies or []} cookie_string = self._cookie_string_from_mapping(cookie_map) if cookie_string: return True, cookie_string, "Playwright 登录成功" return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie" @classmethod def _build_signin_tree_header(cls) -> str: return quote(json.dumps(cls._signin_router_tree, separators=(",", ":"))) @staticmethod def _build_signin_action_body(is_gambler: bool) -> str: return json.dumps([bool(is_gambler)], separators=(",", ":")) @staticmethod def _normalize_response_text(text: str) -> str: if not text: return "" if "ä½" in text or "å·²" in text or "签到" in text: try: return text.encode("latin1", errors="ignore").decode("utf-8", errors="ignore") except Exception: return text return text @classmethod def _extract_signin_action_id_from_chunk(cls, chunk_text: str) -> str: if not chunk_text: return "" patterns = [ rf'createServerReference[\s\S]{{0,120}}?\("([a-f0-9]{{32,}})"[\s\S]{{0,1200}}?"{re.escape(cls._signin_action_name)}"', rf'([a-f0-9]{{32,}}).{{0,240}}?"{re.escape(cls._signin_action_name)}"', ] for pattern in patterns: match = re.search(pattern, chunk_text, re.S) if match: return match.group(1) return "" @classmethod def _parse_signin_action_response(cls, text: str) -> Tuple[bool, str]: text = cls._normalize_response_text(text) if not text: return False, "签到响应为空" for raw_line in text.splitlines(): line = raw_line.strip() if not line or ":" not in line: continue _, payload = line.split(":", 1) try: data = json.loads(payload) except Exception: continue if not isinstance(data, dict): continue if isinstance(data.get("response"), dict): data = data["response"] error = data.get("error") if isinstance(error, dict): message = cls._normalize_response_text(error.get("description") or error.get("message") or "签到失败") if "已经签到" in message or "签到过" in message or "明天再来" in message: return True, message return False, message message = cls._normalize_response_text(data.get("message") or data.get("description")) success = data.get("success") if message: if success is False: return False, message if "已经签到" in message or "签到过" in message or "明天再来" in message: return True, message return True, message return False, "签到响应格式异常" def _discover_signin_action_id(self, cookies: Dict[str, str], token: str, referer: str) -> str: headers = { "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Origin": self.base_url, "Referer": referer, "Authorization": f"Bearer {token}", } try: home_resp = requests.get( url=f"{self.base_url}/", headers=headers, cookies=cookies, proxies=getattr(settings, "PROXY", None) if settings is not None else None, timeout=self.timeout, verify=False, ) except Exception: return "" if home_resp.status_code != 200: return "" html = home_resp.text or "" chunk_paths = list(dict.fromkeys(re.findall(r'/_next/static/chunks/[A-Za-z0-9._-]+\.js', html))) for chunk_path in chunk_paths: try: chunk_resp = requests.get( url=f"{self.base_url}{chunk_path}", headers={ "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "application/javascript,text/javascript,*/*;q=0.1", "Connection": "close", }, proxies=getattr(settings, "PROXY", None) if settings is not None else None, timeout=min(self.timeout, 20), verify=False, ) except Exception: continue if chunk_resp.status_code != 200: continue action_id = self._extract_signin_action_id_from_chunk(chunk_resp.text or "") if action_id: return action_id return "" def perform_legacy_web_checkin( self, *, cookie_string: str, is_gambler: bool = False, trigger: str = "网页兜底", ) -> Tuple[bool, Dict[str, Any], str]: cookies = self.parse_cookie_string(cookie_string) token = str(cookies.get("token") or "").strip() csrf_token = str(cookies.get("csrf_access_token") or "").strip() if not cookies or not token: result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "status_code": 400, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "签到失败", "message": "缺少可用的影巢网页 Cookie", "data": {}, "source": "hdhive_web_legacy", } return False, result, result["message"] user_id = self._decode_token_user_id(token) referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" headers = { "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", "Origin": self.base_url, "Referer": referer, "Authorization": f"Bearer {token}", } if csrf_token: headers["X-CSRF-TOKEN"] = csrf_token payload = {"is_gambler": True} if is_gambler else {} try: response = requests.post( url=f"{self.base_url}/api/customer/user/checkin", headers=headers, cookies=cookies, json=payload, timeout=self.timeout, proxies=getattr(settings, "PROXY", None) if settings is not None else None, verify=False, ) except Exception as exc: result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "status_code": 0, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "签到失败", "message": f"网页签到请求异常: {exc}", "data": {}, "source": "hdhive_web_legacy", } return False, result, result["message"] try: body = response.json() except Exception: body = {} message = "" if isinstance(body, dict): message = str(body.get("description") or body.get("message") or body.get("code") or "").strip() if not message: message = str(response.text or f"HTTP {response.status_code}").strip()[:200] lowered = message.lower() already_signed = "已经签到" in message or "签到过" in message or "明天再来" in message success = bool(response.status_code < 400 and (not isinstance(body, dict) or body.get("success") is not False)) if already_signed: success = True result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": success, "status_code": response.status_code, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "今日已签到" if already_signed else "签到成功" if success else "签到失败", "message": message or ("签到成功" if success else f"HTTP {response.status_code}"), "data": body if isinstance(body, dict) else {}, "source": "hdhive_web_legacy", } return success, result, result["message"] def perform_web_checkin_with_fallback( self, *, cookie_string: str, is_gambler: bool = False, trigger: str = "网页兜底", ) -> Tuple[bool, Dict[str, Any], str]: legacy_ok, legacy_result, legacy_message = self.perform_legacy_web_checkin( cookie_string=cookie_string, is_gambler=is_gambler, trigger=trigger, ) if legacy_ok: return legacy_ok, legacy_result, legacy_message cookies = self.parse_cookie_string(cookie_string) token = str(cookies.get("token") or "").strip() csrf_token = str(cookies.get("csrf_access_token") or "").strip() if not cookies or not token: return legacy_ok, legacy_result, legacy_message user_id = self._decode_token_user_id(token) referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" action_id = self._discover_signin_action_id(cookies, token, referer) if not action_id: message = "旧版网页签到接口不可用,且未能解析当前站点签到 Action;请更新影巢网页 Cookie 后重试" legacy_result["message"] = message legacy_result["status"] = "签到失败" legacy_result["source"] = "hdhive_web_next_action" return False, legacy_result, message headers = { "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", "Accept": "text/x-component", "Content-Type": "text/plain;charset=UTF-8", "Origin": self.base_url, "Referer": f"{self.base_url}/", "Authorization": f"Bearer {token}", "next-action": action_id, "next-router-state-tree": self._build_signin_tree_header(), } if csrf_token: headers["x-csrf-token"] = csrf_token try: response = requests.post( url=f"{self.base_url}/", headers=headers, cookies=cookies, data=self._build_signin_action_body(is_gambler), proxies=getattr(settings, "PROXY", None) if settings is not None else None, timeout=self.timeout, verify=False, ) except Exception as exc: return False, { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "status_code": 0, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "签到失败", "message": f"Next Action 签到请求异常: {exc}", "data": {}, "source": "hdhive_web_next_action", }, f"Next Action 签到请求异常: {exc}" redirect_target = str(response.headers.get("x-action-redirect") or response.headers.get("Location") or "").strip() if "/login" in redirect_target: message = "影巢网页 Cookie 已失效,请先在 HDHiveDailySign 中更新 Cookie 或重新自动登录" result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "status_code": response.status_code, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "签到失败", "message": message, "data": {"redirect": redirect_target}, "source": "hdhive_web_next_action", } return False, result, message if response.status_code in (404, 405): message = f"影巢网页签到入口暂不可用或 Cookie 已失效(HTTP {response.status_code}),请更新本插件里的影巢网页 Cookie 后重试" result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "status_code": response.status_code, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "签到失败", "message": message, "data": {}, "source": "hdhive_web_next_action", } return False, result, message response_text = "" try: response_text = response.content.decode("utf-8", errors="ignore") except Exception: response_text = response.text or "" success, message = self._parse_signin_action_response(response_text) result = { "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), "ok": success, "status_code": response.status_code, "trigger": trigger, "is_gambler": bool(is_gambler), "status": "今日已签到" if "已经签到" in message or "签到过" in message or "明天再来" in message else "签到成功" if success else "签到失败", "message": message, "data": {}, "source": "hdhive_web_next_action", } return success, result, message