diff --git a/app/modules/indexer/parser/__init__.py b/app/modules/indexer/parser/__init__.py index 5f3245938..a7ace57ea 100644 --- a/app/modules/indexer/parser/__init__.py +++ b/app/modules/indexer/parser/__init__.py @@ -47,6 +47,8 @@ class SiteParserBase(metaclass=ABCMeta): schema = None # 请求模式 cookie/apikey request_mode = "cookie" + # 详情页默认依赖用户 ID;当前会话接口可在配置页面时关闭此前置校验。 + _user_detail_requires_userid = True def __init__(self, site_name: str, url: str, @@ -227,7 +229,7 @@ class SiteParserBase(metaclass=ABCMeta): self._parse_user_base_info(self._index_html) # 解析用户详细信息 if self._user_detail_page: - if not self._has_valid_userid(): + if self._user_detail_requires_userid and not self._has_valid_userid(): logger.warn(f"{self._site_name} 未解析到有效用户 ID,跳过用户详情页请求") else: self._parse_user_detail_info( diff --git a/app/modules/indexer/parser/tnode.py b/app/modules/indexer/parser/tnode.py index 8adb33d5d..5914d3b0d 100644 --- a/app/modules/indexer/parser/tnode.py +++ b/app/modules/indexer/parser/tnode.py @@ -9,16 +9,21 @@ from app.foundation import temporal as time_tools class TNodeSiteUserInfo(SiteParserBase): + """通过当前会话 API 获取 TNode 站点用户资料与做种信息。""" + schema = SiteSchema.TNode def _parse_site_page(self, html_text: str): + """提取 CSRF 令牌,并配置用于取得用户 ID 的当前会话接口。""" html_text = self._prepare_html_text(html_text) # csrf_token = re.search(r'', html_text) if csrf_token: self._addition_headers = {'X-CSRF-TOKEN': csrf_token.group(1)} + # 当前用户接口依赖 Cookie 会话,不需要预先知道用户 ID。 self._user_detail_page = "api/user/getMainInfo" + self._user_detail_requires_userid = False self._torrent_seeding_page = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000" def _parse_logged_in(self, html_text): @@ -31,12 +36,15 @@ class TNodeSiteUserInfo(SiteParserBase): return True def _parse_user_base_info(self, html_text: str): + """首页不提供用户 ID,身份信息需在详情接口返回后补齐。""" self.username = self.userid def _parse_user_traffic_info(self, html_text: str): + """流量信息已由当前用户接口返回,无需单独解析。""" pass def _parse_user_detail_info(self, html_text: str): + """从当前用户接口一次解析身份、资料、流量及未读消息数量。""" try: detail = json.loads(html_text) except json.JSONDecodeError: @@ -98,6 +106,7 @@ class TNodeSiteUserInfo(SiteParserBase): return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + """当前仅支持 API 返回的未读数量,不解析消息链接。""" return None def _parse_message_content(self, html_text): diff --git a/tests/test_tnode_parser.py b/tests/test_tnode_parser.py new file mode 100644 index 000000000..4de98e074 --- /dev/null +++ b/tests/test_tnode_parser.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +"""TNode 用户信息刷新顺序与失败保护回归测试。""" + +import json +from unittest.mock import Mock + +import pytest +from requests import Response + +from app.adapters.network.http import RequestUtils +from app.modules.indexer.parser.tnode import TNodeSiteUserInfo +from app.runtime.config import settings + +SITE_URL = "https://tnode.example/" +PROFILE_URL = f"{SITE_URL}api/user/getMainInfo" +SEEDING_URL = f"{SITE_URL}api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000" +HOME_HTML = '' + + +@pytest.fixture +def user_info() -> dict: + """构造不含真实账号信息的 TNode 用户资料。""" + return { + "id": 42, + "username": "test-user", + "class": {"name": "Power User"}, + "regTime": "2023-01-02 03:04:05", + "upload": 4096, + "download": 1024, + "bonus": 12.5, + "unreadAdmin": 1, + "unreadInbox": 2, + "unreadSystem": 3, + } + + +@pytest.fixture +def pages(user_info: dict) -> dict[str, str]: + """提供完整解析流程所需的离线首页、资料和做种响应。""" + return { + SITE_URL: HOME_HTML, + PROFILE_URL: json.dumps({"status": 200, "data": user_info}), + f"{SITE_URL}index.php": HOME_HTML, + SEEDING_URL: json.dumps({ + "status": 200, + "data": {"torrents": [{"size": 1024, "seeding": 2}, {"size": 2048, "seeding": 3}]}, + }), + } + + +@pytest.fixture +def parser(monkeypatch: pytest.MonkeyPatch, pages: dict[str, str]) -> TNodeSiteUserInfo: + """只替换 HTTP 边界,保留真实解析、请求头合并和会话关闭行为。""" + client = Mock(spec=RequestUtils) + + def get_response(url: str, **_kwargs) -> Response: + """拒绝未声明的请求,并返回离线 HTTP 响应。""" + response = Response() + response.status_code = 200 + response.encoding = "utf-8" + response._content = pages[url].encode("utf-8") + return response + + client.get_res.side_effect = get_response + monkeypatch.setattr(settings, "SITE_MESSAGE", False) + return TNodeSiteUserInfo( + site_name="TNode", + url=SITE_URL, + site_cookie="session=test-cookie", + apikey=None, + token=None, + request_utils=client, + ) + + +def test_tnode_fetches_current_user_before_seeding(parser: TNodeSiteUserInfo) -> None: + """空用户 ID 时仍应获取当前会话资料,之后继续刷新做种数据。""" + assert parser.userid is None + + parser.parse() + + assert parser.userid == 42 + assert parser.username == "test-user" + assert parser.user_level == "Power User" + assert parser.join_at == "2023-01-02 03:04:05" + assert parser.upload == 4096 + assert parser.download == 1024 + assert parser.ratio == 4 + assert parser.bonus == 12.5 + assert parser.message_unread == 6 + assert parser.seeding == 2 + assert parser.seeding_size == 3072 + assert parser.seeding_info == [[2, 1024], [3, 2048]] + + calls = parser._request_utils.get_res.call_args_list + urls = [call.kwargs["url"] for call in calls] + assert urls.count(PROFILE_URL) == 1 + assert urls.index(PROFILE_URL) < urls.index(SEEDING_URL) + for call in calls: + if call.kwargs["url"] in (PROFILE_URL, SEEDING_URL): + assert call.kwargs["headers"]["X-CSRF-TOKEN"] == "test-csrf" + assert call.kwargs["cookies"] == "session=test-cookie" + parser._request_utils.close.assert_called_once_with() + + +@pytest.mark.parametrize("payload", ["", "Login", '{"status": 401}']) +def test_tnode_failed_profile_skips_seeding( + parser: TNodeSiteUserInfo, pages: dict[str, str], payload: str, +) -> None: + """资料为空、非 JSON 或业务失败时,不应继续请求做种接口。""" + pages[PROFILE_URL] = payload + + parser.parse() + + urls = [call.kwargs["url"] for call in parser._request_utils.get_res.call_args_list] + assert urls.count(PROFILE_URL) == 1 + assert SEEDING_URL not in urls + assert parser.userid is None + assert parser.username is None + assert parser.seeding == 0 + parser._request_utils.close.assert_called_once_with() + + +@pytest.mark.parametrize("userid", [None, "", " ", 0, "0"]) +def test_tnode_invalid_userid_skips_seeding( + parser: TNodeSiteUserInfo, pages: dict[str, str], user_info: dict, userid, +) -> None: + """即使资料接口成功,缺失或无效的用户 ID 仍应阻止做种请求。""" + user_info["id"] = userid + pages[PROFILE_URL] = json.dumps({"status": 200, "data": user_info}) + + parser.parse() + + urls = [call.kwargs["url"] for call in parser._request_utils.get_res.call_args_list] + assert urls.count(PROFILE_URL) == 1 + assert SEEDING_URL not in urls + assert not parser._has_valid_userid() + assert parser.seeding == 0 + parser._request_utils.close.assert_called_once_with() + + +def test_tnode_missing_csrf_skips_user_requests(parser: TNodeSiteUserInfo, pages: dict[str, str]) -> None: + """首页缺少 CSRF 时,不应请求未经配置的资料或做种地址。""" + pages[SITE_URL] = "Login" + + parser.parse() + + urls = [call.kwargs["url"] for call in parser._request_utils.get_res.call_args_list] + assert urls == [SITE_URL, f"{SITE_URL}index.php"] + assert parser.userid is None + assert parser.seeding == 0 + parser._request_utils.close.assert_called_once_with()