Files

884 lines
30 KiB
Python
Raw Permalink Normal View History

2025-12-29 03:06:49 +08:00
"""
Search service v2.0 - Enhanced version of search service
Integrate multiple search engines and support API Key rotation and failover
2026-02-05 00:25:38 +08:00
Supported search engines (in order of priority):
1. Tavily - specially designed for AI, free 1000 times/month
2. SerpAPI - Google/Bing result crawling
3. Google CSE Custom Search Engine
4. Bing Search API
5. DuckDuckGo Get the scoop for free
2026-02-05 00:25:38 +08:00
Reference: daily_stock_analysis-main/src/search_service.py
2025-12-29 03:06:49 +08:00
"""
2026-02-05 00:25:38 +08:00
import re
import time
2026-02-05 00:25:38 +08:00
from abc import ABC, abstractmethod
from dataclasses import dataclass
2026-02-05 00:25:38 +08:00
from datetime import datetime
from itertools import cycle
from typing import Any, Dict, List, Optional
2026-02-05 00:25:38 +08:00
from urllib.parse import urlparse
import requests
2025-12-29 03:06:49 +08:00
from app.utils.config_loader import load_addon_config
from app.utils.logger import get_logger
2025-12-29 03:06:49 +08:00
logger = get_logger(__name__)
# Track Google API quota status
_google_quota_exhausted = False
_google_quota_reset_time = 0
2025-12-29 03:06:49 +08:00
2026-02-05 00:25:38 +08:00
@dataclass
class SearchResult:
"""Search result data class"""
2026-02-05 00:25:38 +08:00
title: str
snippet: str # summary
2026-02-05 00:25:38 +08:00
url: str
source: str # Source website
2026-02-05 00:25:38 +08:00
published_date: Optional[str] = None
sentiment: str = "neutral" # Emotion tags
2026-02-05 00:25:38 +08:00
def to_text(self) -> str:
"""Convert to text format"""
2026-02-05 00:25:38 +08:00
date_str = f" ({self.published_date})" if self.published_date else ""
return f"【{self.source}{self.title}{date_str}\n{self.snippet}"
2026-02-05 00:25:38 +08:00
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary"""
2026-02-05 00:25:38 +08:00
return {
"title": self.title,
"link": self.url,
"snippet": self.snippet,
"source": self.source,
"published": self.published_date or "",
"sentiment": self.sentiment,
2026-02-05 00:25:38 +08:00
}
2025-12-29 03:06:49 +08:00
@dataclass
2026-02-05 00:25:38 +08:00
class SearchResponse:
"""search response"""
2026-02-05 00:25:38 +08:00
query: str
results: List[SearchResult]
provider: str # search engine used
2026-02-05 00:25:38 +08:00
success: bool = True
error_message: Optional[str] = None
search_time: float = 0.0 # Search time (seconds)
2026-02-05 00:25:38 +08:00
def to_context(self, max_results: int = 5) -> str:
"""Transform search results into context that can be used for AI analysis"""
2026-02-05 00:25:38 +08:00
if not self.success or not self.results:
return f"No relevant results were found for '{self.query}'."
lines = [f"[Search results for {self.query}] (source: {self.provider})"]
2026-02-05 00:25:38 +08:00
for i, result in enumerate(self.results[:max_results], 1):
lines.append(f"\n{i}. {result.to_text()}")
2026-02-05 00:25:38 +08:00
return "\n".join(lines)
2026-02-05 00:25:38 +08:00
def to_list(self) -> List[Dict[str, Any]]:
"""Convert to list format (compatible with old interface)"""
2026-02-05 00:25:38 +08:00
return [r.to_dict() for r in self.results]
class BaseSearchProvider(ABC):
"""Search engine base class"""
2026-02-05 00:25:38 +08:00
def __init__(self, api_keys: List[str], name: str):
2025-12-29 03:06:49 +08:00
"""
Initialize search engine
2025-12-29 03:06:49 +08:00
Args:
api_keys: API Key list (supports multiple key load balancing)
name: search engine name
2025-12-29 03:06:49 +08:00
"""
2026-02-05 00:25:38 +08:00
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}
2026-02-05 00:25:38 +08:00
@property
def name(self) -> str:
return self._name
2026-02-05 00:25:38 +08:00
@property
def is_available(self) -> bool:
"""Check if there is an available API Key"""
2026-02-05 00:25:38 +08:00
return bool(self._api_keys)
2026-02-05 00:25:38 +08:00
def _get_next_key(self) -> Optional[str]:
"""
Get the next available API Key (load balancing)
Strategy: polling + skip keys with too many errors
2026-02-05 00:25:38 +08:00
"""
if not self._key_cycle:
return None
# Try at most all keys
2026-02-05 00:25:38 +08:00
for _ in range(len(self._api_keys)):
key = next(self._key_cycle)
# Skip keys with too many errors (more than 3 times)
2026-02-05 00:25:38 +08:00
if self._key_errors.get(key, 0) < 3:
return key
# There is a problem with all keys, reset the error count and return the first one
logger.warning(f"[{self._name}] all API keys have recorded errors, resetting error counters")
2026-02-05 00:25:38 +08:00
self._key_errors = {key: 0 for key in self._api_keys}
return self._api_keys[0] if self._api_keys else None
2026-02-05 00:25:38 +08:00
def _record_success(self, key: str) -> None:
"""Record successful use"""
2026-02-05 00:25:38 +08:00
self._key_usage[key] = self._key_usage.get(key, 0) + 1
# Decrement error count on success
2026-02-05 00:25:38 +08:00
if key in self._key_errors and self._key_errors[key] > 0:
self._key_errors[key] -= 1
2026-02-05 00:25:38 +08:00
def _record_error(self, key: str) -> None:
"""Log errors"""
2026-02-05 00:25:38 +08:00
self._key_errors[key] = self._key_errors.get(key, 0) + 1
logger.warning(f"[{self._name}] API key {key[:8]}... error count: {self._key_errors[key]}")
2026-02-05 00:25:38 +08:00
@abstractmethod
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a search (subclass implementation)"""
2026-02-05 00:25:38 +08:00
pass
2026-02-05 00:25:38 +08:00
def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
Perform a search
2026-02-05 00:25:38 +08:00
Args:
query: search keyword
max_results: Maximum number of results returned
days: Search the time range of the last few days (default 7 days)
2026-02-05 00:25:38 +08:00
Returns:
SearchResponse object
2026-02-05 00:25:38 +08:00
"""
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 is not configured",
2026-02-05 00:25:38 +08:00
)
2026-02-05 00:25:38 +08:00
start_time = time.time()
try:
response = self._do_search(query, api_key, max_results, days=days)
response.search_time = time.time() - start_time
2026-02-05 00:25:38 +08:00
if response.success:
self._record_success(api_key)
logger.info(
f"[{self._name}] search '{query}' succeeded, returned {len(response.results)} results in {response.search_time:.2f}s"
)
2026-02-05 00:25:38 +08:00
else:
self._record_error(api_key)
2026-02-05 00:25:38 +08:00
return response
2026-02-05 00:25:38 +08:00
except Exception as e:
self._record_error(api_key)
elapsed = time.time() - start_time
logger.error(f"[{self._name}] search '{query}' failed: {e}")
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query, results=[], provider=self._name, success=False, error_message=str(e), search_time=elapsed
2026-02-05 00:25:38 +08:00
)
2026-02-05 00:25:38 +08:00
@staticmethod
def _extract_domain(url: str) -> str:
"""Extract domain name from URL as source"""
2026-02-05 00:25:38 +08:00
try:
parsed = urlparse(url)
domain = parsed.netloc.replace("www.", "")
return domain or "Unknown source"
except Exception as e:
logger.debug(f"Failed to extract domain from URL {url}: {e}")
return "Unknown source"
2025-12-29 03:06:49 +08:00
2026-02-05 00:25:38 +08:00
class TavilySearchProvider(BaseSearchProvider):
"""
Tavily search engine
Features:
- Search API optimized for AI/LLM
- Free version 1000 requests per month
- Return structured search results
Documentation: https://docs.tavily.com/
2026-02-05 00:25:38 +08:00
"""
2026-02-05 00:25:38 +08:00
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Tavily")
2026-02-05 00:25:38 +08:00
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a Tavily search"""
2026-02-05 00:25:38 +08:00
try:
from tavily import TavilyClient
except ImportError:
# If tavily-python is not installed, use the REST API
2026-02-05 00:25:38 +08:00
return self._do_search_rest(query, api_key, max_results, days)
2026-02-05 00:25:38 +08:00
try:
client = TavilyClient(api_key=api_key)
# Perform a search
2026-02-05 00:25:38 +08:00
response = client.search(
query=query,
search_depth="advanced",
max_results=max_results,
include_answer=False,
include_raw_content=False,
days=days,
)
# Parse results
2026-02-05 00:25:38 +08:00
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"),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2026-02-05 00:25:38 +08:00
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 quota exhausted: {error_msg}"
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=error_msg)
2026-02-05 00:25:38 +08:00
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Performing Tavily searches using the REST API (alternative)"""
2026-02-05 00:25:38 +08:00
try:
url = "https://api.tavily.com/search"
headers = {
"Content-Type": "application/json",
2026-02-05 00:25:38 +08:00
}
payload = {
"api_key": api_key,
"query": query,
"search_depth": "advanced",
"max_results": max_results,
"include_answer": False,
"include_raw_content": False,
2026-02-05 00:25:38 +08:00
}
2026-02-05 00:25:38 +08:00
response = requests.post(url, headers=headers, json=payload, timeout=15)
2026-02-05 00:25:38 +08:00
if response.status_code != 200:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=f"HTTP {response.status_code}: {response.text}",
2026-02-05 00:25:38 +08:00
)
2026-02-05 00:25:38 +08:00
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"),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2026-02-05 00:25:38 +08:00
except Exception as e:
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e))
2026-02-05 00:25:38 +08:00
class SerpAPISearchProvider(BaseSearchProvider):
"""
SerpAPI search engine
Features:
-Support multiple search engines such as Google, Bing, Baidu, etc.
- Free version 100 requests per month
Documentation: https://serpapi.com/
2026-02-05 00:25:38 +08:00
"""
2026-02-05 00:25:38 +08:00
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "SerpAPI")
2026-02-05 00:25:38 +08:00
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a SerpAPI search"""
2026-02-05 00:25:38 +08:00
try:
from serpapi import GoogleSearch
except ImportError:
return self._do_search_rest(query, api_key, max_results, days)
2026-02-05 00:25:38 +08:00
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,
2026-02-05 00:25:38 +08:00
}
2026-02-05 00:25:38 +08:00
search = GoogleSearch(params)
response = search.get_dict()
2026-02-05 00:25:38 +08:00
results = []
organic_results = response.get("organic_results", [])
2026-02-05 00:25:38 +08:00
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"),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2026-02-05 00:25:38 +08:00
except Exception as e:
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e))
2026-02-05 00:25:38 +08:00
def _do_search_rest(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Performing SerpAPI searches using the REST API"""
2026-02-05 00:25:38 +08:00
try:
tbs = "qdr:w"
if days <= 1:
tbs = "qdr:d"
elif days <= 7:
tbs = "qdr:w"
elif days <= 30:
tbs = "qdr:m"
2026-02-05 00:25:38 +08:00
url = "https://serpapi.com/search"
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"hl": "zh-cn",
"gl": "cn",
"tbs": tbs,
"num": max_results,
2026-02-05 00:25:38 +08:00
}
2026-02-05 00:25:38 +08:00
response = requests.get(url, params=params, timeout=15)
2026-02-05 00:25:38 +08:00
if response.status_code != 200:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=f"HTTP {response.status_code}",
2026-02-05 00:25:38 +08:00
)
2026-02-05 00:25:38 +08:00
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"),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2026-02-05 00:25:38 +08:00
except Exception as e:
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e))
2026-02-05 00:25:38 +08:00
class GoogleSearchProvider(BaseSearchProvider):
"""Google Custom Search (CSE) search engine"""
2026-02-05 00:25:38 +08:00
def __init__(self, api_key: str, cx: str):
super().__init__([api_key] if api_key else [], "Google")
self._cx = cx
2026-02-05 00:25:38 +08:00
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a Google search"""
2026-02-05 00:25:38 +08:00
global _google_quota_exhausted, _google_quota_reset_time
2026-02-05 00:25:38 +08:00
if not self._cx:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="Google Search CX is not configured",
2026-02-05 00:25:38 +08:00
)
2025-12-29 03:06:49 +08:00
try:
2026-02-05 00:25:38 +08:00
url = "https://www.googleapis.com/customsearch/v1"
params = {
"key": api_key,
"cx": self._cx,
"q": query,
"num": min(max_results, 10),
2026-02-05 00:25:38 +08:00
}
# Add time limit
2026-02-05 00:25:38 +08:00
if days <= 1:
params["dateRestrict"] = "d1"
2026-02-05 00:25:38 +08:00
elif days <= 7:
params["dateRestrict"] = "w1"
2026-02-05 00:25:38 +08:00
elif days <= 30:
params["dateRestrict"] = "m1"
2025-12-29 03:06:49 +08:00
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()
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="Google API quota exhausted",
2026-02-05 00:25:38 +08:00
)
2025-12-29 03:06:49 +08:00
response.raise_for_status()
data = response.json()
2025-12-29 03:06:49 +08:00
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", ""),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2025-12-29 03:06:49 +08:00
except Exception as e:
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e))
2025-12-29 03:06:49 +08:00
2026-02-05 00:25:38 +08:00
class BingSearchProvider(BaseSearchProvider):
"""Bing Search API search engine"""
2026-02-05 00:25:38 +08:00
def __init__(self, api_key: str):
super().__init__([api_key] if api_key else [], "Bing")
2026-02-05 00:25:38 +08:00
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a Bing search"""
2025-12-29 03:06:49 +08:00
try:
2026-02-05 00:25:38 +08:00
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"}
2025-12-29 03:06:49 +08:00
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
data = response.json()
2025-12-29 03:06:49 +08:00
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", ""),
)
)
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
2025-12-29 03:06:49 +08:00
except Exception as e:
return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e))
2025-12-29 03:06:49 +08:00
2026-02-05 00:25:38 +08:00
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""DuckDuckGo search engine (free, no API Key required)"""
2026-02-05 00:25:38 +08:00
def __init__(self):
super().__init__(["free"], "DuckDuckGo")
2026-02-05 00:25:38 +08:00
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""Perform a DuckDuckGo search"""
try:
# Using 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 = []
# Get RelatedTopics
related_topics = data.get("RelatedTopics", [])
2026-02-05 00:25:38 +08:00
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"]:
2026-02-05 00:25:38 +08:00
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",
)
)
# Check 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",
),
)
# If no results, try the HTML version
2026-02-05 00:25:38 +08:00
if not results:
results = self._search_html(query, max_results)
2026-02-05 00:25:38 +08:00
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))
2026-02-05 00:25:38 +08:00
def _search_html(self, query: str, max_results: int) -> List[SearchResult]:
"""DuckDuckGo HTML search alternatives"""
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)
2026-02-05 00:25:38 +08:00
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:
2026-02-05 00:25:38 +08:00
logger.debug(f"DuckDuckGo HTML search failed: {e}")
return []
2026-02-05 00:25:38 +08:00
class SearchService:
"""
Search service
Function:
1. Manage multiple search engines
2. Automatic failover
3. Result aggregation and formatting
2026-02-05 00:25:38 +08:00
"""
2026-02-05 00:25:38 +08:00
def __init__(self):
self._providers: List[BaseSearchProvider] = []
self._config = {}
self._load_config()
self._init_providers()
2026-02-05 00:25:38 +08:00
def _load_config(self):
"""Load configuration"""
2026-02-05 00:25:38 +08:00
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))
2026-02-05 00:25:38 +08:00
def _init_providers(self):
"""Initialize search engines (sorted by priority)"""
2026-02-05 00:25:38 +08:00
from app.config import APIKeys
# 1. Tavily (AI optimized search)
2026-02-05 00:25:38 +08:00
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"Configured Tavily search with {len(tavily_keys)} API keys")
# 2. SerpAPI
2026-02-05 00:25:38 +08:00
serpapi_keys = APIKeys.SERPAPI_KEYS
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"Configured SerpAPI search with {len(serpapi_keys)} API keys")
# 3. Google CSE
google_api_key = self._config.get("google", {}).get("api_key")
google_cx = self._config.get("google", {}).get("cx")
2026-02-05 00:25:38 +08:00
if google_api_key and google_cx:
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
logger.info("Configured Google CSE search")
# 4. Bing
bing_api_key = self._config.get("bing", {}).get("api_key")
2026-02-05 00:25:38 +08:00
if bing_api_key:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("Configured Bing search")
# 5. DuckDuckGo (Free Tips)
2026-02-05 00:25:38 +08:00
self._providers.append(DuckDuckGoSearchProvider())
logger.info("Configured DuckDuckGo search as the free fallback")
2026-02-05 00:25:38 +08:00
if len(self._providers) == 1:
logger.warning("Only DuckDuckGo is available. Configure more search-engine API keys for better coverage.")
2026-02-05 00:25:38 +08:00
@property
def is_available(self) -> bool:
"""Check if a search engine is available"""
2026-02-05 00:25:38 +08:00
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]]:
2026-02-05 00:25:38 +08:00
"""
Perform a search (compatible with old interface)
2026-02-05 00:25:38 +08:00
Args:
query: search keyword
num_results: Maximum number of results returned
date_restrict: time restriction (Google format, such as 'd7')
days: Search the last few days (priority higher than date_restrict)
2026-02-05 00:25:38 +08:00
Returns:
Search result list
2026-02-05 00:25:38 +08:00
"""
limit = num_results if num_results else self.max_results
# Parse date_restrict to days
2026-02-05 00:25:38 +08:00
if date_restrict and not days:
if date_restrict.startswith("d"):
2026-02-05 00:25:38 +08:00
days = int(date_restrict[1:])
elif date_restrict.startswith("w"):
2026-02-05 00:25:38 +08:00
days = int(date_restrict[1:]) * 7
elif date_restrict.startswith("m"):
2026-02-05 00:25:38 +08:00
days = int(date_restrict[1:]) * 30
2026-02-05 00:25:38 +08:00
response = self.search_with_fallback(query, limit, days)
return response.to_list()
2026-02-05 00:25:38 +08:00
def search_with_fallback(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
"""
Perform a search (with automatic failover)
2026-02-05 00:25:38 +08:00
Args:
query: search keyword
max_results: Maximum number of results returned
days: Search the last few days
2026-02-05 00:25:38 +08:00
Returns:
SearchResponse object
2026-02-05 00:25:38 +08:00
"""
# Try each search engine in sequence
2026-02-05 00:25:38 +08:00
for provider in self._providers:
if not provider.is_available:
continue
2026-02-05 00:25:38 +08:00
response = provider.search(query, max_results, days)
2026-02-05 00:25:38 +08:00
if response.success and response.results:
return response
else:
logger.warning(f"{provider.name} search failed: {response.error_message}. Trying the next engine.")
# all engines fail
2026-02-05 00:25:38 +08:00
return SearchResponse(
query=query,
results=[],
provider="None",
success=False,
error_message="All search engines are unavailable or failed",
2026-02-05 00:25:38 +08:00
)
2026-02-05 00:25:38 +08:00
def search_stock_news(
self, stock_code: str, stock_name: str, market: str = "USStock", max_results: int = 5
2026-02-05 00:25:38 +08:00
) -> SearchResponse:
"""
Search stock related news
2026-02-05 00:25:38 +08:00
Args:
stock_code: stock code
stock_name: stock name
market: market type
max_results: Maximum number of results returned
2026-02-05 00:25:38 +08:00
Returns:
SearchResponse object
2026-02-05 00:25:38 +08:00
"""
# Intelligently determine search time range
2026-02-05 00:25:38 +08:00
today_weekday = datetime.now().weekday()
if today_weekday == 0: # on Monday
2026-02-05 00:25:38 +08:00
search_days = 3
elif today_weekday >= 5: # weekend
2026-02-05 00:25:38 +08:00
search_days = 2
else:
search_days = 1
# Build search queries based on market type
if market == "USStock":
2026-02-05 00:25:38 +08:00
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"
2026-02-05 00:25:38 +08:00
else:
query = f"{stock_name} {stock_code} latest news"
logger.info(f"Searching stock news: {stock_name}({stock_code}), market={market}, days={search_days}")
2026-02-05 00:25:38 +08:00
return self.search_with_fallback(query, max_results, search_days)
2026-02-05 00:25:38 +08:00
def search_stock_events(
self, stock_code: str, stock_name: str, event_types: Optional[List[str]] = None
2026-02-05 00:25:38 +08:00
) -> SearchResponse:
"""
Search for specific stock events (annual report preview, shareholding reduction, etc.)
2026-02-05 00:25:38 +08:00
"""
if event_types is None:
event_types = ["年报预告", "减持公告", "业绩快报"]
2026-02-05 00:25:38 +08:00
event_query = " OR ".join(event_types)
query = f"{stock_name} ({event_query})"
2026-02-05 00:25:38 +08:00
return self.search_with_fallback(query, max_results=5, days=30)
# Singleton instance
2026-02-05 00:25:38 +08:00
_search_service: Optional[SearchService] = None
def get_search_service() -> SearchService:
"""Get the search service singleton"""
2026-02-05 00:25:38 +08:00
global _search_service
if _search_service is None:
_search_service = SearchService()
return _search_service
def reset_search_service() -> None:
"""Reset the search service (for testing or after configuration updates)"""
2026-02-05 00:25:38 +08:00
global _search_service
_search_service = None