feat: 集成博查搜索Web Search API

- 新增BochaSearchProvider搜索引擎类,支持中文搜索和AI摘要
- 添加bocha_api_keys配置项,支持多Key负载均衡
- 将Bocha设为第一优先级搜索引擎(中文优化)
- 搜索时间范围设置为oneMonth,适合捕获财报公告等信息
- 完善异常处理和错误日志记录
- 修复source字段逻辑,确保空值正确fallback到域名提取
This commit is contained in:
Alfred
2026-01-14 18:54:51 +08:00
parent b1bfea5dad
commit 20f5731897
2 changed files with 203 additions and 4 deletions

View File

@@ -55,6 +55,7 @@ class Config:
openai_model: str = "gpt-4o-mini" # OpenAI 兼容模型名称
# === 搜索引擎配置(支持多 Key 负载均衡)===
bocha_api_keys: List[str] = field(default_factory=list) # Bocha API Keys
tavily_api_keys: List[str] = field(default_factory=list) # Tavily API Keys
serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys
@@ -156,6 +157,9 @@ class Config:
stock_list = ['600519', '000001', '300750']
# 解析搜索引擎 API Keys支持多个 key逗号分隔
bocha_keys_str = os.getenv('BOCHA_API_KEYS', '')
bocha_api_keys = [k.strip() for k in bocha_keys_str.split(',') if k.strip()]
tavily_keys_str = os.getenv('TAVILY_API_KEYS', '')
tavily_api_keys = [k.strip() for k in tavily_keys_str.split(',') if k.strip()]
@@ -177,6 +181,7 @@ class Config:
openai_api_key=os.getenv('OPENAI_API_KEY'),
openai_base_url=os.getenv('OPENAI_BASE_URL'),
openai_model=os.getenv('OPENAI_MODEL', 'gpt-4o-mini'),
bocha_api_keys=bocha_api_keys,
tavily_api_keys=tavily_api_keys,
serpapi_keys=serpapi_keys,
wechat_webhook_url=os.getenv('WECHAT_WEBHOOK_URL'),
@@ -224,8 +229,8 @@ class Config:
elif not self.gemini_api_key:
warnings.append("提示:未配置 Gemini API Key将使用 OpenAI 兼容 API")
if not self.tavily_api_keys and not self.serpapi_keys:
warnings.append("提示:未配置搜索引擎 API Key (Tavily/SerpAPI),新闻搜索功能将不可用")
if not self.bocha_api_keys and not self.tavily_api_keys and not self.serpapi_keys:
warnings.append("提示:未配置搜索引擎 API Key (Bocha/Tavily/SerpAPI),新闻搜索功能将不可用")
# 检查通知配置
has_notification = (

View File

@@ -344,6 +344,192 @@ class SerpAPISearchProvider(BaseSearchProvider):
return '未知来源'
class BochaSearchProvider(BaseSearchProvider):
"""
博查搜索引擎
特点:
- 专为AI优化的中文搜索API
- 结果准确、摘要完整
- 支持时间范围过滤和AI摘要
- 兼容Bing Search API格式
文档https://bocha-ai.feishu.cn/wiki/RXEOw02rFiwzGSkd9mUcqoeAnNK
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Bocha")
def _do_search(self, query: str, api_key: str, max_results: int) -> SearchResponse:
"""执行博查搜索"""
try:
import requests
except ImportError:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="requests 未安装,请运行: pip install requests"
)
try:
# API 端点
url = "https://api.bocha.cn/v1/web-search"
# 请求头
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
# 请求参数严格按照API文档
payload = {
"query": query,
"freshness": "oneMonth", # 搜索近一个月,适合捕获财报、公告等信息
"summary": True, # 启用AI摘要
"count": min(max_results, 50) # 最大50条
}
# 执行搜索
response = requests.post(url, headers=headers, json=payload, timeout=10)
# 检查HTTP状态码
if response.status_code != 200:
# 尝试解析错误信息
try:
if response.headers.get('content-type', '').startswith('application/json'):
error_data = response.json()
error_message = error_data.get('message', response.text)
else:
error_message = response.text
except:
error_message = response.text
# 根据错误码处理
if response.status_code == 403:
error_msg = f"余额不足: {error_message}"
elif response.status_code == 401:
error_msg = f"API KEY无效: {error_message}"
elif response.status_code == 400:
error_msg = f"请求参数错误: {error_message}"
elif response.status_code == 429:
error_msg = f"请求频率达到限制: {error_message}"
else:
error_msg = f"HTTP {response.status_code}: {error_message}"
logger.warning(f"[Bocha] 搜索失败: {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
# 解析响应
try:
data = response.json()
except ValueError as e:
error_msg = f"响应JSON解析失败: {str(e)}"
logger.error(f"[Bocha] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
# 检查响应code
if data.get('code') != 200:
error_msg = data.get('msg') or f"API返回错误码: {data.get('code')}"
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
# 记录原始响应到日志
logger.info(f"[Bocha] 搜索完成query='{query}'")
logger.debug(f"[Bocha] 原始响应: {data}")
# 解析搜索结果
results = []
web_pages = data.get('data', {}).get('webPages', {})
value_list = web_pages.get('value', [])
for item in value_list[:max_results]:
# 优先使用summaryAI摘要fallback到snippet
snippet = item.get('summary') or item.get('snippet', '')
# 截取摘要长度
if snippet:
snippet = snippet[:500]
results.append(SearchResult(
title=item.get('name', ''),
snippet=snippet,
url=item.get('url', ''),
source=item.get('siteName') or self._extract_domain(item.get('url', '')),
published_date=item.get('datePublished'), # UTC+8格式无需转换
))
logger.info(f"[Bocha] 成功解析 {len(results)} 条结果")
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except requests.exceptions.Timeout:
error_msg = "请求超时"
logger.error(f"[Bocha] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
except requests.exceptions.RequestException as e:
error_msg = f"网络请求失败: {str(e)}"
logger.error(f"[Bocha] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
except Exception as e:
error_msg = f"未知错误: {str(e)}"
logger.error(f"[Bocha] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
@staticmethod
def _extract_domain(url: str) -> str:
"""从 URL 提取域名作为来源"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc.replace('www.', '')
return domain or '未知来源'
except:
return '未知来源'
class SearchService:
"""
搜索服务
@@ -356,6 +542,7 @@ class SearchService:
def __init__(
self,
bocha_keys: Optional[List[str]] = None,
tavily_keys: Optional[List[str]] = None,
serpapi_keys: Optional[List[str]] = None,
):
@@ -363,18 +550,24 @@ class SearchService:
初始化搜索服务
Args:
bocha_keys: 博查搜索 API Key 列表
tavily_keys: Tavily API Key 列表
serpapi_keys: SerpAPI Key 列表
"""
self._providers: List[BaseSearchProvider] = []
# 初始化搜索引擎(按优先级排序)
# Tavily 优先(免费额度更多,每月 1000 次
# 1. Bocha 优先中文搜索优化AI摘要
if bocha_keys:
self._providers.append(BochaSearchProvider(bocha_keys))
logger.info(f"已配置 Bocha 搜索,共 {len(bocha_keys)} 个 API Key")
# 2. Tavily免费额度更多每月 1000 次)
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key")
# SerpAPI 作为备选(每月 100 次)
# 3. SerpAPI 作为备选(每月 100 次)
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key")
@@ -661,6 +854,7 @@ def get_search_service() -> SearchService:
config = get_config()
_search_service = SearchService(
bocha_keys=config.bocha_api_keys,
tavily_keys=config.tavily_api_keys,
serpapi_keys=config.serpapi_keys,
)