Files
DinQuant/backend_api_python/app/services/search.py
T
Dinger 05f07ee544 refactor deployment config and exchange integrations
Simplify runtime configuration and remove legacy database and settings surface so new installs are easier to operate.
Refresh deployment assets, docs, and order execution behavior to keep the packaged app aligned with the current backend.

Made-with: Cursor
2026-03-21 18:32:04 +08:00

916 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Search service v2.0 - 增强版搜索服务
整合多个搜索引擎,支持 API Key 轮换和故障转移
支持的搜索引擎(按优先级):
1. Tavily - 专为AI设计,免费1000次/月
2. SerpAPI - Google/Bing 结果抓取
3. Google CSE - 自定义搜索引擎
4. Bing Search API
5. DuckDuckGo - 免费兜底
参考:daily_stock_analysis-main/src/search_service.py
"""
import requests
import json
import time
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Optional
from itertools import cycle
from urllib.parse import urlparse
from app.utils.logger import get_logger
from app.utils.config_loader import load_addon_config
logger = get_logger(__name__)
# Track Google API quota status
_google_quota_exhausted = False
_google_quota_reset_time = 0
@dataclass
class SearchResult:
"""搜索结果数据类"""
title: str
snippet: str # 摘要
url: str
source: str # 来源网站
published_date: Optional[str] = None
sentiment: str = 'neutral' # 情绪标签
def to_text(self) -> str:
"""转换为文本格式"""
date_str = f" ({self.published_date})" if self.published_date else ""
return f"【{self.source}{self.title}{date_str}\n{self.snippet}"
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
'title': self.title,
'link': self.url,
'snippet': self.snippet,
'source': self.source,
'published': self.published_date or '',
'sentiment': self.sentiment,
}
@dataclass
class SearchResponse:
"""搜索响应"""
query: str
results: List[SearchResult]
provider: str # 使用的搜索引擎
success: bool = True
error_message: Optional[str] = None
search_time: float = 0.0 # 搜索耗时(秒)
def to_context(self, max_results: int = 5) -> str:
"""将搜索结果转换为可用于 AI 分析的上下文"""
if not self.success or not self.results:
return f"搜索 '{self.query}' 未找到相关结果。"
lines = [f"【{self.query} 搜索结果】(来源:{self.provider}"]
for i, result in enumerate(self.results[:max_results], 1):
lines.append(f"\n{i}. {result.to_text()}")
return "\n".join(lines)
def to_list(self) -> List[Dict[str, Any]]:
"""转换为列表格式(兼容旧接口)"""
return [r.to_dict() for r in self.results]
class BaseSearchProvider(ABC):
"""搜索引擎基类"""
def __init__(self, api_keys: List[str], name: str):
"""
初始化搜索引擎
Args:
api_keys: API Key 列表(支持多个 key 负载均衡)
name: 搜索引擎名称
"""
self._api_keys = api_keys
self._name = name
self._key_cycle = cycle(api_keys) if api_keys else None
self._key_usage: Dict[str, int] = {key: 0 for key in api_keys}
self._key_errors: Dict[str, int] = {key: 0 for key in api_keys}
@property
def name(self) -> str:
return self._name
@property
def is_available(self) -> bool:
"""检查是否有可用的 API Key"""
return bool(self._api_keys)
def _get_next_key(self) -> Optional[str]:
"""
获取下一个可用的 API Key(负载均衡)
策略:轮询 + 跳过错误过多的 key
"""
if not self._key_cycle:
return None
# 最多尝试所有 key
for _ in range(len(self._api_keys)):
key = next(self._key_cycle)
# 跳过错误次数过多的 key(超过 3 次)
if self._key_errors.get(key, 0) < 3:
return key
# 所有 key 都有问题,重置错误计数并返回第一个
logger.warning(f"[{self._name}] 所有 API Key 都有错误记录,重置错误计数")
self._key_errors = {key: 0 for key in self._api_keys}
return self._api_keys[0] if self._api_keys else None
def _record_success(self, key: str) -> None:
"""记录成功使用"""
self._key_usage[key] = self._key_usage.get(key, 0) + 1
# 成功后减少错误计数
if key in self._key_errors and self._key_errors[key] > 0:
self._key_errors[key] -= 1
def _record_error(self, key: str) -> None:
"""记录错误"""
self._key_errors[key] = self._key_errors.get(key, 0) + 1
logger.warning(f"[{self._name}] API Key {key[:8]}... 错误计数: {self._key_errors[key]}")
@abstractmethod
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行搜索(子类实现)"""
pass
def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
执行搜索
Args:
query: 搜索关键词
max_results: 最大返回结果数
days: 搜索最近几天的时间范围(默认7天)
Returns:
SearchResponse 对象
"""
api_key = self._get_next_key()
if not api_key:
return SearchResponse(
query=query,
results=[],
provider=self._name,
success=False,
error_message=f"{self._name} 未配置 API Key"
)
start_time = time.time()
try:
response = self._do_search(query, api_key, max_results, days=days)
response.search_time = time.time() - start_time
if response.success:
self._record_success(api_key)
logger.info(f"[{self._name}] 搜索 '{query}' 成功,返回 {len(response.results)} 条结果,耗时 {response.search_time:.2f}s")
else:
self._record_error(api_key)
return response
except Exception as e:
self._record_error(api_key)
elapsed = time.time() - start_time
logger.error(f"[{self._name}] 搜索 '{query}' 失败: {e}")
return SearchResponse(
query=query,
results=[],
provider=self._name,
success=False,
error_message=str(e),
search_time=elapsed
)
@staticmethod
def _extract_domain(url: str) -> str:
"""从 URL 提取域名作为来源"""
try:
parsed = urlparse(url)
domain = parsed.netloc.replace('www.', '')
return domain or '未知来源'
except:
return '未知来源'
class TavilySearchProvider(BaseSearchProvider):
"""
Tavily 搜索引擎
特点:
- 专为 AI/LLM 优化的搜索 API
- 免费版每月 1000 次请求
- 返回结构化的搜索结果
文档:https://docs.tavily.com/
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Tavily")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Tavily 搜索"""
try:
from tavily import TavilyClient
except ImportError:
# 如果未安装 tavily-python,使用 REST API
return self._do_search_rest(query, api_key, max_results, days)
try:
client = TavilyClient(api_key=api_key)
# 执行搜索
response = client.search(
query=query,
search_depth="advanced",
max_results=max_results,
include_answer=False,
include_raw_content=False,
days=days,
)
# 解析结果
results = []
for item in response.get('results', []):
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('content', '')[:500],
url=item.get('url', ''),
source=self._extract_domain(item.get('url', '')),
published_date=item.get('published_date'),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
error_msg = str(e)
if 'rate limit' in error_msg.lower() or 'quota' in error_msg.lower():
error_msg = f"API 配额已用尽: {error_msg}"
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""使用 REST API 执行 Tavily 搜索(备选方案)"""
try:
url = "https://api.tavily.com/search"
headers = {
'Content-Type': 'application/json',
}
payload = {
'api_key': api_key,
'query': query,
'search_depth': 'advanced',
'max_results': max_results,
'include_answer': False,
'include_raw_content': False,
}
response = requests.post(url, headers=headers, json=payload, timeout=15)
if response.status_code != 200:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=f"HTTP {response.status_code}: {response.text}"
)
data = response.json()
results = []
for item in data.get('results', []):
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('content', '')[:500],
url=item.get('url', ''),
source=self._extract_domain(item.get('url', '')),
published_date=item.get('published_date'),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
class SerpAPISearchProvider(BaseSearchProvider):
"""
SerpAPI 搜索引擎
特点:
- 支持 Google、Bing、百度等多种搜索引擎
- 免费版每月 100 次请求
文档:https://serpapi.com/
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "SerpAPI")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 SerpAPI 搜索"""
try:
from serpapi import GoogleSearch
except ImportError:
return self._do_search_rest(query, api_key, max_results, days)
try:
tbs = "qdr:w"
if days <= 1:
tbs = "qdr:d"
elif days <= 7:
tbs = "qdr:w"
elif days <= 30:
tbs = "qdr:m"
else:
tbs = "qdr:y"
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"google_domain": "google.com.hk",
"hl": "zh-cn",
"gl": "cn",
"tbs": tbs,
"num": max_results
}
search = GoogleSearch(params)
response = search.get_dict()
results = []
organic_results = response.get('organic_results', [])
for item in organic_results[:max_results]:
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('snippet', '')[:500],
url=item.get('link', ''),
source=item.get('source', self._extract_domain(item.get('link', ''))),
published_date=item.get('date'),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""使用 REST API 执行 SerpAPI 搜索"""
try:
tbs = "qdr:w"
if days <= 1:
tbs = "qdr:d"
elif days <= 7:
tbs = "qdr:w"
elif days <= 30:
tbs = "qdr:m"
url = "https://serpapi.com/search"
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"hl": "zh-cn",
"gl": "cn",
"tbs": tbs,
"num": max_results
}
response = requests.get(url, params=params, timeout=15)
if response.status_code != 200:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=f"HTTP {response.status_code}"
)
data = response.json()
results = []
for item in data.get('organic_results', [])[:max_results]:
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('snippet', '')[:500],
url=item.get('link', ''),
source=self._extract_domain(item.get('link', '')),
published_date=item.get('date'),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
class GoogleSearchProvider(BaseSearchProvider):
"""Google Custom Search (CSE) 搜索引擎"""
def __init__(self, api_key: str, cx: str):
super().__init__([api_key] if api_key else [], "Google")
self._cx = cx
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Google 搜索"""
global _google_quota_exhausted, _google_quota_reset_time
if not self._cx:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="Google Search 未配置 CX"
)
try:
url = "https://www.googleapis.com/customsearch/v1"
params = {
'key': api_key,
'cx': self._cx,
'q': query,
'num': min(max_results, 10),
}
# 添加时间限制
if days <= 1:
params['dateRestrict'] = 'd1'
elif days <= 7:
params['dateRestrict'] = 'w1'
elif days <= 30:
params['dateRestrict'] = 'm1'
response = requests.get(url, params=params, timeout=10)
if response.status_code == 429:
_google_quota_exhausted = True
import datetime
tomorrow = datetime.datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
_google_quota_reset_time = tomorrow.timestamp()
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="Google API 配额已用尽"
)
response.raise_for_status()
data = response.json()
results = []
if 'items' in data:
for item in data['items']:
results.append(SearchResult(
title=item.get('title', ''),
snippet=item.get('snippet', ''),
url=item.get('link', ''),
source='Google',
published_date=item.get('pagemap', {}).get('metatags', [{}])[0].get('article:published_time', ''),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
class BingSearchProvider(BaseSearchProvider):
"""Bing Search API 搜索引擎"""
def __init__(self, api_key: str):
super().__init__([api_key] if api_key else [], "Bing")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 Bing 搜索"""
try:
url = "https://api.bing.microsoft.com/v7.0/search"
headers = {"Ocp-Apim-Subscription-Key": api_key}
params = {
"q": query,
"count": max_results,
"textDecorations": True,
"textFormat": "HTML"
}
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
data = response.json()
results = []
if 'webPages' in data and 'value' in data['webPages']:
for item in data['webPages']['value']:
results.append(SearchResult(
title=item.get('name', ''),
snippet=item.get('snippet', ''),
url=item.get('url', ''),
source='Bing',
published_date=item.get('datePublished', ''),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""DuckDuckGo 搜索引擎(免费,无需 API Key"""
def __init__(self):
super().__init__(['free'], "DuckDuckGo")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行 DuckDuckGo 搜索"""
try:
# 使用 DuckDuckGo Instant Answer API
url = "https://api.duckduckgo.com/"
params = {
'q': query,
'format': 'json',
'no_html': 1,
'skip_disambig': 1
}
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
results = []
# 获取 RelatedTopics
related_topics = data.get('RelatedTopics', [])
for topic in related_topics[:max_results]:
if isinstance(topic, dict):
if 'FirstURL' in topic:
results.append(SearchResult(
title=topic.get('Text', '')[:100],
snippet=topic.get('Text', ''),
url=topic.get('FirstURL', ''),
source='DuckDuckGo',
))
elif 'Topics' in topic:
for sub_topic in topic['Topics']:
if len(results) >= max_results:
break
if 'FirstURL' in sub_topic:
results.append(SearchResult(
title=sub_topic.get('Text', '')[:100],
snippet=sub_topic.get('Text', ''),
url=sub_topic.get('FirstURL', ''),
source='DuckDuckGo',
))
# 检查 AbstractURL
if data.get('AbstractURL') and len(results) < max_results:
results.insert(0, SearchResult(
title=data.get('Heading', query),
snippet=data.get('AbstractText', ''),
url=data.get('AbstractURL', ''),
source='DuckDuckGo',
))
# 如果没有结果,尝试 HTML 版本
if not results:
results = self._search_html(query, max_results)
return SearchResponse(
query=query,
results=results[:max_results],
provider=self.name,
success=len(results) > 0,
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
def _search_html(self, query: str, max_results: int) -> List[SearchResult]:
"""DuckDuckGo HTML 搜索备选"""
try:
url = "https://lite.duckduckgo.com/lite/"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
data = {'q': query}
response = requests.post(url, headers=headers, data=data, timeout=10)
response.raise_for_status()
results = []
html = response.text
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
links = re.findall(link_pattern, html)
snippets = re.findall(snippet_pattern, html)
for i, (link, title) in enumerate(links[:max_results]):
snippet = snippets[i] if i < len(snippets) else ''
if link and title:
results.append(SearchResult(
title=title.strip(),
snippet=snippet.strip(),
url=link,
source='DuckDuckGo',
))
return results
except Exception as e:
logger.debug(f"DuckDuckGo HTML search failed: {e}")
return []
class SearchService:
"""
搜索服务
功能:
1. 管理多个搜索引擎
2. 自动故障转移
3. 结果聚合和格式化
"""
def __init__(self):
self._providers: List[BaseSearchProvider] = []
self._config = {}
self._load_config()
self._init_providers()
def _load_config(self):
"""加载配置"""
config = load_addon_config()
self._config = config.get('search', {})
self.provider = self._config.get('provider', 'google')
self.max_results = int(self._config.get('max_results', 10))
def _init_providers(self):
"""初始化搜索引擎(按优先级排序)"""
from app.config import APIKeys
# 1. TavilyAI优化搜索)
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key")
# 2. SerpAPI
serpapi_keys = APIKeys.SERPAPI_KEYS
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key")
# 3. Google CSE
google_api_key = self._config.get('google', {}).get('api_key')
google_cx = self._config.get('google', {}).get('cx')
if google_api_key and google_cx:
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
logger.info("已配置 Google CSE 搜索")
# 4. Bing
bing_api_key = self._config.get('bing', {}).get('api_key')
if bing_api_key:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("已配置 Bing 搜索")
# 5. DuckDuckGo(免费兜底)
self._providers.append(DuckDuckGoSearchProvider())
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")
if len(self._providers) == 1:
logger.warning("仅有 DuckDuckGo 可用,建议配置更多搜索引擎 API Key")
@property
def is_available(self) -> bool:
"""检查是否有可用的搜索引擎"""
return any(p.is_available for p in self._providers)
def search(self, query: str, num_results: int = None, date_restrict: str = None, days: int = 7) -> List[Dict[str, Any]]:
"""
执行搜索(兼容旧接口)
Args:
query: 搜索关键词
num_results: 最大返回结果数
date_restrict: 时间限制(Google 格式,如 'd7'
days: 搜索最近几天(优先级高于 date_restrict
Returns:
搜索结果列表
"""
limit = num_results if num_results else self.max_results
# 解析 date_restrict 为 days
if date_restrict and not days:
if date_restrict.startswith('d'):
days = int(date_restrict[1:])
elif date_restrict.startswith('w'):
days = int(date_restrict[1:]) * 7
elif date_restrict.startswith('m'):
days = int(date_restrict[1:]) * 30
response = self.search_with_fallback(query, limit, days)
return response.to_list()
def search_with_fallback(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
执行搜索(带自动故障转移)
Args:
query: 搜索关键词
max_results: 最大返回结果数
days: 搜索最近几天
Returns:
SearchResponse 对象
"""
# 依次尝试各个搜索引擎
for provider in self._providers:
if not provider.is_available:
continue
response = provider.search(query, max_results, days)
if response.success and response.results:
return response
else:
logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎")
# 所有引擎都失败
return SearchResponse(
query=query,
results=[],
provider="None",
success=False,
error_message="所有搜索引擎都不可用或搜索失败"
)
def search_stock_news(
self,
stock_code: str,
stock_name: str,
market: str = "USStock",
max_results: int = 5
) -> SearchResponse:
"""
搜索股票相关新闻
Args:
stock_code: 股票代码
stock_name: 股票名称
market: 市场类型
max_results: 最大返回结果数
Returns:
SearchResponse 对象
"""
# 智能确定搜索时间范围
today_weekday = datetime.now().weekday()
if today_weekday == 0: # 周一
search_days = 3
elif today_weekday >= 5: # 周末
search_days = 2
else:
search_days = 1
# 根据市场类型构建搜索查询
if market == "USStock":
query = f"{stock_name} {stock_code} stock news latest"
elif market == "Crypto":
query = f"{stock_name} crypto news price analysis"
elif market == "Forex":
query = f"{stock_name} {stock_code} forex news analysis"
else:
query = f"{stock_name} {stock_code} latest news"
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), market={market}, days={search_days}")
return self.search_with_fallback(query, max_results, search_days)
def search_stock_events(
self,
stock_code: str,
stock_name: str,
event_types: Optional[List[str]] = None
) -> SearchResponse:
"""
搜索股票特定事件(年报预告、减持等)
"""
if event_types is None:
event_types = ["年报预告", "减持公告", "业绩快报"]
event_query = " OR ".join(event_types)
query = f"{stock_name} ({event_query})"
return self.search_with_fallback(query, max_results=5, days=30)
# 单例实例
_search_service: Optional[SearchService] = None
def get_search_service() -> SearchService:
"""获取搜索服务单例"""
global _search_service
if _search_service is None:
_search_service = SearchService()
return _search_service
def reset_search_service() -> None:
"""重置搜索服务(用于测试或配置更新后)"""
global _search_service
_search_service = None