mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-24 23:16:49 +00:00
1115 lines
46 KiB
Python
1115 lines
46 KiB
Python
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
|