feat(search): add Brave Search API support (#255)

* feat(search): add Brave Search API support

- Add BraveSearchProvider class with multi-key load balancing
- Support time-based filtering (freshness: pd/pw/pm/py)
- Priority order: Bocha > Tavily > Brave > SerpAPI
- Configure via BRAVE_API_KEYS environment variable
- US stock optimized (English language, US region)

* docs: add Brave Search API to documentation

- Update README.md (CN)
- Update docs/full-guide.md
- Update docs/README_EN.md
- Update docs/README_CHT.md
This commit is contained in:
[ZE]
2026-02-05 13:40:46 +01:00
committed by GitHub
parent 7a26de6798
commit 13b556e79e
10 changed files with 212 additions and 12 deletions

View File

@@ -58,6 +58,9 @@ GEMINI_REQUEST_DELAY=30
TAVILY_API_KEYS=your_tavily_key_here
# SerpAPI Keys支持多个逗号分隔
SERPAPI_API_KEYS=your_serpapi_key_here
# Brave Search API Keys支持多个逗号分隔
# 获取: https://brave.com/search/api/
BRAVE_API_KEYS=your_brave_key_here
# ===================================
# 通知渠道配置(可同时配置多个,全部推送)

View File

@@ -43,7 +43,7 @@
|------|------|
| AI 模型 | Gemini免费、OpenAI 兼容、DeepSeek、通义千问、Claude、Ollama |
| 行情数据 | AkShare、Tushare、Pytdx、Baostock、YFinance |
| 新闻搜索 | Tavily、SerpAPI、Bocha |
| 新闻搜索 | Tavily、SerpAPI、Bocha、Brave |
### 内置交易纪律
@@ -114,6 +114,7 @@
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API新闻搜索 | 推荐 |
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) 全渠道搜索 | 可选 |
| `BOCHA_API_KEYS` | [博查搜索](https://open.bocha.cn/) Web Search API中文搜索优化支持AI摘要多个key用逗号分隔 | 可选 |
| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API隐私优先美股优化多个key用逗号分隔 | 可选 |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 |
| `WECHAT_MSG_TYPE` | 企微消息类型,默认 markdown支持配置 text 类型,发送纯 markdown 文本 | 可选 |

View File

@@ -83,10 +83,11 @@ class MarketCommand(BotCommand):
# 初始化搜索服务
search_service = None
if config.bocha_api_keys or config.tavily_api_keys or config.serpapi_keys:
if config.bocha_api_keys or config.tavily_api_keys or config.brave_api_keys or config.serpapi_keys:
search_service = SearchService(
bocha_keys=config.bocha_api_keys,
tavily_keys=config.tavily_api_keys,
brave_keys=config.brave_api_keys,
serpapi_keys=config.serpapi_keys
)

View File

@@ -47,7 +47,7 @@
|------|------|
| AI 模型 | Gemini免費、OpenAI 兼容、DeepSeek、通義千問、Claude、Ollama |
| 行情數據 | AkShare、Tushare、Pytdx、Baostock、YFinance |
| 新聞搜索 | Tavily、SerpAPI、Bocha |
| 新聞搜索 | Tavily、SerpAPI、Bocha、Brave |
### 內建交易紀律
@@ -118,6 +118,7 @@
| `STOCK_LIST` | 自選股代碼,如 `600519,hk00700,AAPL,TSLA` | ✅ |
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API新聞搜索 | 推薦 |
| `BOCHA_API_KEYS` | [博查搜索](https://open.bocha.cn/) Web Search API中文搜索優化支持AI摘要多個key用逗號分隔 | 可選 |
| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API隱私優先美股優化多個key用逗號分隔 | 可選 |
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) 備用搜索 | 可選 |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可選 |

View File

@@ -47,7 +47,7 @@ English | [简体中文](../README.md) | [繁體中文](README_CHT.md)
|------|----------|
| LLMs | Gemini (free), OpenAI-compatible, DeepSeek, Qwen, Claude, Ollama |
| Market Data | AkShare, Tushare, Pytdx, Baostock, YFinance |
| News Search | Tavily, SerpAPI, Bocha |
| News Search | Tavily, SerpAPI, Bocha, Brave |
### Built-in Trading Rules
@@ -117,6 +117,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
|------------|------|:----:|
| `STOCK_LIST` | Watchlist codes, e.g., `600519,AAPL,hk00700` | ✅ |
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news) | Recommended |
| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API (privacy-focused, US stocks optimized) | Optional |
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) Backup search | Optional |
| `BOCHA_API_KEYS` | [Bocha Search](https://open.bocha.cn/) Web Search API (Chinese search optimized, supports AI summaries, multiple keys comma-separated) | Optional |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | Optional |

View File

@@ -99,6 +99,7 @@ daily_stock_analysis/
| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594` | ✅ |
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API新闻搜索 | 推荐 |
| `BOCHA_API_KEYS` | [博查搜索](https://open.bocha.cn/) Web Search API中文搜索优化支持AI摘要多个key用逗号分隔 | 可选 |
| `BRAVE_API_KEYS` | [Brave Search](https://brave.com/search/api/) API隐私优先美股优化多个key用逗号分隔 | 可选 |
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) 备用搜索 | 可选 |
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 |
@@ -190,6 +191,7 @@ daily_stock_analysis/
|--------|------|:----:|
| `TAVILY_API_KEYS` | Tavily 搜索 API Key推荐 | 推荐 |
| `BOCHA_API_KEYS` | 博查搜索 API Key中文优化 | 可选 |
| `BRAVE_API_KEYS` | Brave Search API Key美股优化 | 可选 |
| `SERPAPI_API_KEYS` | SerpAPI 备用搜索 | 可选 |
### 数据源配置

View File

@@ -422,10 +422,11 @@ def main() -> int:
search_service = None
analyzer = None
if config.bocha_api_keys or config.tavily_api_keys or config.serpapi_keys:
if config.bocha_api_keys or config.tavily_api_keys or config.brave_api_keys or config.serpapi_keys:
search_service = SearchService(
bocha_keys=config.bocha_api_keys,
tavily_keys=config.tavily_api_keys,
brave_keys=config.brave_api_keys,
serpapi_keys=config.serpapi_keys
)

View File

@@ -66,6 +66,7 @@ class Config:
# === 搜索引擎配置(支持多 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
brave_api_keys: List[str] = field(default_factory=list) # Brave Search API Keys
serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys
# === 通知配置(可同时配置多个,全部推送)===
@@ -306,6 +307,9 @@ class Config:
serpapi_keys_str = os.getenv('SERPAPI_API_KEYS', '')
serpapi_keys = [k.strip() for k in serpapi_keys_str.split(',') if k.strip()]
brave_keys_str = os.getenv('BRAVE_API_KEYS', '')
brave_api_keys = [k.strip() for k in brave_keys_str.split(',') if k.strip()]
# 企微消息类型与最大字节数逻辑
wechat_msg_type = os.getenv('WECHAT_MSG_TYPE', 'markdown')
wechat_msg_type_lower = wechat_msg_type.lower()
@@ -335,6 +339,7 @@ class Config:
openai_temperature=float(os.getenv('OPENAI_TEMPERATURE', '0.7')),
bocha_api_keys=bocha_api_keys,
tavily_api_keys=tavily_api_keys,
brave_api_keys=brave_api_keys,
serpapi_keys=serpapi_keys,
wechat_webhook_url=os.getenv('WECHAT_WEBHOOK_URL'),
feishu_webhook_url=os.getenv('FEISHU_WEBHOOK_URL'),
@@ -468,8 +473,8 @@ class Config:
elif not self.gemini_api_key:
warnings.append("提示:未配置 Gemini API Key将使用 OpenAI 兼容 API")
if not self.bocha_api_keys and not self.tavily_api_keys and not self.serpapi_keys:
warnings.append("提示:未配置搜索引擎 API Key (Bocha/Tavily/SerpAPI),新闻搜索功能将不可用")
if not self.bocha_api_keys and not self.tavily_api_keys and not self.brave_api_keys and not self.serpapi_keys:
warnings.append("提示:未配置搜索引擎 API Key (Bocha/Tavily/Brave/SerpAPI),新闻搜索功能将不可用")
# 检查通知配置
has_notification = (

View File

@@ -79,6 +79,7 @@ class StockAnalysisPipeline:
self.search_service = SearchService(
bocha_keys=self.config.bocha_api_keys,
tavily_keys=self.config.tavily_api_keys,
brave_keys=self.config.brave_api_keys,
serpapi_keys=self.config.serpapi_keys,
)

View File

@@ -695,6 +695,182 @@ class BochaSearchProvider(BaseSearchProvider):
return '未知来源'
class BraveSearchProvider(BaseSearchProvider):
"""
Brave Search 搜索引擎
特点:
- 隐私优先的独立搜索引擎
- 索引超过300亿页面
- 免费层可用
- 支持时间范围过滤
文档https://brave.com/search/api/
"""
API_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Brave")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Brave 搜索"""
try:
# 请求头
headers = {
'X-Subscription-Token': api_key,
'Accept': 'application/json'
}
# 确定时间范围freshness 参数)
if days <= 1:
freshness = "pd" # Past day (24小时)
elif days <= 7:
freshness = "pw" # Past week
elif days <= 30:
freshness = "pm" # Past month
else:
freshness = "py" # Past year
# 请求参数
params = {
"q": query,
"count": min(max_results, 20), # Brave 最大支持20条
"freshness": freshness,
"search_lang": "en", # 英文内容US股票优先
"country": "US", # 美国区域偏好
"safesearch": "moderate"
}
# 执行搜索GET 请求)
response = requests.get(
self.API_ENDPOINT,
headers=headers,
params=params,
timeout=10
)
# 检查HTTP状态码
if response.status_code != 200:
error_msg = self._parse_error(response)
logger.warning(f"[Brave] 搜索失败: {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"[Brave] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
logger.info(f"[Brave] 搜索完成query='{query}'")
logger.debug(f"[Brave] 原始响应: {data}")
# 解析搜索结果
results = []
web_data = data.get('web', {})
web_results = web_data.get('results', [])
for item in web_results[:max_results]:
# 解析发布日期ISO 8601 格式)
published_date = None
age = item.get('age') or item.get('page_age')
if age:
try:
# 转换 ISO 格式为简单日期字符串
dt = datetime.fromisoformat(age.replace('Z', '+00:00'))
published_date = dt.strftime('%Y-%m-%d')
except (ValueError, AttributeError):
published_date = age # 解析失败时使用原始值
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('description', '')[:500], # 截取到500字符
url=item.get('url', ''),
source=self._extract_domain(item.get('url', '')),
published_date=published_date
))
logger.info(f"[Brave] 成功解析 {len(results)} 条结果")
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True
)
except requests.exceptions.Timeout:
error_msg = "请求超时"
logger.error(f"[Brave] {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"[Brave] {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"[Brave] {error_msg}")
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
def _parse_error(self, response) -> str:
"""解析错误响应"""
try:
if response.headers.get('content-type', '').startswith('application/json'):
error_data = response.json()
# Brave API 返回的错误格式
if 'message' in error_data:
return error_data['message']
if 'error' in error_data:
return error_data['error']
return str(error_data)
return response.text[:200]
except:
return f"HTTP {response.status_code}: {response.text[:200]}"
@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:
"""
搜索服务
@@ -719,6 +895,7 @@ class SearchService:
self,
bocha_keys: Optional[List[str]] = None,
tavily_keys: Optional[List[str]] = None,
brave_keys: Optional[List[str]] = None,
serpapi_keys: Optional[List[str]] = None,
):
"""
@@ -727,6 +904,7 @@ class SearchService:
Args:
bocha_keys: 博查搜索 API Key 列表
tavily_keys: Tavily API Key 列表
brave_keys: Brave Search API Key 列表
serpapi_keys: SerpAPI Key 列表
"""
self._providers: List[BaseSearchProvider] = []
@@ -742,7 +920,12 @@ class SearchService:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key")
# 3. SerpAPI 作为备选(每月 100 次
# 3. Brave Search隐私优先全球覆盖
if brave_keys:
self._providers.append(BraveSearchProvider(brave_keys))
logger.info(f"已配置 Brave 搜索,共 {len(brave_keys)} 个 API Key")
# 4. SerpAPI 作为备选(每月 100 次)
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key")
@@ -1217,6 +1400,7 @@ def get_search_service() -> SearchService:
_search_service = SearchService(
bocha_keys=config.bocha_api_keys,
tavily_keys=config.tavily_api_keys,
brave_keys=config.brave_api_keys,
serpapi_keys=config.serpapi_keys,
)