fix(indexer): identify HDDolby API connection failures (#7164)

This commit is contained in:
jxxghp
2026-10-23 21:04:49 +08:00
parent cc3845621f
commit e1b0d5b4bf
6 changed files with 112 additions and 9 deletions

View File

@@ -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]:
"""

View File

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

View File

@@ -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` 表示后台尚未完成,不能把刷新锁或网络等待

View File

@@ -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():
"""
用户基础信息解析保留用户标识、名称和未读消息数量。

View File

@@ -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 地址及网络代理",
)

View File

@@ -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):
"""