# -*- coding: utf-8 -*- import json from typing import Optional, Tuple from urllib.parse import urlencode, urljoin from app.runtime.log import logger from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.foundation import temporal as time_tools class SunnyPTSiteUserInfo(SiteParserBase): """ SunnyPT MoviePilot API 用户数据解析器 """ schema = SiteSchema.SunnyPT request_mode = "apikey" def _parse_site_page(self, html_text: str) -> None: """ 配置 SunnyPT 用户数据接口地址和认证请求头 :param html_text: API Key 模式下的空首页数据 """ self._base_url = f"{str(self._api_url or 'https://api.sunnypt.top/api/v1/mp').rstrip('/')}/" self._user_basic_page = "profile" self._user_detail_page = None self._user_traffic_page = None self._torrent_seeding_page = None self._user_mail_unread_page = "messages" self._sys_mail_unread_page = None self._addition_headers = { "Accept": "application/json", "User-Agent": self._ua, "X-API-Key": self.apikey, } def _load_api_payload(self, html_text: str, operation: str) -> Optional[dict]: """ 解析并校验 SunnyPT 通用 JSON 响应 :param html_text: API 响应文本 :param operation: 错误信息中的操作名称 :return: 成功时返回响应 data,失败时返回 None """ if not html_text: self.err_msg = f"{operation}失败,未收到 API 响应" return None try: payload = json.loads(html_text) except (TypeError, json.JSONDecodeError) as err: self.err_msg = f"{operation}失败,API 响应不是有效 JSON" logger.warning(f"{self._site_name} {self.err_msg}:{str(err)}") return None if not isinstance(payload, dict): self.err_msg = f"{operation}失败,API 响应结构无效" logger.warning(f"{self._site_name} {self.err_msg}") return None if str(payload.get("code")) != "0": self.err_msg = payload.get("msg") or f"{operation}失败" logger.warning(f"{self._site_name} {operation}失败:{self.err_msg}") return None data = payload.get("data") return data if isinstance(data, dict) else None def _parse_user_base_info(self, html_text: str) -> None: """ 解析 SunnyPT 用户资料、流量和做种统计 :param html_text: profile 接口响应文本 """ user_info = self._load_api_payload(html_text, "获取用户信息") if not user_info: return self.userid = user_info.get("id") self.username = user_info.get("username") self.user_level = user_info.get("level") or str(user_info.get("class") or "") self.join_at = time_tools.normalize_datetime(user_info.get("registered_at")) self.upload = int(user_info.get("uploaded") or 0) self.download = int(user_info.get("downloaded") or 0) self.ratio = float(user_info.get("ratio") or 0) self.bonus = float(user_info.get("bonus") or 0) self.seeding = int(user_info.get("seeding_count") or 0) self.seeding_size = int(user_info.get("seeding_size") or 0) self.leeching = int(user_info.get("leeching_count") or 0) self.leeching_size = int(user_info.get("leeching_size") or 0) self.message_unread = int(user_info.get("unread_messages") or 0) def _pase_unread_msgs(self) -> None: """ 分页读取 SunnyPT 未读消息;解析阶段不标记已读,避免投递失败后丢失通知 """ page = 1 while True: query = urlencode({ "unread_only": "true", "page": page, "page_size": 100, }) html_text = self._get_page_content( url=urljoin(self._base_url, f"messages?{query}") ) has_more = self._parse_message_unread_links(html_text, []) if not has_more: return page += 1 def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 解析 SunnyPT 未读消息列表并直接保存消息正文 :param html_text: messages 接口响应文本 :param msg_links: 兼容解析器基类签名的消息链接容器 :return: 存在下一页时返回占位字符串,否则返回 None """ messages_data = self._load_api_payload(html_text, "获取未读消息") if not messages_data: return None self.message_unread = int(messages_data.get("unread_count") or 0) for message in messages_data.get("items") or []: if not isinstance(message, dict) or not message.get("unread"): continue title = message.get("title") content = message.get("content") created_at = time_tools.normalize_datetime(message.get("created_at")) message_id = message.get("id") if title and content and created_at: message_source = f"sunnypt-message:{message_id}" if message_id is not None else None self.message_unread_contents.append( (title, created_at, content, message_source) ) return "next" if messages_data.get("has_more") else None def _parse_user_traffic_info(self, html_text: str) -> None: """ 跳过独立流量页面,profile 接口已经返回完整统计 :param html_text: 未使用的页面文本 """ def _parse_user_detail_info(self, html_text: str) -> None: """ 跳过独立用户详情页面,profile 接口已经返回完整资料 :param html_text: 未使用的页面文本 """ def _parse_user_torrent_seeding_info( self, html_text: str, multi_page: bool = False, ) -> Optional[str]: """ 跳过独立做种列表,profile 接口已经返回做种数量和体积 :param html_text: 未使用的页面文本 :param multi_page: 是否为后续分页 :return: 始终返回 None """ return None def _parse_message_content( self, html_text: str, ) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ 跳过消息详情请求,messages 接口已经返回完整正文 :param html_text: 未使用的页面文本 :return: 三个空值 """ return None, None, None