diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py
index b27a08070..0d35edee9 100644
--- a/app/application/torrent/download.py
+++ b/app/application/torrent/download.py
@@ -5,8 +5,9 @@ import time
from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union
-from urllib.parse import parse_qs, unquote, urlsplit
+from urllib.parse import parse_qs, unquote, urljoin, urlsplit
+from lxml import html
from torrentool.api import Torrent
from torrentool.exceptions import TorrentoolException
@@ -158,6 +159,32 @@ def _cache_valid_torrent_content(
return folder_name, file_list
+def _first_download_form(text: str, url: str) -> Optional[Tuple[str, dict[str, Any]]]:
+ """解析下载确认表单,仅向当前下载端点提交有效控件,避免泄露站点凭据。"""
+ try:
+ forms = html.fromstring(text).forms
+ except (ValueError, html.etree.ParserError):
+ return None
+ for form in forms:
+ action = form.get("action", "").strip()
+ target = url if action in ("", "?") else urljoin(url, action)
+ origin, destination = urlsplit(url), urlsplit(target)
+ if (destination[:2] != origin[:2] or destination.path != origin.path
+ or form.get("method", "post").lower() != "post"):
+ continue
+ data = dict(form.form_values())
+ # form_values 不包含用户点击的提交控件,首次下载确认可能依赖该字段。
+ submits = form.xpath(
+ './/input[@name and not(@disabled) and translate(@type, "SUBMIT", "submit")="submit"]'
+ ' | .//button[@name and not(@disabled) and (not(@type) or @type="submit")]'
+ )
+ if submits:
+ data[submits[0].get("name")] = submits[0].get("value", "")
+ if data:
+ return target, data
+ return None
+
+
def _text_filter_failure(torrent: TorrentInfo, params: Dict[str, Any], content: str) -> Optional[Tuple[str, str]]:
"""按原有顺序检查包含、排除、质量等正则,并返回业务原因和运行诊断。"""
for key, label, subject, expected in (
@@ -273,6 +300,7 @@ class TorrentHelper:
return None, url, "", [], "磁力链接"
# 构建 torrent 种子文件的缓存路径
cache_path = Path(HashUtils.md5(url)).with_suffix(".torrent")
+ original_url = url
# 种子二进制固定落 TEMP_PATH/torrents 不进 Redis,下载器与提交链须按同一路由读取
cache_backend = FileCache(local_only=True)
# 读取缓存的种子文件
@@ -324,41 +352,26 @@ class TorrentHelper:
return cache_path, req.content, folder_name, file_list, ""
if "下载种子文件".encode("utf-8") in req.content:
# 首次下载提示页面
- skip_flag = False
- try:
- forms = re.findall(r'
(.*?)', req.text, re.S)
- for form in forms:
- action = form[0]
- if action != "?":
- continue
- action = url
- inputs = re.findall(r'', form[1], re.S)
- if inputs:
- data = {}
- for item in inputs:
- data[item[0]] = item[1]
- # 改写req
- req = http_port.request(
- method="POST", url=action, cookie=cookie, ua=ua,
- referer=referer, proxies=config.proxy if proxy else None,
- data=data,
- )
- if req and req.status_code == 200:
- # 检查是不是种子文件,如果不是抛出异常
- Torrent.from_string(req.content)
- # 跳过成功
- logger.info("触发了站点首次种子下载,已自动跳过")
- skip_flag = True
- elif req is not None:
- logger.warn(f"触发了站点首次种子下载,且无法自动跳过,"
- f"返回码:{req.status_code},错误原因:{req.reason}")
- else:
- logger.warn("触发了站点首次种子下载,且无法自动跳过")
- break
- except Exception as err:
- logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)}")
- if not skip_flag:
+ confirmation = _first_download_form(req.text, url)
+ if confirmation is None:
+ logger.warning("触发首次种子下载,但未找到可安全提交的确认表单")
+ if cache_invalid:
+ self.add_invalid(original_url)
return cache_path, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子"
+ action, data = confirmation
+ req = http_port.request(
+ method="POST", url=action, cookie=cookie, ua=ua,
+ referer=url, proxies=config.proxy if proxy else None, data=data,
+ )
+ if rate_limit_error := _torrent_rate_limit_error(req):
+ return cache_path, None, "", [], rate_limit_error
+ if req is None:
+ return cache_path, None, "", [], "无法打开链接"
+ if req.status_code != 200:
+ return cache_path, None, "", [], f"下载种子出错,状态码:{req.status_code}"
+ if not req.content:
+ return cache_path, None, "", [], "未下载到种子数据"
+ logger.info("已提交站点首次种子下载确认,正在校验种子内容")
# 首次下载提示页面完成后,重新按原始二进制解析 POST 响应。
assert req is not None
if req.content:
@@ -369,13 +382,15 @@ class TorrentHelper:
# 成功拿到种子数据
return cache_path, req.content, folder_name, file_list, ""
# 解析失败时终止,避免把 HTML 继续下沉到下载器或缓存。
+ if cache_invalid:
+ self.add_invalid(original_url)
return cache_path, None, "", [], "种子数据有误,请确认链接是否正确"
elif req is None:
return cache_path, None, "", [], "无法打开链接"
else:
# 把错误的种子记下来,避免重复使用
if cache_invalid and not _is_transient_torrent_status(req.status_code):
- self.add_invalid(url)
+ self.add_invalid(original_url)
return cache_path, None, "", [], f"下载种子出错,状态码:{req.status_code}"
def get_torrent_info(self, torrent_path: Path) -> Tuple[str, List[str]]:
diff --git a/app/chain/download/failure.py b/app/chain/download/failure.py
index d75910a54..830ee1a94 100644
--- a/app/chain/download/failure.py
+++ b/app/chain/download/failure.py
@@ -29,6 +29,8 @@ DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
"无法读取种子文件",
"无法获取下载地址",
"种子下载失败",
+ "种子数据有误",
+ "下载失败黑名单",
"torrent not found",
"not found",
"404",
diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py
index eb3095bff..cf25272b1 100644
--- a/app/chain/download/submission.py
+++ b/app/chain/download/submission.py
@@ -237,6 +237,12 @@ class _DownloadResourceOwner(_DownloadOwnerBase):
return None, "", []
if torrent.enclosure.startswith("magnet:"):
return torrent.enclosure, "", []
+ helper: TorrentHelper = cast(Any, TorrentHelper)()
+ if helper.is_invalid(torrent.enclosure):
+ if on_failure:
+ on_failure("种子已加入下载失败黑名单(24小时)")
+ logger.info(f"{torrent.title} 命中下载失败黑名单,跳过下载")
+ return None, "", []
torrent = self._with_site_credentials(torrent)
result = self._get_torrent_content(torrent)
if result is None:
@@ -250,6 +256,8 @@ class _DownloadResourceOwner(_DownloadOwnerBase):
if not content:
if on_failure:
on_failure(error_msg or "下载种子内容为空")
+ if error_msg and "种子数据有误" in error_msg:
+ helper.add_invalid(torrent.enclosure)
logger.error(f"下载种子文件失败:{torrent.title}")
self.post_message(Message(
channel=channel,
@@ -582,16 +590,15 @@ class DownloadSubmissionOwner(_DownloadResourceOwner):
context.selected_episodes = []
download_error = "下载种子内容为空"
- def remember_rate_limit(error_msg: str) -> None:
- """仅保留本次限流原因,避免准备流程用空内容覆盖站点冷却窗口。"""
+ def remember_failure(error_msg: str) -> None:
+ """保留真实失败原因,使无效种子和限流分别使用正确的资源冷却。"""
nonlocal download_error
- if torrent_rules.rate_limit_cooldown(error_msg) is not None:
- download_error = error_msg
+ download_error = error_msg
if not torrent_file and not torrent_content:
torrent_content, _, _ = self.download_torrent(
torrent, channel=channel, source=source, userid=userid,
- on_failure=remember_rate_limit,
+ on_failure=remember_failure,
)
elif torrent_file:
torrent_content = (
diff --git a/docs/testing.md b/docs/testing.md
index 7c3eebd4c..6d8f1d154 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -109,7 +109,8 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
种子下载重试的离线边界见 `tests/test_torrent_download_retry.py`:普通 GET 在网络失败、
403/5xx、空响应或无法解析的响应后最多重试两次,退避为 1、2 秒。签名链接刷新、换票地址、
-首次下载确认 POST 保持既有流程。HTTP 429 或 JSON `message` / `msg` 明确提示限流时,
+首次下载确认按 HTML 表单语义读取控件,兼容单双引号、属性顺序、空 action 和提交按钮;
+仅向同来源的下载端点提交一次 POST,使用确认页地址作为 Referer。HTTP 429 或 JSON `message` / `msg` 明确提示限流时,
停止短退避重试并在通知中保留原因,不把限流响应交给种子解析器或写入无效地址缓存。
测试替换 HTTP 端口与等待,不访问真实站点。
瞬时状态不会写入 24 小时无效链接缓存;订阅记录的泛化“下载种子内容为空”使用 1 小时冷却,
@@ -117,6 +118,11 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
短期限流至少冷却 1 小时,提示等待更长时按提示延长。准备流程通过单次失败回调把限流原因
交给持久化冷却,避免统一的空内容错误覆盖它;签名链接遇到明确限流也不刷新重试。
此策略不追溯修改已经写入的失败记录。
+种子内容耗尽重试仍无效或首次下载确认仍返回无效内容时,加入现有 24 小时下载失败黑名单;
+下载链再次遇到该地址直接跳过请求和通知。黑名单保存原始资源地址,不使用重定向目标;
+短期签名或换票资源在下载链中屏蔽原始 enclosure,不将临时请求地址写入无效缓存。
+订阅持久化保留具体失败原因,无效种子使用 24 小时资源冷却,避免重启后每小时重复通知。
+网络异常、空响应、403/5xx 和限流仍沿用瞬时重试与流控规则,不因本次修改加入内容无效黑名单。
换票资源的跨批次复用见 `tests/test_download_resource_cache.py`:种子成功解析后,
本地 `torrents` 缓存以站点资源身份、原始换票请求和 Cookie 的摘要保存内容,命中后
diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json
index 846731188..a4b939e4c 100644
--- a/tests/fixtures/architecture/complexity-baseline.json
+++ b/tests/fixtures/architecture/complexity-baseline.json
@@ -38,7 +38,7 @@
"app/application/rss.py:RssHelper._parse_impl": 37,
"app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 45,
"app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 43,
- "app/application/torrent/download.py:TorrentHelper.download_torrent": 25,
+ "app/application/torrent/download.py:TorrentHelper.download_torrent": 24,
"app/application/torrent/download.py:TorrentHelper.match_torrent": 16,
"app/application/transfer/models.py:TransferPlanCheckpoint.__post_init__": 18,
"app/chain/download/existence.py:DownloadExistenceOwner.get_no_exists_info": 23,
@@ -141,7 +141,6 @@
"app/agent/policy/sanitizer.py:sanitize_for_host": 6,
"app/api/endpoints/transfer.py:_execute_manual_transfer": 6,
"app/application/rss.py:RssHelper._parse_impl": 7,
- "app/application/torrent/download.py:TorrentHelper.download_torrent": 6,
"app/chain/scraping.py:ScrapingChain._download_and_save_image": 7,
"app/chain/scraping.py:ScrapingChain.scrape_metadata_event": 6,
"app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 7,
diff --git a/tests/test_torrent_download_retry.py b/tests/test_torrent_download_retry.py
index 29119d452..72587863d 100644
--- a/tests/test_torrent_download_retry.py
+++ b/tests/test_torrent_download_retry.py
@@ -39,8 +39,9 @@ def boundary(monkeypatch):
monkeypatch.setattr(download, "FileCache", Mock(return_value=cache))
sleep = Mock()
monkeypatch.setattr(download.time, "sleep", sleep)
+ monkeypatch.setattr(download, "TTLCache", Mock(return_value={}))
helper = download.TorrentHelper()
- helper.add_invalid = Mock()
+ helper.add_invalid = Mock(wraps=helper.add_invalid)
return helper, port, cache, sleep
@@ -112,10 +113,137 @@ def test_first_download_confirmation_post_is_not_retried(boundary):
page = '下载种子文件'.encode()
port.request.side_effect = [_response(content=page), _response(503)]
- assert helper.download_torrent(URL)[1] is None
+ result = helper.download_torrent(URL)
+ assert result[1] is None
+ assert result[4] == "下载种子出错,状态码:503"
assert [call.kwargs["method"] for call in port.request.call_args_list] == ["GET", "POST"]
sleep.assert_not_called()
+ helper.add_invalid.assert_not_called()
+
+
+def test_first_download_confirmation_preserves_quota_error(boundary):
+ """确认 POST 遇到站点限流时保留原因,不把限流页当作失效种子。"""
+ helper, port, _, _ = boundary
+ page = '下载种子文件'.encode()
+ message = "相同種子當天最多下載10次"
+ port.request.side_effect = [_response(content=page), _response(content=json.dumps({"message": message}).encode())]
+
+ assert helper.download_torrent(URL)[4] == f"站点限流:{message}"
+ assert port.request.call_count == 2
+ helper.add_invalid.assert_not_called()
+
+
+def test_first_download_confirmation_empty_response_is_transient(boundary):
+ """确认请求的空响应不证明种子无效,不能污染资源黑名单。"""
+ helper, port, _, _ = boundary
+ page = '下载种子文件'.encode()
+ port.request.side_effect = [_response(content=page), _response(content=b"")]
+
+ assert helper.download_torrent(URL)[4] == "未下载到种子数据"
+ assert port.request.call_count == 2
+ helper.add_invalid.assert_not_called()
+
+
+@pytest.mark.parametrize("form", [
+ '',
+ "",
+ '',
+ '',
+ '',
+])
+def test_first_download_form_variants_are_confirmed(boundary, form):
+ """按 HTML 表单语义提交确认,兼容属性顺序、引号、实体和提交按钮。"""
+ helper, port, cache, sleep = boundary
+ page = ("下载种子文件" + form).encode()
+ port.request.side_effect = [_response(content=page), _response()]
+
+ assert helper.download_torrent(URL, cookie="session=test")[1] == CONTENT
+
+ assert [call.kwargs["method"] for call in port.request.call_args_list] == ["GET", "POST"]
+ posted = port.request.call_args.kwargs
+ assert posted["url"] == URL
+ assert posted["cookie"] == "session=test"
+ assert posted["referer"] == URL
+ assert posted["data"] == ({"token": "a&b", "confirm": "yes"} if "button" in form else
+ {"token": "a&b"} if "token" in form else {"confirm": "yes"})
+ cache.set.assert_called_once()
+ helper.add_invalid.assert_not_called()
+ sleep.assert_not_called()
+
+
+def test_first_download_form_never_posts_credentials_to_other_origin(boundary):
+ """确认表单不能把站点 Cookie 和隐藏凭据发送到其他来源。"""
+ helper, port, _, _ = boundary
+ page = '下载种子文件'
+ port.request.return_value = _response(content=page.encode())
+
+ assert helper.download_torrent(URL, cookie="session=test")[1] is None
+ port.request.assert_called_once()
+ helper.add_invalid.assert_called_once_with(URL)
+
+
+def test_failed_confirmation_blacklists_original_url_after_redirect(boundary):
+ """确认后仍返回 HTML 时屏蔽原资源地址,刷新候选使用同一个黑名单键。"""
+ helper, port, _, _ = boundary
+ redirect = _response(302)
+ redirect.headers["Location"] = "https://tracker.example/download.php?id=39&confirm=1"
+ page = '下载种子文件'.encode()
+ port.request.side_effect = [redirect, _response(content=page), _response(content=page)]
+
+ assert helper.download_torrent(URL)[1] is None
+ helper.add_invalid.assert_called_once_with(URL)
+ assert helper.is_invalid(URL)
+
+
+def test_chain_blacklist_suppresses_repeated_requests_and_notifications(boundary):
+ """多轮下载同一失败资源只提醒首次,并阻止后续请求和错误通知。"""
+ _, port, _, _ = boundary
+ port.request.return_value = _response(content=b"invalid torrent")
+ chain = object.__new__(DownloadChain)
+ chain.post_message = Mock()
+ torrent = TorrentInfo(site=1, site_name="test", title="Series S01E39", enclosure=URL)
+
+ for _ in range(5):
+ assert chain.download_torrent(torrent)[0] is None
+
+ assert port.request.call_count == 3
+ chain.post_message.assert_called_once()
+
+
+@pytest.mark.parametrize("error", ["种子数据有误,请确认链接是否正确",
+ "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子"])
+def test_invalid_torrent_data_has_resource_cooldown(error):
+ """无效种子及确认失败使用资源级冷却,重启也不会退回每小时通知。"""
+ assert DownloadFailureOwner._download_failure_ttl(error) == 86400
+
+
+def test_invalid_content_preparation_preserves_persisted_resource_cooldown(boundary, monkeypatch):
+ """单任务准备保留无效种子原因,持久化冷却不能再被统一空内容错误覆盖。"""
+ _, port, _, _ = boundary
+ port.request.return_value = _response(content=b"invalid torrent")
+ chain = object.__new__(DownloadChain)
+ chain.post_message = Mock()
+ chain.download_failure_repository = Mock()
+ chain._apply_resource_download_event = Mock(return_value=(None, None))
+ context = Context(
+ torrent_info=TorrentInfo(site=1, site_name="test", title="Series S01E39", enclosure=URL),
+ media_info=MediaInfo(title="Series", type=MediaType.TV), meta_info=MetaInfo("Series S01E39"),
+ )
+ media = Mock()
+ media.supplement_tmdb_info.return_value = context.media_info
+ monkeypatch.setattr(submission, "MediaChain", Mock(return_value=media))
+
+ prepared, error = chain._prepare_download_single(
+ context=context, torrent_file=None, torrent_content=None, episodes=None,
+ channel=None, source="Subscribe|1", downloader=None, save_path=None, userid=None, username=None,
+ )
+
+ assert prepared is None
+ failure = chain.download_failure_repository.record_failure.call_args.args[0]
+ assert failure.error_message == error == "种子数据有误,请确认链接是否正确"
+ assert (datetime.fromisoformat(failure.next_retry_at)
+ - datetime.fromisoformat(failure.failed_at)).total_seconds() == 86400
def test_redirected_get_retries_at_destination(boundary):
@@ -144,13 +272,13 @@ def test_second_retry_can_recover(boundary):
@pytest.mark.parametrize("content", [b"", b"d4:info", b"CF"])
def test_invalid_content_exhaustion_never_caches_response(boundary, content):
- """空或无法解析的响应耗尽预算后仍不能进入种子及无效链接缓存。"""
+ """无效内容耗尽重试后进入黑名单,空响应仍按瞬时失败处理。"""
helper, port, cache, _ = boundary
port.request.return_value = _response(content=content)
assert helper.download_torrent(URL)[1] is None
assert port.request.call_count == 3
cache.set.assert_not_called()
- helper.add_invalid.assert_not_called()
+ assert helper.add_invalid.call_count == (1 if content else 0)
@pytest.mark.parametrize("content", [CONTENT, b"magnet:?xt=urn:btih:abc"])
diff --git a/tests/test_torrent_link_refresh.py b/tests/test_torrent_link_refresh.py
index 338ebaf83..ec43c9625 100644
--- a/tests/test_torrent_link_refresh.py
+++ b/tests/test_torrent_link_refresh.py
@@ -126,6 +126,7 @@ def download_chain(monkeypatch):
chain.post_message = Mock()
chain.search_site_torrents = Mock(return_value=[])
helper = Mock()
+ helper.is_invalid.return_value = False
helper.download_torrent.return_value = FAILED
monkeypatch.setattr(submission, "TorrentHelper", Mock(return_value=helper))
cache = Mock()
@@ -309,7 +310,7 @@ def test_real_torrent_parse_and_cooldown_follow_final_attempt(monkeypatch, final
assert cache.set.call_args.args[1] == content
else:
assert prepared is None
- assert error == "下载种子内容为空"
+ assert error == f"下载种子出错,状态码:{final_status}"
chain._record_download_failure.assert_called_once()
chain.post_message.assert_called_once()
cache.set.assert_not_called()