Files
archived-MoviePilot-Plugins/plugins.v2/agentresourceofficer/services/hdhive_openapi.py

1115 lines
46 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.
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="/(?P<media_type>tv|movie)/(?P<tmdb_id>\d+)"[^>]*>\s*'
r'<div[^>]*>\s*'
r'<img alt="(?P<title>[^"]+)"[^>]*srcset="(?P<srcset>[^"]*)"[^>]*src="(?P<src>[^"]+)"[^>]*>'
r'.*?<span class="release_date[^"]*">(?P<release>[^<]+)</span>',
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'<script[^>]+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 cloakbrowser import launch_context
except Exception:
return False, "", server_action_message or "自动登录失败,且 CloakBrowser 不可用"
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
context = None
try:
context = launch_context(headless=True, proxy=proxy)
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()
finally:
if context:
context.close()
except Exception as exc:
return False, "", f"CloakBrowser 自动登录失败: {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, "CloakBrowser 登录成功"
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