fix(indexer): fetch TNode current user before requiring an id (#6895)

This commit is contained in:
jxxghp
2026-10-06 07:40:06 +08:00
parent a8f71f32a8
commit 43edef140f
3 changed files with 164 additions and 1 deletions

View File

@@ -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(

View File

@@ -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)
# <meta name="x-csrf-token" content="fd169876a7b4846f3a7a16fcd5cccf8d">
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', 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):

152
tests/test_tnode_parser.py Normal file
View File

@@ -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 = '<html><head><meta name="x-csrf-token" content="test-csrf"></head></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", ["", "<html>Login</html>", '{"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] = "<html>Login</html>"
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()