feat: 收敛 SerpAPI 正文抓取范围,降低搜索链路尾延迟 (#882) (#898)

* fix(issue-882): [feature]-收敛-serpapi-正文抓取范围,降低搜索链路尾延迟
This commit is contained in:
mumu
2026-04-01 11:12:50 +08:00
committed by GitHub
parent 066a597028
commit 68986cc7a9
3 changed files with 825 additions and 20 deletions

View File

@@ -37,6 +37,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### 改进
- 🖥️ **核心页面统一到同一套工作台视觉语言**`Home / Chat / Backtest / Portfolio / Settings` 进一步收口到共享设计 token、`input-surface` 输入体系、空态/错误态表达和抽屉遮罩语义,减少页面之间的视觉割裂与局部私有样式漂移。
- 💬 **问股交互可达性与反馈增强** — 问股页补强了会话导出、通知发送、消息复制、历史删除与追问上下文提示AI 回复操作不再过度依赖 hover触屏设备和小屏场景下也能直接触达关键按钮。
- 📊 **回测与持仓页表面和状态表达继续标准化** — 回测页筛选控件、布尔状态、结果表格与汇总卡片统一到共享输入/状态原语;持仓页的导入反馈、汇率刷新提示、空态与警示信息进一步归口到共享组件,减少页面级重复实现。
- 🧭 **导航与页面壳层协同优化** — 侧边栏主题切换、问股完成角标、移动端抽屉遮罩和主内容滚动契约进一步统一,首页、问股和回测在桌面端与移动端的切页体验更稳定。
- 🔎 **SerpAPI 正文补抓范围收敛** — 自然搜索结果不再逐条同步抓取网页正文;现在仅对极少数高位且摘要明显不足的结果,在更短超时预算内做延迟补抓,并优先复用 SerpAPI 已返回的结构化摘要,降低搜索链路尾延迟与慢站点放大风险。
### 测试
- 🧪 **UI 治理与关键路径回归补强** — 补充 `SidebarNav``ChatPage``BacktestPage` 等组件测试,并新增 UI governance 守卫,持续防止交互元素重新引入原生 `title` 属性或旧 `input-terminal` 样式回流。同步更新 smoke / markdown drawer 相关验证,覆盖主题升级后的关键主链路。
- [修复] 🐳 **Docker WebUI 运行时优先复用预构建静态资源**`prepare_webui_frontend_assets()` 现在会先检查镜像内已有的 `static/index.html` 是否可直接复用;当容器运行时不包含 `apps/dsa-web` 源码目录且未安装 `npm` 时,也不会误报“未找到前端项目,无法自动构建”,从而恢复 Docker 部署后的 WebUI 打开能力。
- [改进] 🔎 **SerpAPI 正文补抓范围收敛** — 自然搜索结果不再逐条同步抓取网页正文;现在仅对极少数高位且摘要明显不足的结果,在更短超时预算内做延迟补抓,并优先复用 SerpAPI 已返回的结构化摘要,降低搜索链路尾延迟与慢站点放大风险。
## [3.11.0] - 2026-03-27
### 发布亮点
- 🎨 **Web 工作台完成一轮 UI 统一与双主题升级** — 首页、问股、回测、持仓和设置页进一步收口到统一设计 token、输入表面和状态表达新增完整浅色主题并支持浅色 / 深色一键切换与持久化保存。
- 🤖 **Bot / Agent 能力重新补回主分支** — 恢复 `/history``/strategies``/research` 等命令,`/ask` 继续支持多股对比与组合视角Deep Research、事件监控与 schedule 轮询链路重新接回主线能力。
- 🔒 **安全性与运行稳态同步补强** — 修复 `X-Forwarded-For` 限流绕过风险,恢复 LiteLLM 官方 PyPI 安装路径Tushare 初始化不再依赖本地 SDK降低 Docker、桌面打包和环境重建时的脆弱点。
- 🖥️ **日常使用细节继续打磨** — 修复首页港股自动补全提交、登录页首屏主题闪烁、历史长股票名重叠,以及 Telegram Markdown 解析失败时整条通知发送中断等问题。
### 新功能
- 🎨 **全新浅色主题与双主题切换上线** — Web 工作台新增完整浅色主题,并支持在侧边栏中一键切换浅色 / 深色模式;主题选择会持久化保存,刷新页面后仍保持当前偏好。此次升级不是局部配色微调,而是对卡片层级、边界对比、输入表面、状态提示和页面背景做了一整套 light theme 重绘。
- 🤖 **补回主分支缺失的 Agent / Bot 能力**`#648` / `#649` 已重新补回 `main`Bot 恢复 `/history``/strategies``/research``/ask` 保留多股对比与组合视角Deep Research 与 Event Monitor 的配置重新在 Web 设置页可见并可编辑schedule 模式也重新接入事件告警轮询。
### 改进
- 🖥️ **核心页面统一到同一套工作台视觉语言**`Home / Chat / Backtest / Portfolio / Settings` 进一步收口到共享设计 token、`input-surface` 输入体系、空态/错误态表达和抽屉遮罩语义,减少页面之间的视觉割裂与局部私有样式漂移。
- 💬 **问股交互可达性与反馈增强** — 问股页补强了会话导出、通知发送、消息复制、历史删除与追问上下文提示AI 回复操作不再过度依赖 hover触屏设备和小屏场景下也能直接触达关键按钮。
- 📊 **回测与持仓页表面和状态表达继续标准化** — 回测页筛选控件、布尔状态、结果表格与汇总卡片统一到共享输入/状态原语;持仓页的导入反馈、汇率刷新提示、空态与警示信息进一步归口到共享组件,减少页面级重复实现。

View File

@@ -21,6 +21,7 @@ from datetime import date, datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from typing import List, Dict, Any, Optional, Tuple
from itertools import cycle
from urllib.parse import parse_qsl, unquote, urlparse
import requests
from newspaper import Article, Config
from tenacity import (
@@ -419,6 +420,46 @@ class SerpAPISearchProvider(BaseSearchProvider):
文档https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis
"""
_ORGANIC_CONTENT_FETCH_LIMIT = 1
_ORGANIC_CONTENT_FETCH_RANK_LIMIT = 2
_ORGANIC_CONTENT_FETCH_TIMEOUT = 2
_ORGANIC_SNIPPET_SUFFICIENT_LENGTH = 140
_ORGANIC_FETCHED_PREVIEW_LENGTH = 320
_SKIPPED_CONTENT_FETCH_SUFFIXES = (
".pdf",
".jpg",
".jpeg",
".png",
".gif",
".svg",
".webp",
".zip",
".rar",
".7z",
".doc",
".docx",
".ppt",
".pptx",
".xls",
".xlsx",
".csv",
)
_SKIPPED_CONTENT_FETCH_QUERY_KEYS = {
"attachment",
"attachment_file",
"doc",
"document",
"download",
"download_file",
"file",
"file_name",
"filename",
"file_path",
"filepath",
"resource",
"resource_file",
}
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "SerpAPI")
@@ -553,28 +594,33 @@ class SerpAPISearchProvider(BaseSearchProvider):
# 4. 解析 Organic Results (自然搜索结果)
organic_results = response.get('organic_results', [])
organic_content_fetch_attempts = 0
for item in organic_results[:max_results]:
for rank, item in enumerate(organic_results[:max_results]):
link = item.get('link', '')
snippet = item.get('snippet', '')
rich_extensions = self._extract_rich_snippet_extensions(item)
snippet = self._build_organic_snippet(item, rich_extensions=rich_extensions)
# 增强:如果需要,解析网页正文
# 策略:如果摘要太短,或者为了获取更多信息,可以请求网页
# 这里我们对所有结果尝试获取正文但为了性能仅获取前1000字符
content = ""
if link:
try:
fetched_content = fetch_url_content(link, timeout=5)
if fetched_content:
# 如果获取到了正文,将其拼接到 snippet 中,或者替换 snippet
# 这里选择拼接,保留原摘要
content = fetched_content
if len(content) > 500:
snippet = f"{snippet}\n\n【网页详情】\n{content[:500]}..."
else:
snippet = f"{snippet}\n\n【网页详情】\n{content}"
except Exception as e:
logger.debug(f"[SerpAPI] Fetch content failed: {e}")
if self._should_fetch_organic_content(
link=link,
snippet=snippet,
rank=rank,
fetched_count=organic_content_fetch_attempts,
has_structured_summary=bool(rich_extensions),
):
organic_content_fetch_attempts += 1
try:
fetched_content = fetch_url_content(
link,
timeout=self._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
if fetched_content:
snippet = self._merge_organic_snippet_with_content(
snippet,
fetched_content,
)
except Exception as e:
logger.debug(f"[SerpAPI] Fetch content failed: {e}")
results.append(SearchResult(
title=item.get('title', ''),
@@ -605,12 +651,203 @@ class SerpAPISearchProvider(BaseSearchProvider):
def _extract_domain(url: str) -> str:
"""从 URL 提取域名"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
return parsed.netloc.replace('www.', '') or '未知来源'
except Exception:
return '未知来源'
@classmethod
def _normalize_organic_text(cls, value: Any) -> str:
"""标准化 SerpAPI organic 文本字段。"""
text = "" if value is None else str(value)
return re.sub(r"\s+", " ", text).strip()
@classmethod
def _extract_rich_snippet_extensions(cls, item: Dict[str, Any]) -> List[str]:
"""提取 rich_snippet 中已有的结构化摘要,优先复用 API 原始返回。"""
rich_snippet = item.get("rich_snippet")
if not isinstance(rich_snippet, dict):
return []
extensions: List[str] = []
seen: set[str] = set()
for section in ("top", "bottom"):
section_data = rich_snippet.get(section)
if not isinstance(section_data, dict):
continue
raw_extensions = section_data.get("extensions")
if isinstance(raw_extensions, (list, tuple, set)):
for raw_value in raw_extensions:
value = cls._normalize_organic_text(raw_value)
if not value or value in seen:
continue
seen.add(value)
extensions.append(value)
for raw_value in cls._flatten_rich_snippet_values(
section_data.get("detected_extensions")
):
if raw_value in seen:
continue
seen.add(raw_value)
extensions.append(raw_value)
return extensions
@classmethod
def _flatten_rich_snippet_values(
cls,
value: Any,
*,
label: Optional[str] = None,
allow_unlabeled_scalar: bool = False,
) -> List[str]:
"""把 rich_snippet.detected_extensions 展平为可读文本。"""
if isinstance(value, dict):
flattened: List[str] = []
for key, nested_value in value.items():
flattened.extend(
cls._flatten_rich_snippet_values(
nested_value,
label=cls._normalize_organic_text(str(key)).replace("_", " "),
)
)
return flattened
if isinstance(value, (list, tuple, set)):
flattened: List[str] = []
for nested_value in value:
flattened.extend(
cls._flatten_rich_snippet_values(
nested_value,
label=label,
allow_unlabeled_scalar=True,
)
)
return flattened
text = cls._normalize_organic_text(value)
if not text:
return []
if label:
return [f"{label}: {text}"]
if allow_unlabeled_scalar:
return [text]
return []
@classmethod
def _build_organic_snippet(
cls,
item: Dict[str, Any],
*,
rich_extensions: Optional[List[str]] = None,
) -> str:
"""构建 organic result 摘要,尽量先消费 SerpAPI 已返回的信息。"""
snippet = cls._normalize_organic_text(item.get("snippet", ""))
if rich_extensions is None:
rich_extensions = cls._extract_rich_snippet_extensions(item)
if rich_extensions:
rich_text = " | ".join(rich_extensions)
if rich_text and rich_text not in snippet:
snippet = f"{snippet}\n{rich_text}".strip() if snippet else rich_text
return snippet
@classmethod
def _matches_skipped_content_fetch_suffix(cls, value: Any) -> bool:
"""判断链接片段是否指向附件或其他非 HTML 资源。"""
normalized_value = cls._normalize_organic_text(value).lower()
if not normalized_value:
return False
decoded_value = unquote(normalized_value)
if decoded_value.endswith(cls._SKIPPED_CONTENT_FETCH_SUFFIXES):
return True
return urlparse(decoded_value).path.lower().endswith(
cls._SKIPPED_CONTENT_FETCH_SUFFIXES
)
@classmethod
def _matches_skipped_content_fetch_query_param(
cls, key: Any, value: Any
) -> bool:
"""仅对少数显式附件参数跳过正文抓取,避免误伤普通 HTML 页面。"""
normalized_key = cls._normalize_organic_text(key)
if not normalized_key:
return False
snake_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", normalized_key)
canonical_key = re.sub(r"[^a-z0-9]+", "_", snake_key.lower()).strip("_")
if canonical_key not in cls._SKIPPED_CONTENT_FETCH_QUERY_KEYS:
return False
return cls._matches_skipped_content_fetch_suffix(value)
@classmethod
def _should_fetch_organic_content(
cls,
*,
link: Any,
snippet: str,
rank: int,
fetched_count: int,
has_structured_summary: bool,
) -> bool:
"""仅对极少量高位且摘要明显不足的结果补抓正文。"""
if fetched_count >= cls._ORGANIC_CONTENT_FETCH_LIMIT:
return False
if rank >= cls._ORGANIC_CONTENT_FETCH_RANK_LIMIT:
return False
if has_structured_summary:
return False
if len(snippet) >= cls._ORGANIC_SNIPPET_SUFFICIENT_LENGTH:
return False
if not isinstance(link, str):
return False
if not link or not link.startswith(("http://", "https://")):
return False
parsed_link = urlparse(link)
if parsed_link.scheme not in {"http", "https"}:
return False
if cls._matches_skipped_content_fetch_suffix(parsed_link.path):
return False
for key, value in parse_qsl(parsed_link.query, keep_blank_values=True):
if cls._matches_skipped_content_fetch_query_param(key, value):
return False
return True
@classmethod
def _merge_organic_snippet_with_content(cls, snippet: str, content: str) -> str:
"""用较短正文预览补强 snippet避免拉长单次搜索耗时和返回体积。"""
normalized = cls._normalize_organic_text(content)
if not normalized:
return snippet
preview = normalized[:cls._ORGANIC_FETCHED_PREVIEW_LENGTH]
if len(normalized) > cls._ORGANIC_FETCHED_PREVIEW_LENGTH:
preview = f"{preview}..."
if snippet:
return f"{snippet}\n\n【网页详情】\n{preview}"
return f"【网页详情】\n{preview}"
class BochaSearchProvider(BaseSearchProvider):
"""

View File

@@ -0,0 +1,539 @@
# -*- coding: utf-8 -*-
"""
Regression tests for SerpAPI organic content fetch throttling (Issue #882).
"""
import sys
import unittest
from types import ModuleType
from unittest.mock import MagicMock, patch
# Mock newspaper before search_service import (optional dependency)
if "newspaper" not in sys.modules:
mock_np = MagicMock()
mock_np.Article = MagicMock()
mock_np.Config = MagicMock()
sys.modules["newspaper"] = mock_np
from src.search_service import SerpAPISearchProvider
class _FakeGoogleSearch:
response_payload = {}
init_params = []
def __init__(self, params):
type(self).init_params.append(params)
def get_dict(self):
return type(self).response_payload
@classmethod
def reset(cls) -> None:
cls.response_payload = {}
cls.init_params = []
def _fake_serpapi_module() -> ModuleType:
module = ModuleType("serpapi")
module.GoogleSearch = _FakeGoogleSearch
return module
class TestSerpAPISearchProvider(unittest.TestCase):
"""Tests for provider-specific organic content fetch behavior."""
def _patch_serpapi(self, payload):
_FakeGoogleSearch.reset()
_FakeGoogleSearch.response_payload = payload
return patch.dict(sys.modules, {"serpapi": _fake_serpapi_module()})
def test_provider_skips_body_fetch_when_snippet_is_sufficient(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
long_snippet = "这是一段已经足够长的摘要。 " * 12
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Long summary result",
"link": "https://example.com/long-summary",
"snippet": long_snippet,
"source": "Example",
"date": "2026-03-20",
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertEqual(resp.results[0].snippet, long_snippet.strip())
self.assertEqual(resp.results[0].published_date, "2026-03-20")
mock_fetch.assert_not_called()
self.assertEqual(_FakeGoogleSearch.init_params[0]["num"], 3)
def test_provider_uses_rich_snippet_extensions_without_fetching(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Structured summary result",
"link": "https://example.com/structured-summary",
"source": "Example",
"rich_snippet": {
"top": {
"extensions": [
"Q4 revenue grows 22% year over year and margin keeps improving",
"Management raises full-year guidance after demand stays strong",
]
},
"bottom": {
"extensions": [
"Brokerages lift target prices and keep overweight ratings",
]
},
},
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertIn("Q4 revenue grows 22%", resp.results[0].snippet)
self.assertIn("Brokerages lift target prices", resp.results[0].snippet)
mock_fetch.assert_not_called()
def test_provider_uses_detected_rich_snippet_fields_without_fetching(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Detected extensions result",
"link": "https://example.com/detected-extensions",
"source": "Example",
"rich_snippet": {
"top": {
"detected_extensions": {
"price": "$125.30",
"updated_at": "1 hour ago",
}
},
"bottom": {
"detected_extensions": {
"rating": 4.5,
"votes": 1200,
}
},
},
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertIn("price: $125.30", resp.results[0].snippet)
self.assertIn("updated at: 1 hour ago", resp.results[0].snippet)
self.assertIn("rating: 4.5", resp.results[0].snippet)
self.assertIn("votes: 1200", resp.results[0].snippet)
mock_fetch.assert_not_called()
def test_provider_preserves_falsy_detected_extensions_without_fetching(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Zero-like extensions result",
"link": "https://example.com/zero-like-extensions",
"source": "Example",
"rich_snippet": {
"top": {
"detected_extensions": {
"price": 0,
"market_open": False,
}
},
"bottom": {
"detected_extensions": {
"votes": 0,
}
},
},
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertIn("price: 0", resp.results[0].snippet)
self.assertIn("market open: False", resp.results[0].snippet)
self.assertIn("votes: 0", resp.results[0].snippet)
mock_fetch.assert_not_called()
def test_provider_ignores_scalar_detected_extensions_and_still_fetches(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Malformed detected extensions result",
"link": "https://example.com/malformed-detected-extensions",
"snippet": "摘要过短",
"source": "Example",
"rich_snippet": {
"top": {
"detected_extensions": True,
},
},
}
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
mock_fetch.assert_called_once_with(
"https://example.com/malformed-detected-extensions",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertNotIn("True", resp.results[0].snippet)
self.assertIn("【网页详情】", resp.results[0].snippet)
def test_provider_ignores_malformed_rich_snippet_sections(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
long_enough_snippet = "已有摘要,足够避免补抓。 " * 16
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Malformed rich snippet",
"link": "https://example.com/malformed-rich-snippet",
"snippet": long_enough_snippet,
"source": "Example",
"rich_snippet": {
"top": "unexpected string payload",
"bottom": ["unexpected", "list"],
},
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertEqual(resp.results[0].snippet, long_enough_snippet.strip())
mock_fetch.assert_not_called()
def test_provider_ignores_non_list_rich_snippet_extensions(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Malformed extensions payload",
"link": "https://example.com/malformed-extensions",
"source": "Example",
"rich_snippet": {
"top": {
"extensions": True,
},
"bottom": {
"extensions": 1,
"detected_extensions": {
"rating": 4.5,
},
},
},
}
]
}
), patch("src.search_service.fetch_url_content") as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 1)
self.assertIn("rating: 4.5", resp.results[0].snippet)
mock_fetch.assert_not_called()
def test_extract_rich_snippet_extensions_handles_non_dict_payloads(self) -> None:
self.assertEqual(
SerpAPISearchProvider._extract_rich_snippet_extensions(
{"rich_snippet": "unexpected string payload"}
),
[],
)
self.assertEqual(
SerpAPISearchProvider._extract_rich_snippet_extensions(
{
"rich_snippet": {
"top": "unexpected string payload",
"bottom": ["unexpected", "list"],
}
}
),
[],
)
def test_merge_organic_snippet_uses_normalized_length_for_ellipsis(self) -> None:
merged = SerpAPISearchProvider._merge_organic_snippet_with_content(
"原始摘要",
"A" + ("\n" * (SerpAPISearchProvider._ORGANIC_FETCHED_PREVIEW_LENGTH + 20)),
)
self.assertFalse(merged.endswith("..."))
def test_provider_fetches_only_one_top_short_snippet_candidate(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Need extra context",
"link": "https://example.com/need-extra-context",
"snippet": "摘要过短",
"source": "Example",
},
{
"title": "Second short result",
"link": "https://example.com/second-short",
"snippet": "也很短",
"source": "Example",
},
{
"title": "Third short result",
"link": "https://example.com/third-short",
"snippet": "还是很短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 3)
mock_fetch.assert_called_once_with(
"https://example.com/need-extra-context",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertIn("【网页详情】", resp.results[0].snippet)
self.assertEqual(resp.results[1].snippet, "也很短")
self.assertEqual(resp.results[2].snippet, "还是很短")
def test_provider_skips_asset_link_and_fetches_next_eligible_result(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "PDF attachment",
"link": "https://example.com/report.PDF?download=1",
"snippet": "附件摘要很短",
"source": "Example",
},
{
"title": "HTML article",
"link": "https://example.com/article",
"snippet": "正文摘要也短",
"source": "Example",
},
{
"title": "Third short result",
"link": "https://example.com/third-short",
"snippet": "还是很短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 3)
mock_fetch.assert_called_once_with(
"https://example.com/article",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertEqual(resp.results[0].snippet, "附件摘要很短")
self.assertIn("【网页详情】", resp.results[1].snippet)
self.assertEqual(resp.results[2].snippet, "还是很短")
def test_provider_skips_query_encoded_attachment_and_fetches_next_result(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Attachment behind download endpoint",
"link": "https://example.com/download?file=report.pdf",
"snippet": "附件摘要很短",
"source": "Example",
},
{
"title": "HTML article",
"link": "https://example.com/article",
"snippet": "正文摘要也短",
"source": "Example",
},
{
"title": "Third short result",
"link": "https://example.com/third-short",
"snippet": "还是很短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=3)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 3)
mock_fetch.assert_called_once_with(
"https://example.com/article",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertEqual(resp.results[0].snippet, "附件摘要很短")
self.assertIn("【网页详情】", resp.results[1].snippet)
self.assertEqual(resp.results[2].snippet, "还是很短")
def test_provider_keeps_html_fetch_for_asset_like_query_param(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "HTML article with media param",
"link": "https://example.com/article?thumbnail=cover.jpg",
"snippet": "摘要过短",
"source": "Example",
},
{
"title": "Second short result",
"link": "https://example.com/second-short",
"snippet": "也很短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=2)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 2)
mock_fetch.assert_called_once_with(
"https://example.com/article?thumbnail=cover.jpg",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertIn("【网页详情】", resp.results[0].snippet)
self.assertEqual(resp.results[1].snippet, "也很短")
def test_provider_skips_non_string_link_and_keeps_fetch_budget(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Malformed link result",
"link": {"href": "https://example.com/broken"},
"snippet": "摘要过短",
"source": "Example",
},
{
"title": "HTML article",
"link": "https://example.com/article",
"snippet": "正文摘要也短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
return_value="网页正文补充信息 " * 40,
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=2)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 2)
mock_fetch.assert_called_once_with(
"https://example.com/article",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertEqual(resp.results[0].snippet, "摘要过短")
self.assertIn("【网页详情】", resp.results[1].snippet)
def test_provider_fetch_failure_stays_fail_open_and_stops_after_budget(self) -> None:
provider = SerpAPISearchProvider(["dummy_key"])
with self._patch_serpapi(
{
"organic_results": [
{
"title": "Slow result",
"link": "https://example.com/slow",
"snippet": "摘要过短",
"source": "Example",
},
{
"title": "Another short result",
"link": "https://example.com/another-short",
"snippet": "仍然很短",
"source": "Example",
},
]
}
), patch(
"src.search_service.fetch_url_content",
side_effect=TimeoutError("slow site"),
) as mock_fetch:
resp = provider.search("阿里巴巴 财报", max_results=2)
self.assertTrue(resp.success)
self.assertEqual(len(resp.results), 2)
mock_fetch.assert_called_once_with(
"https://example.com/slow",
timeout=SerpAPISearchProvider._ORGANIC_CONTENT_FETCH_TIMEOUT,
)
self.assertEqual(resp.results[0].snippet, "摘要过短")
self.assertEqual(resp.results[1].snippet, "仍然很短")
if __name__ == "__main__":
unittest.main()