"""Google Custom Search Engine (CSE) API Client This module provides a client for Google's Custom Search JSON API, allowing users to bring their own API key (BYOK) for search functionality. """ import httpx from typing import Optional from dataclasses import dataclass from urllib.parse import urlparse from flask import render_template # Google Custom Search API endpoint CSE_API_URL = 'https://www.googleapis.com/customsearch/v1' class CSEException(Exception): """Exception raised for CSE API errors""" def __init__(self, message: str, code: int = 500, is_quota_error: bool = False): self.message = message self.code = code self.is_quota_error = is_quota_error super().__init__(self.message) @dataclass class CSEError: """Represents an error from the CSE API""" code: int message: str @property def is_quota_exceeded(self) -> bool: return self.code == 429 or 'quota' in self.message.lower() @property def is_invalid_key(self) -> bool: return self.code == 400 or 'invalid' in self.message.lower() @dataclass class CSEResult: """Represents a single search result from CSE API""" title: str link: str snippet: str display_link: str html_title: Optional[str] = None html_snippet: Optional[str] = None # Image-specific fields (populated for image search) image_url: Optional[str] = None thumbnail_url: Optional[str] = None image_width: Optional[int] = None image_height: Optional[int] = None context_link: Optional[str] = None # Page where image was found @dataclass class CSEResponse: """Represents a complete CSE API response""" results: list[CSEResult] total_results: str search_time: float query: str start_index: int is_image_search: bool = False error: Optional[CSEError] = None @property def has_error(self) -> bool: return self.error is not None @property def has_results(self) -> bool: return len(self.results) > 0 class CSEClient: """Client for Google Custom Search Engine API Usage: client = CSEClient(api_key='your-key', cse_id='your-cse-id') response = client.search('python programming') if response.has_error: print(f"Error: {response.error.message}") else: for result in response.results: print(f"{result.title}: {result.link}") """ def __init__(self, api_key: str, cse_id: str, timeout: float = 10.0): """Initialize CSE client Args: api_key: Google API key with Custom Search API enabled cse_id: Custom Search Engine ID (cx parameter) timeout: Request timeout in seconds """ self.api_key = api_key self.cse_id = cse_id self.timeout = timeout self._client = httpx.Client(timeout=timeout) def search( self, query: str, start: int = 1, num: int = 10, safe: str = 'off', language: str = '', country: str = '', search_type: str = '' ) -> CSEResponse: """Execute a search query against the CSE API Args: query: Search query string start: Starting result index (1-based, for pagination) num: Number of results to return (max 10) safe: Safe search setting ('off', 'medium', 'high') language: Language restriction (e.g., 'lang_en') country: Country restriction (e.g., 'countryUS') search_type: Type of search ('image' for image search, '' for web) Returns: CSEResponse with results or error information """ params = { 'key': self.api_key, 'cx': self.cse_id, 'q': query, 'start': start, 'num': min(num, 10), # API max is 10 'safe': safe, } # Add search type for image search if search_type == 'image': params['searchType'] = 'image' # Add optional parameters if language: # CSE uses 'lr' for language restrict params['lr'] = language if country: # CSE uses 'cr' for country restrict params['cr'] = country try: response = self._client.get(CSE_API_URL, params=params) data = response.json() # Check for API errors if 'error' in data: error_info = data['error'] return CSEResponse( results=[], total_results='0', search_time=0.0, query=query, start_index=start, error=CSEError( code=error_info.get('code', 500), message=error_info.get('message', 'Unknown error') ) ) # Parse successful response search_info = data.get('searchInformation', {}) items = data.get('items', []) is_image = search_type == 'image' results = [] for item in items: # Extract image-specific data if present image_data = item.get('image', {}) results.append(CSEResult( title=item.get('title', ''), link=item.get('link', ''), snippet=item.get('snippet', ''), display_link=item.get('displayLink', ''), html_title=item.get('htmlTitle'), html_snippet=item.get('htmlSnippet'), # Image fields image_url=item.get('link') if is_image else None, thumbnail_url=image_data.get('thumbnailLink'), image_width=image_data.get('width'), image_height=image_data.get('height'), context_link=image_data.get('contextLink') )) return CSEResponse( results=results, total_results=search_info.get('totalResults', '0'), search_time=float(search_info.get('searchTime', 0)), query=query, start_index=start, is_image_search=is_image ) except httpx.TimeoutException: return CSEResponse( results=[], total_results='0', search_time=0.0, query=query, start_index=start, error=CSEError(code=408, message='Request timed out') ) except httpx.RequestError as e: return CSEResponse( results=[], total_results='0', search_time=0.0, query=query, start_index=start, error=CSEError(code=500, message=f'Request failed: {str(e)}') ) except Exception as e: return CSEResponse( results=[], total_results='0', search_time=0.0, query=query, start_index=start, error=CSEError(code=500, message=f'Unexpected error: {str(e)}') ) def close(self): """Close the HTTP client""" self._client.close() def __enter__(self): return self def __exit__(self, *args): self.close() def cse_results_to_html(response: CSEResponse, query: str) -> str: """Convert CSE API response to HTML matching Whoogle's result format This generates HTML that mimics the structure expected by Whoogle's existing filter and result processing pipeline. Args: response: CSEResponse from the API query: Original search query Returns: HTML string formatted like Google search results """ if response.has_error: error = response.error if error.is_quota_exceeded: return _error_html( 'API Quota Exceeded', 'Your Google Custom Search API quota has been exceeded. ' 'Free tier allows 100 queries/day. Wait until midnight PT ' 'or enable billing in Google Cloud Console.' ) elif error.is_invalid_key: return _error_html( 'Invalid API Key', 'Your Google Custom Search API key is invalid. ' 'Please check your API key and CSE ID in settings.' ) else: return _error_html('Search Error', error.message) if not response.has_results: return _no_results_html(query) # Use different HTML structure for image vs web results if response.is_image_search: return _image_results_html(response, query) # Build HTML results matching Whoogle's expected structure results_html = [] for result in response.results: # Escape HTML in content title = _escape_html(result.title) snippet = _escape_html(result.snippet) link = result.link display_link = _escape_html(result.display_link) # Use HTML versions if available (they have bold tags for query terms) if result.html_title: title = result.html_title if result.html_snippet: snippet = result.html_snippet # Match the structure used by Google/mock results result_html = f'''
{_escape_html(message)}
No results found for {_escape_html(query)}