Files
MoviePilot/app/modules/indexer/parser/nexus_rabbit.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

205 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
"""
解析 NexusRabbit 站点的用户资料、流量和做种信息。
"""
import json
import re
from urllib.parse import urljoin
from lxml import etree
from app.foundation import size as size_tools
from app.foundation import temporal as time_tools
from app.foundation import text as text_tools
from app.foundation.dom import DomUtils
from app.modules.indexer.parser import SitePage, SiteParserBase, SiteSchema
from app.runtime.log import logger
class NexusRabbitSiteUserInfo(SiteParserBase):
"""
解析 NexusRabbit 站点的用户资料、流量与消息。
"""
schema = SiteSchema.NexusRabbit
def _parse_site_page(self, html_text: SitePage) -> None:
"""
识别并配置站点数据页面与请求参数。
"""
html_text = self._prepare_html_text(html_text)
user_detail = re.search(r"user.php\?id=(\d+)", html_text)
if not (user_detail and user_detail.group().strip()):
return
self.userid = user_detail.group(1)
self._user_detail_page = f"user.php?id={self.userid}"
self._user_traffic_page = None
self._torrent_seeding_page = "api/general"
self._torrent_seeding_params = {
"page": 1,
"limit": 5000000,
"action": "userTorrentsList",
"data": {"type": "seeding", "id": int(self.userid)},
}
self._torrent_seeding_headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
# 必须要加上这一条,不然返回的是空数据
"X-Requested-With": "XMLHttpRequest",
}
self._user_mail_unread_page = None
self._sys_mail_unread_page = "api/general"
self._mail_unread_params = {
"page": 1,
"limit": 5000000,
"action": "getMessageIn",
}
self._mail_unread_headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
}
def _parse_user_torrent_seeding_info(
self, html_text: str, multi_page: bool = False
) -> None:
"""
从一次完整列表响应更新做种数量、体积和人数分布。
:param html_text: 做种列表 API 响应文本
:param multi_page: 保留基类调用参数,本实现不继续翻页
"""
try:
torrents = json.loads(html_text).get("data", [])
except Exception as e:
logger.error(f"解析做种信息失败: {str(e)}")
return None
seeding_size = 0
seeding_info = []
for torrent in torrents:
seeders = int(torrent.get("seeders", 0))
size = size_tools.parse_size(torrent.get("size"))
seeding_size += size
seeding_info.append([seeders, size])
self.seeding = len(torrents)
self.seeding_size = seeding_size
self.seeding_info = seeding_info
def _parse_message_unread_links(
self, html_text: str, msg_links: list[str]
) -> str | None:
"""
收集未读消息链接,返回仍需读取的下一页。
"""
unread_ids = []
try:
messages = json.loads(html_text).get("data", [])
except Exception as e:
logger.error(f"解析未读消息失败: {e}")
return None
for msg in messages:
msg_id, msg_unread = msg.get("id"), msg.get("unread")
if not (msg_id and msg_unread) or msg_unread == "no":
continue
unread_ids.append(msg_id)
head, date, content = msg.get("subject"), msg.get("added"), msg.get("msg")
if head and date and content:
self.message_unread_contents.append((head, date, content))
self.message_unread = len(unread_ids)
if unread_ids:
self._get_page_content(
url=urljoin(self._base_url, "api/general?loading=true"),
params={"action": "readMessage", "data": {"ids": unread_ids}},
headers={
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
},
)
return None
def _parse_user_base_info(self, html_text: SitePage) -> None:
"""
只有奶糖余额才需要在 base 中获取,其它均可以在详情页拿到。
"""
html = etree.HTML(html_text)
try:
if not DomUtils.has_child_elements(html):
return
bonus = html.xpath(
'//div[contains(text(), "奶糖余额")]/following-sibling::div[1]/text()'
)
if bonus:
self.bonus = text_tools.parse_float(bonus[0].strip())
finally:
if html is not None:
del html
def _parse_user_detail_info(self, html_text: str) -> None:
"""
解析用户等级、注册时间及其他资料。
"""
html = etree.HTML(html_text)
try:
if not DomUtils.has_child_elements(html):
return
# 缩小一下查找范围,所有的信息都在这个 div 里
user_info = html.xpath('//div[contains(@class, "layui-hares-user-info-right")]')
if not user_info:
return
user_info = user_info[0]
# 用户名
if username := user_info.xpath(
'.//span[contains(text(), "用户名")]/a/span/text()'
):
self.username = username[0].strip()
# 等级
if user_level := user_info.xpath('.//span[contains(text(), "等级")]/b/text()'):
self.user_level = user_level[0].strip()
# 加入日期
if join_date := user_info.xpath('.//span[contains(text(), "注册日期")]/text()'):
join_date = join_date[0].strip().split("\r")[0].removeprefix("注册日期:")
self.join_at = time_tools.normalize_datetime(join_date)
# 上传量
if upload := user_info.xpath('.//span[contains(text(), "上传量")]/text()'):
self.upload = size_tools.parse_size(
upload[0].strip().removeprefix("上传量:")
)
# 下载量
if download := user_info.xpath('.//span[contains(text(), "下载量")]/text()'):
self.download = size_tools.parse_size(
download[0].strip().removeprefix("下载量:")
)
# 分享率
if ratio := user_info.xpath('.//span[contains(text(), "分享率")]/em/text()'):
self.ratio = text_tools.parse_float(ratio[0].strip())
finally:
if html is not None:
del html
def _parse_message_content(self, html_text: str) -> None:
"""
消息正文已在消息列表接口中写入实例,此入口不再解析。
:param html_text: 未使用的消息详情文本
"""
pass
def _parse_user_traffic_info(self, html_text: str) -> None:
"""
解析用户的上传,下载,分享率等信息,已经在 _parse_user_detail_info 内实现,重载防止 abstractmethod 报错。
:param html_text: 当前页面或 API 响应文本
"""
pass