From 378944aeba0e1e7a4471c27ffc8ab0d69dbed7e7 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 3 Oct 2026 23:47:04 +0800 Subject: [PATCH] fix(indexer): support XML feeds in site search --- app/modules/indexer/spider/__init__.py | 8 +- tests/test_indexer_spider_rss.py | 116 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 tests/test_indexer_spider_rss.py diff --git a/app/modules/indexer/spider/__init__.py b/app/modules/indexer/spider/__init__.py index e3f58f3dc..e5f0f4df0 100644 --- a/app/modules/indexer/spider/__init__.py +++ b/app/modules/indexer/spider/__init__.py @@ -1119,14 +1119,16 @@ class SiteSpider: def parse(self, html_text: str) -> List[dict]: """ - 解析整个页面 + 解析页面或 RSS 索引,统一使用 HTML 选择器语义。 """ if not html_text: self.is_error = True return [] + # 响应已经解码,XML 编码声明会让 lxml 拒绝 Unicode;标签大小写与 Rust HTML 解析保持一致。 + html_text = re.sub(r"^[\s\ufeff]*<\?xml\b[^>]*\?>", "", html_text, count=1) try: - status_doc = PyQuery(html_text) + status_doc = PyQuery(html_text, parser="html") if self.__is_login_or_permission_page(status_doc): self.is_error = True self.error_detail = "返回登录或权限提示页" @@ -1169,7 +1171,7 @@ class SiteSpider: html_doc = None try: # 解析站点文本对象 - html_doc = PyQuery(html_text) + html_doc = PyQuery(html_text, parser="html") # 种子筛选器 torrents_selector = self.list.get('selector', '') rows = html_doc(torrents_selector) diff --git a/tests/test_indexer_spider_rss.py b/tests/test_indexer_spider_rss.py new file mode 100644 index 000000000..5a137b13e --- /dev/null +++ b/tests/test_indexer_spider_rss.py @@ -0,0 +1,116 @@ +"""RSS 索引在 HTTP 解码后应与网页索引共用同步、异步和原生解析入口。""" + +import asyncio +from unittest.mock import AsyncMock, Mock +from urllib.parse import parse_qs, urlparse + +import pytest +from requests import Response + +from app.modules.indexer import spider as spider_module +from app.modules.indexer.spider import SiteSpider + +RSS_BODY = """索引 +示例番剧 01 & SP [1080p] +Sat, 03 Oct 2026 10:20:30 +0800 +详情介绍

]]>
+2026-10-03T10:20:30+08:00 + +
示例番剧 02 [1080p] +2026-10-03T11:20:30+08:00 + +
""" + + +@pytest.fixture +def rss_indexer(): + """构造与资源包约定一致的 RSS 选择器,避免依赖本机资源扩展。""" + return { + "id": "rss-example", + "name": "RSS 示例", + "domain": "https://example.com/", + "search": {"paths": [{"path": "rss.xml"}], "params": {"q": "{keyword}"}}, + "torrents": { + "list": {"selector": "item"}, + "fields": { + "title": {"selector": "title"}, + "download": {"selector": "enclosure", "attribute": "url"}, + "size": {"selector": "enclosure", "attribute": "length"}, + "date_added": { + "selector": "torrent pubdate", + "filters": [ + {"name": "re_search", "args": [r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", 0]}, + {"name": "replace", "args": ["T", " "]}, + ], + }, + }, + }, + } + + +@pytest.fixture(params=["python", "rust"]) +def parser_engine(request, monkeypatch): + """显式选择真实 Python 或 Rust 解析器,原生依赖缺失时只跳过原生路径。""" + if request.param == "python": + monkeypatch.setattr(spider_module.rust_accel, "parse_indexer_torrents", Mock(return_value=None)) + return + native = pytest.importorskip("moviepilot_rust") + + def parse_native(**kwargs): + """直接调用已安装的 Rust 引擎,避免配置开关让原生测试落到 Python。""" + return native.parse_indexer_torrents_fast( + kwargs["html_text"], kwargs["domain"], kwargs["list_config"], + kwargs["fields"], kwargs["category"], kwargs["result_num"], + ) + + monkeypatch.setattr(spider_module.rust_accel, "parse_indexer_torrents", parse_native) + + +@pytest.mark.usefixtures("parser_engine") +@pytest.mark.parametrize("declaration", [ + "", + '', + "\ufeff ", +]) +def test_rss_indexer_preserves_decoded_text_and_item_fields(rss_indexer, declaration): + """已解码内容不再使用 XML 声明二次解码,混合大小写日期和多条种子均完整保留。""" + spider = SiteSpider(rss_indexer) + rows = spider.parse(declaration + RSS_BODY) + + assert not spider.is_error + assert [(row["title"], row["enclosure"], row["size"], row["pubdate"]) for row in rows] == [ + ("示例番剧 01 & SP [1080p]", "https://example.com/one.torrent", 123456789, "2026-10-03 10:20:30"), + ("示例番剧 02 [1080p]", "https://example.com/two.torrent", 987654321, "2026-10-03 11:20:30"), + ] + + +@pytest.mark.usefixtures("parser_engine") +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_rss_indexer_http_paths_decode_xml_response(rss_indexer, monkeypatch, asynchronous): + """真实响应解码之后,同步和异步请求都能解析公开 RSS 并正确编码搜索关键词。""" + response = Response() + response.status_code = 200 + response.headers["Content-Type"] = "application/rss+xml; charset=utf-8" + response.encoding = "utf-8" + response._content = ('' + RSS_BODY).encode("utf-8") + requester = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response) + transport = spider_module.AsyncRequestUtils if asynchronous else spider_module.RequestUtils + monkeypatch.setattr(transport, "get_res", requester) + keyword = "示例 & SP" + spider = SiteSpider(rss_indexer, keyword=keyword) + + rows = asyncio.run(spider.async_get_torrents()) if asynchronous else spider.get_torrents() + + assert len(rows) == 2 + assert rows[0]["title"] == "示例番剧 01 & SP [1080p]" + assert not spider.is_error + query = parse_qs(urlparse(requester.call_args.args[0]).query) + assert query["q"] == [keyword] + + +@pytest.mark.usefixtures("parser_engine") +def test_empty_rss_channel_is_valid(rss_indexer): + """公开接口没有匹配资源时,空 channel 应作为成功的空结果。""" + spider = SiteSpider(rss_indexer) + assert spider.parse('') == [] + assert not spider.is_error