""" Search service v2.0 - Enhanced version of search service Integrate multiple search engines and support API Key rotation and failover 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 Reference: 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: """Search result data class""" title: str snippet: str # summary url: str source: str # Source website published_date: Optional[str] = None sentiment: str = 'neutral' # Emotion tags def to_text(self) -> str: """Convert to text format""" 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]: """Convert to dictionary""" return { 'title': self.title, 'link': self.url, 'snippet': self.snippet, 'source': self.source, 'published': self.published_date or '', 'sentiment': self.sentiment, } @dataclass class SearchResponse: """search response""" query: str results: List[SearchResult] provider: str # search engine used success: bool = True error_message: Optional[str] = None search_time: float = 0.0 # Search time (seconds) def to_context(self, max_results: int = 5) -> str: """Transform search results into context that can be used for AI analysis""" 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})"] 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]]: """Convert to list format (compatible with old interface)""" return [r.to_dict() for r in self.results] class BaseSearchProvider(ABC): """Search engine base class""" def __init__(self, api_keys: List[str], name: str): """ Initialize search engine Args: api_keys: API Key list (supports multiple key load balancing) name: search engine 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: """Check if there is an available API Key""" return bool(self._api_keys) def _get_next_key(self) -> Optional[str]: """ Get the next available API Key (load balancing) Strategy: polling + skip keys with too many errors """ if not self._key_cycle: return None # Try at most all keys for _ in range(len(self._api_keys)): key = next(self._key_cycle) # Skip keys with too many errors (more than 3 times) 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") 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: """Record successful use""" self._key_usage[key] = self._key_usage.get(key, 0) + 1 # Decrement error count on success if key in self._key_errors and self._key_errors[key] > 0: self._key_errors[key] -= 1 def _record_error(self, key: str) -> None: """Log errors""" 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]}") @abstractmethod def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: """Perform a search (subclass implementation)""" pass def search(self, query: str, max_results: int = 5, days: int = 7) -> SearchResponse: """ Perform a search Args: query: search keyword max_results: Maximum number of results returned days: Search the time range of the last few days (default 7 days) Returns: SearchResponse object """ 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" ) 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}] search '{query}' succeeded, returned {len(response.results)} results in {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}] search '{query}' failed: {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: """Extract domain name from URL as source""" try: parsed = urlparse(url) domain = parsed.netloc.replace('www.', '') return domain or 'Unknown source' except: return 'Unknown source' 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/ """ 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: """Perform a Tavily search""" try: from tavily import TavilyClient except ImportError: # If tavily-python is not installed, use the REST API return self._do_search_rest(query, api_key, max_results, days) try: client = TavilyClient(api_key=api_key) # Perform a search response = client.search( query=query, search_depth="advanced", max_results=max_results, include_answer=False, include_raw_content=False, days=days, ) # Parse results 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 quota exhausted: {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: """Performing Tavily searches using the REST API (alternative)""" 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 search engine Features: -Support multiple search engines such as Google, Bing, Baidu, etc. - Free version 100 requests per month Documentation: 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: """Perform a SerpAPI search""" 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: """Performing SerpAPI searches using the REST API""" 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) search engine""" 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: """Perform a Google search""" 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 is not configured" ) try: url = "https://www.googleapis.com/customsearch/v1" params = { 'key': api_key, 'cx': self._cx, 'q': query, 'num': min(max_results, 10), } # Add time limit 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 quota exhausted" ) 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 search engine""" 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: """Perform a Bing search""" 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 search engine (free, no API Key required)""" def __init__(self): super().__init__(['free'], "DuckDuckGo") 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', []) 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', )) # 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 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 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']*class="result-link"[^>]*href="([^"]*)"[^>]*>([^<]*)' snippet_pattern = r'