From e1b0d5b4bf86846dc644d1bc213cd703b5fa89be Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 23 Oct 2026 21:04:49 +0800 Subject: [PATCH] fix(indexer): identify HDDolby API connection failures (#7164) --- app/chain/site.py | 6 ++-- app/modules/indexer/spider/hddolby.py | 6 +++- docs/testing.md | 6 ++++ tests/test_hddolby_parser.py | 27 +++++++++++++-- tests/test_hddolby_spider.py | 29 +++++++++++++++++ tests/test_site_connectivity.py | 47 +++++++++++++++++++++++++-- 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/app/chain/site.py b/app/chain/site.py index 6cf2b46bc..0fa10ba6c 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -662,6 +662,7 @@ class SiteChain(InteractionChainMixin, ChainBase): 通过 HDDolby 用户 API 验证 API Key,与搜索和资料解析使用相同资源 API 基础地址。 未配置 api_url 时沿用旧站的 api 子域,兼容存量资源和自定义索引。 + API 失败提示仅描述接口主机,不据此断言网页不可访问,也不以网页成功掩盖 API 故障。 :param site: HDDolby 协议站点配置 :return: 是否可用及认证或连接失败原因 @@ -669,6 +670,7 @@ class SiteChain(InteractionChainMixin, ChainBase): domain = site.domain or site_rules.extract_domain(site.url) indexer = SitesHelper().get_indexer(domain) or {} api_url = str(indexer.get("api_url") or f"https://api.{domain}/api/v1").rstrip("/") + api_host = urlparse(api_url).hostname headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", @@ -682,13 +684,13 @@ class SiteChain(InteractionChainMixin, ChainBase): timeout=site.timeout or 15, ) as res: if res is None: - return False, "无法打开网站!" + return False, f"HDDolby API 主机 {api_host} 无法连接,请检查 API 地址及网络代理" if res.status_code == 200: user_info = res.json() if user_info and user_info.get("status") == 0: return True, "连接成功" return False, "APIKEY已过期" - return False, f"错误:{res.status_code} {res.reason}" + return False, f"HDDolby API 主机 {api_host} 返回 HTTP {res.status_code} {res.reason}" def __rousi_test(self, site: SiteSnapshot) -> Tuple[bool, str]: """ diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index d9b189a58..6fc53554a 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -3,6 +3,7 @@ """ from typing import Any, List, Optional, Tuple +from urllib.parse import urlparse from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.application.configuration import get_configured_system_config @@ -202,6 +203,8 @@ class HddolbySpider: def __process_response(self, res: Any) -> Tuple[bool, List[dict[str, Any]]]: """ 统一判定搜索响应状态、业务错误并投影 HDDolby 结果。 + + 连接错误只记录实际 API 主机,避免把接口故障误报为网页不可达或输出 URL 中的凭据。 """ if res and res.status_code == 200: result = res.json() @@ -212,7 +215,8 @@ class HddolbySpider: if res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] - logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") + api_host = urlparse(self._searchurl or "").hostname + logger.warn(f"{self._name} 搜索失败,HDDolby API 主机 {api_host} 无法连接,请检查 API 地址及网络代理") return True, [] def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0, diff --git a/docs/testing.md b/docs/testing.md index 2a9d1c66b..24c969769 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -82,6 +82,12 @@ focused 测试验证,不要求启动产品全套测试。 ## 外部依赖:一律 mock,零真实网络 +高清杜比换域回归由 `tests/test_hddolby_spider.py`、`tests/test_hddolby_parser.py` 和 +`tests/test_site_connectivity.py` 验证:网页使用 `www.uhddolby.com` 时,搜索、用户资料和 +做种统计仍可按资源的 `api_url` 访问 `api.hddolby.com`,并保留站点代理和认证配置。 +API 连接失败或返回 HTTP 错误时,连通性提示指出实际接口主机;网页解析成功不能代替 +API 验证。错误提示和搜索告警不得输出地址中的账号、密码或查询令牌。测试不使用真实站点凭据。 + 社区勋章使用 `tests/test_community_medal_background.py` 验证启动主动预热、无人访问时的六小时 更新、失败后的自动重试及 TaskRegistry 关闭。外部下载或远端开关被屏障阻塞时,查询必须在 屏障释放前返回当前快照;冷启动通过 `refreshing` 表示后台尚未完成,不能把刷新锁或网络等待 diff --git a/tests/test_hddolby_parser.py b/tests/test_hddolby_parser.py index d98d7fc9d..b9e786942 100644 --- a/tests/test_hddolby_parser.py +++ b/tests/test_hddolby_parser.py @@ -27,13 +27,13 @@ USER_DATA_JSON = """ """ -def _build_parser(site_cookie: str = "", **kwargs) -> HDDolbySiteUserInfo: +def _build_parser(site_cookie: str = "", url: str = "https://www.hddolby.com/", **kwargs) -> HDDolbySiteUserInfo: """ - 构造隔离 HTTP 会话、允许覆盖 API 地址的杜比用户数据解析器。 + 构造隔离 HTTP 会话、允许分别指定网页域名和 API 地址的杜比用户数据解析器。 """ return HDDolbySiteUserInfo( site_name="高清杜比", - url="https://www.hddolby.com/", + url=url, site_cookie=site_cookie, apikey="test-api-key", token=None, @@ -49,6 +49,8 @@ def _build_parser(site_cookie: str = "", **kwargs) -> HDDolbySiteUserInfo: ("", "https://api.hddolby.com/api/v1/"), ("https://www.uhddolby.com/api/v1", "https://www.uhddolby.com/api/v1/"), ("https://www.uhddolby.com/api/v1/", "https://www.uhddolby.com/api/v1/"), + ("https://api.hddolby.com/api/v1", "https://api.hddolby.com/api/v1/"), + ("https://api.hddolby.com/api/v1/", "https://api.hddolby.com/api/v1/"), ("https://custom-api.test:8443/dolby/v1/", "https://custom-api.test:8443/dolby/v1/"), ], ) @@ -77,6 +79,25 @@ def test_userdata_uses_resource_api_url_for_profile_and_seeding(monkeypatch, api parser._request_utils.close.assert_called_once_with() +def test_migrated_page_domain_keeps_old_api_for_userdata(monkeypatch): + """ + 网页迁移后仍从旧域 API 读取资料和做种统计,不修改网页地址或代理选择。 + """ + proxy = {"https": "socks5h://proxy.test:1080"} + parser = _build_parser(url="https://www.uhddolby.com/", api_url="https://api.hddolby.com/api/v1", proxy=proxy) + get_page = Mock(side_effect=[USER_DATA_JSON, '{"status": 0, "data": []}']) + monkeypatch.setattr(parser, "_get_page_content", get_page) + + parser.parse() + + assert [call.kwargs["url"] for call in get_page.call_args_list] == [ + "https://api.hddolby.com/api/v1/user/data", "https://api.hddolby.com/api/v1/user/peers", + ] + assert parser.userid == "41088" + assert parser._site_url == "https://www.uhddolby.com/" + assert parser._proxy == proxy + + def test_hddolby_parse_user_base_info_reads_unread_count(): """ 用户基础信息解析保留用户标识、名称和未读消息数量。 diff --git a/tests/test_hddolby_spider.py b/tests/test_hddolby_spider.py index 9f654e457..304878830 100644 --- a/tests/test_hddolby_spider.py +++ b/tests/test_hddolby_spider.py @@ -86,6 +86,10 @@ def test_parse_result_marks_music_torrents(hddolby_spider): "https://www.uhddolby.com/api/v1/torrent/search"), ("https://www.uhddolby.com/", "https://www.uhddolby.com/api/v1/", "https://www.uhddolby.com/api/v1/torrent/search"), + ("https://www.uhddolby.com/", "https://api.hddolby.com/api/v1", + "https://api.hddolby.com/api/v1/torrent/search"), + ("https://www.uhddolby.com/", "https://api.hddolby.com/api/v1/", + "https://api.hddolby.com/api/v1/torrent/search"), ("https://www.uhddolby.com/", "https://custom-api.test:8443/dolby/v1/", "https://custom-api.test:8443/dolby/v1/torrent/search"), ], @@ -124,3 +128,28 @@ def test_search_honors_resource_api_url( assert request.call_args.kwargs["headers"]["x-api-key"] == "dolby-secret" assert torrents[0]["page_url"] == f"{domain}details.php?id=42&hit=1" assert torrents[0]["enclosure"] == f"{domain}download.php?id=42&downhash=test-hash" + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_search_connection_failure_logs_api_host_without_credentials(monkeypatch, asynchronous): + """ + 同步和异步搜索的连接告警指出 API 主机,避免误报网页故障并隐藏地址中的凭据。 + """ + monkeypatch.setattr(hddolby_module, "get_configured_system_config", lambda: None) + spider = HddolbySpider({ + **_build_indexer(), + "domain": "https://www.uhddolby.com/", + "api_url": "https://user:secret@api.hddolby.com/api/v1?token=secret", + }) + request = Mock() + request.return_value.post_res = AsyncMock(return_value=None) if asynchronous else Mock(return_value=None) + monkeypatch.setattr(hddolby_module, "AsyncRequestUtils" if asynchronous else "RequestUtils", request) + warning = Mock() + monkeypatch.setattr(hddolby_module.logger, "warn", warning) + + result = asyncio.run(spider.async_search("Movie")) if asynchronous else spider.search("Movie") + + assert result == (True, []) + warning.assert_called_once_with( + "高清杜比 搜索失败,HDDolby API 主机 api.hddolby.com 无法连接,请检查 API 地址及网络代理", + ) diff --git a/tests/test_site_connectivity.py b/tests/test_site_connectivity.py index 5459ab4f6..045e3509f 100644 --- a/tests/test_site_connectivity.py +++ b/tests/test_site_connectivity.py @@ -238,10 +238,10 @@ def test_api_probe_honors_resource_endpoint(monkeypatch, protocol, host_key, exp @pytest.mark.parametrize("domain", ["hddolby.com", "uhddolby.com"]) -@pytest.mark.parametrize("api_url", ["https://www.uhddolby.com/api/v1", "https://www.uhddolby.com/api/v1/"]) +@pytest.mark.parametrize("api_url", ["https://api.hddolby.com/api/v1", "https://api.hddolby.com/api/v1/"]) def test_hddolby_probe_uses_migrated_resource_for_both_domains(monkeypatch, domain, api_url): """ - 旧域专用路由和新域协议路由均按资源配置探测主站 API,并保留认证与连接参数。 + 旧域专用路由和新域协议路由均按资源访问旧域 API,并保留认证与连接参数。 """ chain = SiteChain() site = SiteSnapshot( @@ -262,13 +262,54 @@ def test_hddolby_probe_uses_migrated_resource_for_both_domains(monkeypatch, doma indexer.assert_called_with(domain) request = open_response.call_args.kwargs assert request["method"] == "GET" - assert request["url"] == "https://www.uhddolby.com/api/v1/user/data" + assert request["url"] == "https://api.hddolby.com/api/v1/user/data" assert request["headers"]["x-api-key"] == "test-key" assert request["proxies"] == (chain.runtime_config.proxy or {}) assert request["timeout"] == 25 context.__exit__.assert_called_once_with(None, None, None) +@pytest.mark.parametrize( + ("api_url", "host"), + [ + (None, "api.uhddolby.com"), + ("https://api.hddolby.com/api/v1", "api.hddolby.com"), + ("https://user:secret@custom-api.test:8443/api?token=secret", "custom-api.test"), + ], +) +@pytest.mark.parametrize("status_code", [None, 405]) +def test_hddolby_api_failure_identifies_host_without_page_fallback(monkeypatch, api_url, host, status_code): + """ + 接口失败提示真实 API 主机且不泄露凭据,网页可用也不能掩盖 API 搜索不可用。 + """ + chain = SiteChain() + site = SiteSnapshot( + id=1, name="高清杜比", domain="uhddolby.com", url="https://www.uhddolby.com/", apikey="test-key", + ) + chain.site_repository = Mock() + chain.site_repository.get_by_domain.return_value = site + indexer = Mock(return_value={"schema": "HDDolby", "api_url": api_url}) + monkeypatch.setattr("app.chain.site.SitesHelper", Mock(return_value=Mock(get_indexer=indexer))) + context = MagicMock() + context.__enter__.return_value = None if status_code is None else Mock(status_code=405, reason="Not Allowed") + monkeypatch.setattr("app.chain.site._open_site_response", Mock(return_value=context)) + generic_test = Mock(return_value=(True, "连接成功")) + monkeypatch.setattr(SiteChain, "_SiteChain__test", generic_test) + + state, message = chain.test(site.url) + + assert state is False + if status_code is None: + assert message == f"HDDolby API 主机 {host} 无法连接,请检查 API 地址及网络代理" + else: + assert message == f"HDDolby API 主机 {host} 返回 HTTP 405 Not Allowed" + assert "secret" not in message + assert "无法打开网站" not in message + generic_test.assert_not_called() + chain.site_repository.fail.assert_called_once_with("uhddolby.com") + context.__exit__.assert_called_once_with(None, None, None) + + @pytest.mark.parametrize("apikey", [None, "test-auth-key"]) def test_yema_registered_domain_prefers_authkey_and_preserves_legacy_cookie(monkeypatch, apikey): """