2025-12-29 03:06:49 +08:00
|
|
|
|
"""
|
2026-04-06 16:47:36 +07: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
|
|
|
|
|
2026-04-06 16:47:36 +07: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
|
2026-03-21 18:32:04 +08:00
|
|
|
|
4. Bing Search API
|
2026-04-06 16:47:36 +07:00
|
|
|
|
5. DuckDuckGo – Get the scoop for free
|
2026-02-05 00:25:38 +08:00
|
|
|
|
|
2026-04-08 07:27:26 +07:00
|
|
|
|
Reference: daily_stock_analysis-main/src/search_service.py
|
2025-12-29 03:06:49 +08:00
|
|
|
|
"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
import re
|
2026-04-09 14:30:51 +07:00
|
|
|
|
import time
|
2026-02-05 00:25:38 +08:00
|
|
|
|
from abc import ABC, abstractmethod
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from dataclasses import dataclass
|
2026-02-05 00:25:38 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from itertools import cycle
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from typing import Any, Dict, List, Optional
|
2026-02-05 00:25:38 +08:00
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
|
import requests
|
|
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
from app.utils.config_loader import load_addon_config
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from app.utils.logger import get_logger
|
2025-12-29 03:06:49 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
# 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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Search result data class"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
title: str
|
2026-04-06 16:47:36 +07:00
|
|
|
|
snippet: str # summary
|
2026-02-05 00:25:38 +08:00
|
|
|
|
url: str
|
2026-04-06 16:47:36 +07:00
|
|
|
|
source: str # Source website
|
2026-02-05 00:25:38 +08:00
|
|
|
|
published_date: Optional[str] = None
|
2026-04-09 14:30:51 +07:00
|
|
|
|
sentiment: str = "neutral" # Emotion tags
|
|
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def to_text(self) -> str:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Convert to dictionary"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return {
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
|
@dataclass
|
2026-02-05 00:25:38 +08:00
|
|
|
|
class SearchResponse:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""search response"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
query: str
|
|
|
|
|
|
results: List[SearchResult]
|
2026-04-06 16:47:36 +07:00
|
|
|
|
provider: str # search engine used
|
2026-02-05 00:25:38 +08:00
|
|
|
|
success: bool = True
|
|
|
|
|
|
error_message: Optional[str] = None
|
2026-04-06 16:47:36 +07:00
|
|
|
|
search_time: float = 0.0 # Search time (seconds)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def to_context(self, max_results: int = 5) -> str:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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:
|
2026-04-08 07:27:26 +07:00
|
|
|
|
return f"No relevant results were found for '{self.query}'."
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-08 07:27:26 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return "\n".join(lines)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def to_list(self) -> List[Dict[str, Any]]:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Search engine base class"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
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
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Initialize search engine
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def name(self) -> str:
|
|
|
|
|
|
return self._name
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def is_available(self) -> bool:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Check if there is an available API Key"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return bool(self._api_keys)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _get_next_key(self) -> Optional[str]:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Get the next available API Key (load balancing)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Strategy: polling + skip keys with too many errors
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not self._key_cycle:
|
|
|
|
|
|
return None
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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)
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# There is a problem with all keys, reset the error count and return the first one
|
2026-04-08 07:27:26 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _record_success(self, key: str) -> None:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Record successful use"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
self._key_usage[key] = self._key_usage.get(key, 0) + 1
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _record_error(self, key: str) -> None:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Log errors"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
self._key_errors[key] = self._key_errors.get(key, 0) + 1
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.warning(f"[{self._name}] API key {key[:8]}... error count: {self._key_errors[key]}")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Perform a search (subclass implementation)"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
pass
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Perform a search
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
query: search keyword
|
|
|
|
|
|
max_results: Maximum number of results returned
|
|
|
|
|
|
days: Search the time range of the last few days (default 7 days)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
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,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message=f"{self._name} API key is not configured",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07: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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if response.success:
|
|
|
|
|
|
self._record_success(api_key)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return response
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
self._record_error(api_key)
|
|
|
|
|
|
elapsed = time.time() - start_time
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.error(f"[{self._name}] search '{query}' failed: {e}")
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return SearchResponse(
|
2026-04-09 14:30:51 +07:00
|
|
|
|
query=query, results=[], provider=self._name, success=False, error_message=str(e), search_time=elapsed
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_domain(url: str) -> str:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Extract domain name from URL as source"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(url)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Tavily search engine
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Features:
|
|
|
|
|
|
- Search API optimized for AI/LLM
|
|
|
|
|
|
- Free version 1000 requests per month
|
|
|
|
|
|
- Return structured search results
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Documentation: https://docs.tavily.com/
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def __init__(self, api_keys: List[str]):
|
|
|
|
|
|
super().__init__(api_keys, "Tavily")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Perform a Tavily search"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from tavily import TavilyClient
|
|
|
|
|
|
except ImportError:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
try:
|
|
|
|
|
|
client = TavilyClient(api_key=api_key)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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,
|
|
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Parse results
|
2026-02-05 00:25:38 +08:00
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
error_msg = str(e)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
if "rate limit" in error_msg.lower() or "quota" in error_msg.lower():
|
2026-04-08 07:27:26 +07:00
|
|
|
|
error_msg = f"API quota exhausted: {error_msg}"
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Performing Tavily searches using the REST API (alternative)"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
try:
|
|
|
|
|
|
url = "https://api.tavily.com/search"
|
|
|
|
|
|
headers = {
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"Content-Type": "application/json",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
payload = {
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
response = requests.post(url, headers=headers, json=payload, timeout=15)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if response.status_code != 200:
|
|
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=[],
|
|
|
|
|
|
provider=self.name,
|
|
|
|
|
|
success=False,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message=f"HTTP {response.status_code}: {response.text}",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
data = response.json()
|
|
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
SerpAPI search engine
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Features:
|
|
|
|
|
|
-Support multiple search engines such as Google, Bing, Baidu, etc.
|
|
|
|
|
|
- Free version 100 requests per month
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Documentation: https://serpapi.com/
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def __init__(self, api_keys: List[str]):
|
|
|
|
|
|
super().__init__(api_keys, "SerpAPI")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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-04-09 14:30:51 +07:00
|
|
|
|
|
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,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"num": max_results,
|
2026-02-05 00:25:38 +08:00
|
|
|
|
}
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
search = GoogleSearch(params)
|
|
|
|
|
|
response = search.get_dict()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
organic_results = response.get("organic_results", [])
|
2026-02-05 00:25:38 +08:00
|
|
|
|
|
|
|
|
|
|
for item in organic_results[:max_results]:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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-04-09 14:30:51 +07:00
|
|
|
|
|
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,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"num": max_results,
|
2026-02-05 00:25:38 +08:00
|
|
|
|
}
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
response = requests.get(url, params=params, timeout=15)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if response.status_code != 200:
|
|
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=[],
|
|
|
|
|
|
provider=self.name,
|
|
|
|
|
|
success=False,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message=f"HTTP {response.status_code}",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
data = response.json()
|
|
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Google Custom Search (CSE) search engine"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Perform a Google search"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
global _google_quota_exhausted, _google_quota_reset_time
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if not self._cx:
|
|
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=[],
|
|
|
|
|
|
provider=self.name,
|
|
|
|
|
|
success=False,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message="Google Search CX is not configured",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07: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 = {
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"key": api_key,
|
|
|
|
|
|
"cx": self._cx,
|
|
|
|
|
|
"q": query,
|
|
|
|
|
|
"num": min(max_results, 10),
|
2026-02-05 00:25:38 +08:00
|
|
|
|
}
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Add time limit
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if days <= 1:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
params["dateRestrict"] = "d1"
|
2026-02-05 00:25:38 +08:00
|
|
|
|
elif days <= 7:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
params["dateRestrict"] = "w1"
|
2026-02-05 00:25:38 +08:00
|
|
|
|
elif days <= 30:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
params["dateRestrict"] = "m1"
|
|
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
response = requests.get(url, params=params, timeout=10)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
if response.status_code == 429:
|
|
|
|
|
|
_google_quota_exhausted = True
|
|
|
|
|
|
import datetime
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
tomorrow = datetime.datetime.utcnow().replace(
|
|
|
|
|
|
hour=0, minute=0, second=0, microsecond=0
|
|
|
|
|
|
) + datetime.timedelta(days=1)
|
2026-01-24 23:26:26 +08:00
|
|
|
|
_google_quota_reset_time = tomorrow.timestamp()
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=[],
|
|
|
|
|
|
provider=self.name,
|
|
|
|
|
|
success=False,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message="Google API quota exhausted",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
data = response.json()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Bing Search API search engine"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def __init__(self, api_key: str):
|
|
|
|
|
|
super().__init__([api_key] if api_key else [], "Bing")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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}
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2025-12-29 03:06:49 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""DuckDuckGo search engine (free, no API Key required)"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def __init__(self):
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Perform a DuckDuckGo search"""
|
2026-01-24 23:26:26 +08:00
|
|
|
|
try:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Using DuckDuckGo Instant Answer API
|
2026-01-24 23:26:26 +08:00
|
|
|
|
url = "https://api.duckduckgo.com/"
|
2026-04-09 14:30:51 +07:00
|
|
|
|
params = {"q": query, "format": "json", "no_html": 1, "skip_disambig": 1}
|
|
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
response = requests.get(url, params=params, timeout=10)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
data = response.json()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
results = []
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Get RelatedTopics
|
2026-04-09 14:30:51 +07:00
|
|
|
|
related_topics = data.get("RelatedTopics", [])
|
2026-02-05 00:25:38 +08:00
|
|
|
|
for topic in related_topics[:max_results]:
|
2026-01-24 23:26:26 +08:00
|
|
|
|
if isinstance(topic, dict):
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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:
|
2026-01-24 23:26:26 +08:00
|
|
|
|
break
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Check AbstractURL
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=results[:max_results],
|
|
|
|
|
|
provider=self.name,
|
|
|
|
|
|
success=len(results) > 0,
|
|
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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]:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""DuckDuckGo HTML search alternatives"""
|
2026-01-24 23:26:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
url = "https://lite.duckduckgo.com/lite/"
|
2026-04-09 14:30:51 +07:00
|
|
|
|
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
|
|
|
|
|
data = {"q": query}
|
|
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
response = requests.post(url, headers=headers, data=data, timeout=10)
|
|
|
|
|
|
response.raise_for_status()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
results = []
|
|
|
|
|
|
html = response.text
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
link_pattern = r'<a[^>]*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>'
|
|
|
|
|
|
snippet_pattern = r'<td[^>]*class="result-snippet"[^>]*>([^<]*)</td>'
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
links = re.findall(link_pattern, html)
|
|
|
|
|
|
snippets = re.findall(snippet_pattern, html)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
for i, (link, title) in enumerate(links[:max_results]):
|
2026-04-09 14:30:51 +07:00
|
|
|
|
snippet = snippets[i] if i < len(snippets) else ""
|
2026-01-24 23:26:26 +08:00
|
|
|
|
if link and title:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
results.append(
|
|
|
|
|
|
SearchResult(
|
|
|
|
|
|
title=title.strip(),
|
|
|
|
|
|
snippet=snippet.strip(),
|
|
|
|
|
|
url=link,
|
|
|
|
|
|
source="DuckDuckGo",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
return results
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-01-24 23:26:26 +08:00
|
|
|
|
except Exception as e:
|
2026-02-05 00:25:38 +08:00
|
|
|
|
logger.debug(f"DuckDuckGo HTML search failed: {e}")
|
2026-01-24 23:26:26 +08:00
|
|
|
|
return []
|
2026-02-05 00:25:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SearchService:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Search service
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Function:
|
|
|
|
|
|
1. Manage multiple search engines
|
|
|
|
|
|
2. Automatic failover
|
|
|
|
|
|
3. Result aggregation and formatting
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
2026-04-09 14:30:51 +07: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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def _load_config(self):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Load configuration"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
config = load_addon_config()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Initialize search engines (sorted by priority)"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
from app.config import APIKeys
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 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))
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info(f"Configured Tavily search with {len(tavily_keys)} API keys")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-03-21 18:32:04 +08:00
|
|
|
|
# 2. SerpAPI
|
2026-02-05 00:25:38 +08:00
|
|
|
|
serpapi_keys = APIKeys.SERPAPI_KEYS
|
|
|
|
|
|
if serpapi_keys:
|
|
|
|
|
|
self._providers.append(SerpAPISearchProvider(serpapi_keys))
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info(f"Configured SerpAPI search with {len(serpapi_keys)} API keys")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-03-21 18:32:04 +08:00
|
|
|
|
# 3. Google CSE
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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))
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info("Configured Google CSE search")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-03-21 18:32:04 +08:00
|
|
|
|
# 4. Bing
|
2026-04-09 14:30:51 +07:00
|
|
|
|
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))
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info("Configured Bing search")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# 5. DuckDuckGo (Free Tips)
|
2026-02-05 00:25:38 +08:00
|
|
|
|
self._providers.append(DuckDuckGoSearchProvider())
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info("Configured DuckDuckGo search as the free fallback")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if len(self._providers) == 1:
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.warning("Only DuckDuckGo is available. Configure more search-engine API keys for better coverage.")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def is_available(self) -> bool:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Perform a search (compatible with old interface)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Search result list
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
|
|
|
|
|
limit = num_results if num_results else self.max_results
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Parse date_restrict to days
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if date_restrict and not days:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
if date_restrict.startswith("d"):
|
2026-02-05 00:25:38 +08:00
|
|
|
|
days = int(date_restrict[1:])
|
2026-04-09 14:30:51 +07:00
|
|
|
|
elif date_restrict.startswith("w"):
|
2026-02-05 00:25:38 +08:00
|
|
|
|
days = int(date_restrict[1:]) * 7
|
2026-04-09 14:30:51 +07:00
|
|
|
|
elif date_restrict.startswith("m"):
|
2026-02-05 00:25:38 +08:00
|
|
|
|
days = int(date_restrict[1:]) * 30
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
response = self.search_with_fallback(query, limit, days)
|
|
|
|
|
|
return response.to_list()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def search_with_fallback(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Perform a search (with automatic failover)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
query: search keyword
|
|
|
|
|
|
max_results: Maximum number of results returned
|
|
|
|
|
|
days: Search the last few days
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
SearchResponse object
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
2026-04-06 16:47:36 +07: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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
response = provider.search(query, max_results, days)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
if response.success and response.results:
|
|
|
|
|
|
return response
|
|
|
|
|
|
else:
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.warning(f"{provider.name} search failed: {response.error_message}. Trying the next engine.")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# all engines fail
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return SearchResponse(
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
results=[],
|
|
|
|
|
|
provider="None",
|
|
|
|
|
|
success=False,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
error_message="All search engines are unavailable or failed",
|
2026-02-05 00:25:38 +08:00
|
|
|
|
)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def search_stock_news(
|
2026-04-09 14:30:51 +07:00
|
|
|
|
self, stock_code: str, stock_name: str, market: str = "USStock", max_results: int = 5
|
2026-02-05 00:25:38 +08:00
|
|
|
|
) -> SearchResponse:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Search stock related news
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
stock_code: stock code
|
|
|
|
|
|
stock_name: stock name
|
|
|
|
|
|
market: market type
|
|
|
|
|
|
max_results: Maximum number of results returned
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
SearchResponse object
|
2026-02-05 00:25:38 +08:00
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Intelligently determine search time range
|
2026-02-05 00:25:38 +08:00
|
|
|
|
today_weekday = datetime.now().weekday()
|
2026-04-06 16:47:36 +07:00
|
|
|
|
if today_weekday == 0: # on Monday
|
2026-02-05 00:25:38 +08:00
|
|
|
|
search_days = 3
|
2026-04-06 16:47:36 +07:00
|
|
|
|
elif today_weekday >= 5: # weekend
|
2026-02-05 00:25:38 +08:00
|
|
|
|
search_days = 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
search_days = 1
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Build search queries based on market type
|
2026-02-27 01:57:04 +08:00
|
|
|
|
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"
|
2026-02-27 01:57:04 +08:00
|
|
|
|
elif market == "Forex":
|
|
|
|
|
|
query = f"{stock_name} {stock_code} forex news analysis"
|
2026-02-05 00:25:38 +08:00
|
|
|
|
else:
|
2026-02-27 01:57:04 +08:00
|
|
|
|
query = f"{stock_name} {stock_code} latest news"
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-08 07:27:26 +07:00
|
|
|
|
logger.info(f"Searching stock news: {stock_name}({stock_code}), market={market}, days={search_days}")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return self.search_with_fallback(query, max_results, search_days)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
def search_stock_events(
|
2026-04-09 14:30:51 +07:00
|
|
|
|
self, stock_code: str, stock_name: str, event_types: Optional[List[str]] = None
|
2026-02-05 00:25:38 +08:00
|
|
|
|
) -> SearchResponse:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
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-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
event_query = " OR ".join(event_types)
|
|
|
|
|
|
query = f"{stock_name} ({event_query})"
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-05 00:25:38 +08:00
|
|
|
|
return self.search_with_fallback(query, max_results=5, days=30)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Singleton instance
|
2026-02-05 00:25:38 +08:00
|
|
|
|
_search_service: Optional[SearchService] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_search_service() -> SearchService:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""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:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Reset the search service (for testing or after configuration updates)"""
|
2026-02-05 00:25:38 +08:00
|
|
|
|
global _search_service
|
|
|
|
|
|
_search_service = None
|