diff --git a/app/agent/tools/impl/_torrent_search_utils.py b/app/agent/tools/impl/_torrent_search_utils.py index b5a8b3bd..f2e65a86 100644 --- a/app/agent/tools/impl/_torrent_search_utils.py +++ b/app/agent/tools/impl/_torrent_search_utils.py @@ -127,8 +127,19 @@ def filter_contexts(items: List[Context], return filtered_items -def simplify_search_result(context: Context, index: int) -> dict: - """精简单条搜索结果""" +def simplify_search_result( + context: Context, + index: int, + include_description: bool = False, +) -> dict: + """ + 精简单条搜索结果 + + :param context: 搜索结果上下文 + :param index: 搜索结果在原始缓存中的序号 + :param include_description: 是否返回种子简介 + :return: 精简后的搜索结果 + """ simplified = {} torrent_info = context.torrent_info meta_info = context.meta_info @@ -147,6 +158,8 @@ def simplify_search_result(context: Context, index: int) -> dict: "freedate_diff": torrent_info.freedate_diff, "pubdate": torrent_info.pubdate, } + if include_description: + simplified["torrent_info"]["description"] = torrent_info.description if media_info: simplified["media_info"] = { diff --git a/app/agent/tools/impl/get_search_results.py b/app/agent/tools/impl/get_search_results.py index d045346f..457ed6cb 100644 --- a/app/agent/tools/impl/get_search_results.py +++ b/app/agent/tools/impl/get_search_results.py @@ -34,6 +34,14 @@ class GetSearchResultsInput(BaseModel): None, description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')", ) + content_pattern: Optional[str] = Field( + None, + description="Regular expression pattern to filter torrent titles, descriptions, and labels (e.g., '特效字幕|国语|DIY')", + ) + include_description: Optional[bool] = Field( + False, + description="Whether to include torrent descriptions in returned results", + ) show_filter_options: Optional[bool] = Field( False, description="Whether to return only optional filter options for re-checking available conditions", @@ -45,6 +53,8 @@ class GetSearchResultsInput(BaseModel): class GetSearchResultsTool(MoviePilotTool): + """获取并筛选最近一次种子搜索结果""" + name: str = "get_search_results" tags: list[str] = [ ToolTag.Read, @@ -54,6 +64,7 @@ class GetSearchResultsTool(MoviePilotTool): args_schema: Type[BaseModel] = GetSearchResultsInput def get_tool_message(self, **kwargs) -> Optional[str]: + """返回工具执行提示""" return "获取搜索结果" async def run( @@ -66,13 +77,33 @@ class GetSearchResultsTool(MoviePilotTool): resolution: Optional[List[str]] = None, release_group: Optional[List[str]] = None, title_pattern: Optional[str] = None, + content_pattern: Optional[str] = None, + include_description: bool = False, show_filter_options: bool = False, page: Optional[int] = 1, **kwargs, ) -> str: + """ + 获取并筛选最近一次种子搜索结果 + + :param site: 站点名称筛选项 + :param season: 季集筛选项 + :param free_state: 促销状态筛选项 + :param video_code: 视频编码筛选项 + :param edition: 制作版本筛选项 + :param resolution: 分辨率筛选项 + :param release_group: 发布组筛选项 + :param title_pattern: 仅匹配种子标题的正则表达式 + :param content_pattern: 匹配种子标题、简介和标签的正则表达式 + :param include_description: 是否在结果中返回种子简介 + :param show_filter_options: 是否只返回可用筛选项 + :param page: 分页页码 + :param kwargs: 工具框架附加参数 + :return: JSON 格式的搜索结果或错误提示 + """ page = max(1, page or 1) logger.info( - f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, show_filter_options={show_filter_options}, page={page}" + f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}" ) try: @@ -87,14 +118,22 @@ class GetSearchResultsTool(MoviePilotTool): } return json.dumps(payload, ensure_ascii=False, indent=2) - regex_pattern = None + title_regex_pattern = None if title_pattern: try: - regex_pattern = re.compile(title_pattern, re.IGNORECASE) + title_regex_pattern = re.compile(title_pattern, re.IGNORECASE) except re.error as e: logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}") return f"正则表达式格式错误: {str(e)}" + content_regex_pattern = None + if content_pattern: + try: + content_regex_pattern = re.compile(content_pattern, re.IGNORECASE) + except re.error as e: + logger.warning(f"正则表达式编译失败: {content_pattern}, 错误: {e}") + return f"正则表达式格式错误: {str(e)}" + filtered_items = filter_contexts( items=items, site=site, @@ -105,14 +144,29 @@ class GetSearchResultsTool(MoviePilotTool): resolution=resolution, release_group=release_group, ) - if regex_pattern: + if title_regex_pattern: filtered_items = [ item for item in filtered_items if item.torrent_info and item.torrent_info.title - and regex_pattern.search(item.torrent_info.title) + and title_regex_pattern.search(item.torrent_info.title) ] + if content_regex_pattern: + content_filtered_items = [] + for item in filtered_items: + torrent_info = item.torrent_info + if not torrent_info: + continue + content_values = [torrent_info.title, torrent_info.description] + content_values.extend(torrent_info.labels or []) + if any( + content_regex_pattern.search(str(value)) + for value in content_values + if value + ): + content_filtered_items.append(item) + filtered_items = content_filtered_items if not filtered_items: return "没有符合筛选条件的搜索结果,请调整筛选条件" @@ -135,7 +189,11 @@ class GetSearchResultsTool(MoviePilotTool): return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {(total_count + page_size - 1) // page_size} 页。" results = [ - simplify_search_result(item, index) + simplify_search_result( + item, + index, + include_description=include_description, + ) for item, index in zip(page_items, page_indices) ] total_pages = (total_count + page_size - 1) // page_size diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0e02599e..135755ff 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -214,6 +214,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。 +`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。 + #### Agent 自主定时任务工具 以下工具用于管理会在指定时间重新唤醒 Agent 的持久化任务,均为管理员级工具: diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index 9a5de5dd..e99e5dfc 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -99,6 +99,9 @@ Filter values must come from the `filter_options` returned by `search_torrents` Fetch results with selected filters: `moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'` +To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched: +`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true` + If empty, tell the user which filter to relax and ask before retrying. #### 4. Present results as a numbered list diff --git a/tests/test_agent_get_search_results_tool.py b/tests/test_agent_get_search_results_tool.py new file mode 100644 index 00000000..146d931f --- /dev/null +++ b/tests/test_agent_get_search_results_tool.py @@ -0,0 +1,100 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from app.agent.tools.impl._torrent_search_utils import simplify_search_result +from app.agent.tools.impl.get_search_results import GetSearchResultsTool +from app.core.context import Context, TorrentInfo + + +def _build_context( + title: str, + *, + description: str = None, + labels: list = None, + index: int = 1, +) -> Context: + """构造种子搜索结果上下文。""" + return Context( + torrent_info=TorrentInfo( + title=title, + description=description, + labels=labels or [], + enclosure=f"https://example.com/download/{index}", + size=1024, + seeders=index, + site_name="测试站点", + ) + ) + + +def _run_tool(items: list[Context], **kwargs) -> str: + """使用指定缓存结果运行搜索结果工具。""" + search_chain = MagicMock() + search_chain.async_last_search_results = AsyncMock(return_value=items) + with patch( + "app.agent.tools.impl.get_search_results.SearchChain", + return_value=search_chain, + ): + return asyncio.run( + GetSearchResultsTool(session_id="session-1", user_id="10001").run( + **kwargs + ) + ) + + +def test_simplify_search_result_only_includes_description_when_requested(): + """精简结果应按参数控制简介输出,避免默认增加上下文长度。""" + context = _build_context("Movie.2026.1080p", description="简繁特效字幕") + + default_result = simplify_search_result(context, 1) + detailed_result = simplify_search_result(context, 1, include_description=True) + + assert "description" not in default_result["torrent_info"] + assert detailed_result["torrent_info"]["description"] == "简繁特效字幕" + + +def test_content_pattern_matches_title_description_and_labels(): + """内容正则应联合匹配标题、简介和标签,并可返回命中的简介。""" + items = [ + _build_context("Movie.Special.Effect.2026", description="普通字幕", index=1), + _build_context("Movie.2026.1080p", description="简繁特效字幕", index=2), + _build_context("Movie.2026.2160p", description="国语音轨", labels=["官译"], index=3), + _build_context("Movie.2026.WEB-DL", description="英文字幕", index=4), + ] + + result = _run_tool( + items, + content_pattern="Special.Effect|特效字幕|官译", + include_description=True, + ) + payload = json.loads(result) + + assert payload["total_count"] == 3 + assert [item["torrent_info"]["description"] for item in payload["results"]] == [ + "普通字幕", + "简繁特效字幕", + "国语音轨", + ] + + +def test_title_pattern_keeps_title_only_matching_semantics(): + """标题正则不应因新增内容筛选而匹配简介或标签。""" + items = [ + _build_context("Movie.特效.2026", description="普通字幕", index=1), + _build_context("Movie.2026.1080p", description="简繁特效字幕", index=2), + _build_context("Movie.2026.2160p", labels=["特效"], index=3), + ] + + result = _run_tool(items, title_pattern="特效", include_description=True) + payload = json.loads(result) + + assert payload["total_count"] == 1 + assert payload["results"][0]["torrent_info"]["title"] == "Movie.特效.2026" + + +def test_invalid_content_pattern_returns_validation_message(): + """非法内容正则应返回明确错误,不进入搜索结果筛选。""" + result = _run_tool([_build_context("Movie.2026")], content_pattern="[") + + assert result.startswith("正则表达式格式错误:")