diff --git a/app/modules/indexer/parser/nexus_php.py b/app/modules/indexer/parser/nexus_php.py
index c1ab34b7a..480040219 100644
--- a/app/modules/indexer/parser/nexus_php.py
+++ b/app/modules/indexer/parser/nexus_php.py
@@ -22,11 +22,12 @@ class NexusPhpSiteUserInfo(SiteParserBase):
def _parse_site_page(self, html_text: str):
html_text = self._prepare_html_text(html_text)
- user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text)
- if user_detail and user_detail.group().strip():
+ # NexusPHP v1.10+ 使用 UUID 标识用户,详情链接参数为 uuid
+ user_detail = re.search(r"userdetails\.php\?(?:id|uuid)=([0-9A-Za-z_-]+)", html_text)
+ if user_detail and user_detail.group(1).strip():
self._user_detail_page = user_detail.group().strip().lstrip('/')
- self.userid = user_detail.group(1)
- self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding"
+ self.userid = user_detail.group(1).strip()
+ self._torrent_seeding_page = self._build_torrent_seeding_page()
else:
user_detail = re.search(r"(userdetails)", html_text)
if user_detail and user_detail.group().strip():
@@ -34,6 +35,29 @@ class NexusPhpSiteUserInfo(SiteParserBase):
self.userid = None
self._torrent_seeding_page = None
+ @staticmethod
+ def _is_uuid(user_id: Optional[str]) -> bool:
+ """
+ 判断用户标识是否为 UUID 形式。
+
+ 使用 UUID 的 NexusPHP 站点将做种列表接口参数改名为 useruuid。
+ """
+ return bool(re.fullmatch(
+ r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}",
+ str(user_id or "").strip()
+ ))
+
+ def _build_torrent_seeding_page(self) -> str:
+ """
+ 构造做种列表接口地址。
+
+ :return: 做种列表地址,无有效用户标识时返回空字符串
+ """
+ if not self._has_valid_userid():
+ return ""
+ user_id_param = "useruuid" if self._is_uuid(self.userid) else "userid"
+ return f"getusertorrentlistajax.php?{user_id_param}={self.userid}&type=seeding"
+
def _parse_message_unread(self, html_text):
"""
解析未读短消息数量
@@ -123,21 +147,33 @@ class NexusPhpSiteUserInfo(SiteParserBase):
has_ucoin, self.bonus = self._parse_ucoin(html)
if has_ucoin:
return
- tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html is not None else None
- if tmps:
- bonus_text = str(tmps[0]).strip()
- bonus_match = re.search(r"([\d,.]+)", bonus_text)
+ bonus_links = html.xpath('//a[contains(@href,"mybonus")]') if html is not None else []
+ for bonus_link in bonus_links:
+ # 魔力值直接写在链接文本中
+ for bonus_text in bonus_link.xpath('text()'):
+ bonus_match = re.search(r"([\d,.]+)", str(bonus_text).strip())
+ if bonus_match and bonus_match.group(1).strip():
+ self.bonus = text_tools.parse_float(bonus_match.group(1))
+ return
+ # 数值嵌在链接内部的元素中(憨憨:13,853,582
)
+ bonus_text = bonus_link.xpath("string(.)").strip()
+ bonus_match = re.fullmatch(r"[\s\[((【]*([\d][\d,.]*)[\s\]))】]*", bonus_text)
+ if bonus_match and bonus_match.group(1).strip():
+ self.bonus = text_tools.parse_float(bonus_match.group(1))
+ return
+ # PTT-NP 等站点的链接文本只有“使用&说明”,数值紧跟在链接之后
+ bonus_match = re.search(r"[::]\s*([\d,.]+)", bonus_link.tail or "")
if bonus_match and bonus_match.group(1).strip():
self.bonus = text_tools.parse_float(bonus_match.group(1))
return
- # PTT-NP 的 mybonus 链接文本只有“使用&说明”,数值位于同一容器的文本中。
- bonus_links = html.xpath('//a[contains(@href,"mybonus")]') if html is not None else []
for bonus_link in bonus_links:
- bonus_containers = bonus_link.xpath('ancestor::*[contains(., "魔力值")][1]')
- if not bonus_containers:
- bonus_containers = bonus_link.xpath('parent::*')
+ # 兜底解析链接附近容器中的魔力值,容器必须临近且文本紧凑,
+ # 避免把公告、新闻等大段文本里的无关数字当作魔力值
+ bonus_containers = bonus_link.xpath('ancestor::*[position() <= 4][contains(., "魔力值")]')
for bonus_container in bonus_containers:
bonus_text = bonus_container.xpath("string(.)")
+ if len(bonus_text) > 200:
+ continue
bonus_match = re.search(r"魔力值.*?[::]\s*([\d,.]+)", bonus_text, flags=re.S)
if bonus_match and bonus_match.group(1).strip():
self.bonus = text_tools.parse_float(bonus_match.group(1))
@@ -264,8 +300,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
return next_page
- @staticmethod
- def _fixup_next_page_url(next_page: str, userid: Optional[str]) -> Optional[str]:
+ def _fixup_next_page_url(self, next_page: str, userid: Optional[str]) -> Optional[str]:
"""
修正做种下一页地址,无法补齐用户 ID 时停止翻页。
@@ -276,12 +311,12 @@ class NexusPhpSiteUserInfo(SiteParserBase):
parsed_url = urlsplit(next_page)
query_params = dict(parse_qsl(parsed_url.query, keep_blank_values=True))
- if query_params.get("userid"):
+ if any(query_params.get(key) for key in ("userid", "useruuid")):
return next_page
if not userid:
return None
- query_params["userid"] = userid
+ query_params["useruuid" if self._is_uuid(userid) else "userid"] = userid
query_params.setdefault("type", "seeding")
return urlunsplit(parsed_url._replace(query=urlencode(query_params)))
@@ -365,10 +400,10 @@ class NexusPhpSiteUserInfo(SiteParserBase):
'and contains(@href,"seeding")]/@href')
csrf_text = html.xpath('//meta[@name="x-csrf"]/@content')
if not self._torrent_seeding_page and seeding_url_text:
- user_js = re.search(r"javascript: getusertorrentlistajax\(\s*'(\d+)", seeding_url_text[0])
+ user_js = re.search(r"javascript:\s*getusertorrentlistajax\(\s*'([^']+)'", seeding_url_text[0])
if user_js and user_js.group(1).strip():
self.userid = user_js.group(1).strip()
- self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding"
+ self._torrent_seeding_page = self._build_torrent_seeding_page()
elif seeding_url_text and csrf_text:
if csrf_text[0].strip():
self._torrent_seeding_page \
diff --git a/tests/test_nexus_php_parser.py b/tests/test_nexus_php_parser.py
index 39bf3f233..fcdd635b7 100644
--- a/tests/test_nexus_php_parser.py
+++ b/tests/test_nexus_php_parser.py
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from urllib.parse import parse_qs, urlsplit
+from lxml import etree
+
from app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo
@@ -108,3 +110,119 @@ def test_nexus_php_bonus_fallback_ignores_unrelated_script_numbers():
)
assert parser.bonus == 0
+
+
+def test_nexus_php_bonus_reads_value_nested_in_mybonus_link():
+ """
+ 憨憨等站点将魔力值嵌在 mybonus 链接内的元素中,且页面公告里存在无关的“魔力值…:数字”文本。
+ """
+ parser = _build_parser()
+ html_text = """
+
+
+
+ 视工作情况会给予魔力值、邀请码等奖励。具体处理措施如下:
+ 1、补偿所有用户100000憨豆;(已经发放完成)
+
+
+
+
+ """
+
+ parser._parse_user_traffic_info(html_text)
+
+ assert parser.bonus == 13853582.0
+
+
+def test_nexus_php_uuid_user_details_link_parses_uuid_user():
+ """
+ NexusPHP v1.10+ 使用 UUID 标识用户的站点应解析用户详情链接,并使用 useruuid 请求做种列表。
+ """
+ parser = _build_parser()
+ html_text = """
+
+
+
+
+
+ """
+
+ parser._parse_site_page(html_text)
+ parser._parse_user_base_info(html_text)
+
+ assert parser.userid == "53d25816-f7b7-4372-b58d-6f445acfe090"
+ assert parser._user_detail_page == "userdetails.php?uuid=53d25816-f7b7-4372-b58d-6f445acfe090"
+ assert parser._torrent_seeding_page == (
+ "getusertorrentlistajax.php?useruuid=53d25816-f7b7-4372-b58d-6f445acfe090&type=seeding"
+ )
+ assert parser.username == "Andreas"
+ assert parser.bonus == 105.5
+
+
+def test_nexus_php_numeric_user_details_link_keeps_userid_param():
+ """
+ 传统数字用户 ID 的站点应继续使用 userid 请求做种列表。
+ """
+ parser = _build_parser()
+ html_text = 'Opportunity'
+
+ parser._parse_site_page(html_text)
+
+ assert parser.userid == "67537"
+ assert parser._torrent_seeding_page == "getusertorrentlistajax.php?userid=67537&type=seeding"
+
+
+def test_nexus_php_uuid_seeding_js_link_uses_useruuid():
+ """
+ 用户详情页的做种 JS 调用携带 UUID 时,应改用 useruuid 参数请求做种列表。
+ """
+ parser = _build_parser()
+ # 首页未解析到用户链接时,做种地址为空,需要从用户详情页的 JS 调用中补齐
+ parser._torrent_seeding_page = None
+ html_text = """
+
+
+ [显示/隐藏]
+
+
+ """
+
+ parser._fixup_torrent_seeding_page(etree.HTML(html_text))
+
+ assert parser.userid == "53d25816-f7b7-4372-b58d-6f445acfe090"
+ assert parser._torrent_seeding_page == (
+ "getusertorrentlistajax.php?useruuid=53d25816-f7b7-4372-b58d-6f445acfe090&type=seeding"
+ )
+
+
+def test_nexus_php_uuid_seeding_next_page_uses_useruuid():
+ """
+ UUID 站点的做种下一页地址缺少用户标识时,应补齐 useruuid 而不是 userid。
+ """
+ parser = _build_parser()
+ parser.userid = "53d25816-f7b7-4372-b58d-6f445acfe090"
+ html_text = """
+
+
+
+ 下一页
+
+
+ """
+
+ next_page = parser._parse_user_torrent_seeding_info(html_text, multi_page=True)
+ query_params = parse_qs(urlsplit(next_page).query)
+
+ assert query_params["useruuid"] == ["53d25816-f7b7-4372-b58d-6f445acfe090"]
+ assert "userid" not in query_params
+ assert query_params["type"] == ["seeding"]