Coverage for src/local_deep_research/web_search_engines/engines/search_engine_google_pse.py: 98%

132 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1from ...security.secure_logging import logger 

2import random 

3import time 

4from typing import Any, Dict, List, Optional 

5 

6from langchain_core.language_models import BaseLLM 

7from requests.exceptions import RequestException 

8 

9from ...security.safe_requests import safe_get 

10from ..rate_limiting import RateLimitError 

11from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

12 

13 

14class GooglePSESearchEngine(BaseSearchEngine): 

15 """Google Programmable Search Engine implementation""" 

16 

17 # Mark as public search engine 

18 is_public = True 

19 egress_sensitivity = Sensitivity.NON_SENSITIVE 

20 egress_exposure = Exposure.EXPOSING 

21 # Mark as generic search engine (general web search) 

22 is_generic = True 

23 

24 def __init__( 

25 self, 

26 max_results: int = 10, 

27 region: str = "us", 

28 safe_search: bool = True, 

29 search_language: str = "English", 

30 api_key: Optional[str] = None, 

31 search_engine_id: Optional[str] = None, 

32 llm: Optional[BaseLLM] = None, 

33 include_full_content: bool = False, 

34 max_filtered_results: Optional[int] = None, 

35 settings_snapshot: Optional[Dict[str, Any]] = None, 

36 max_retries: int = 3, 

37 retry_delay: float = 2.0, 

38 **kwargs, 

39 ): 

40 """ 

41 Initialize the Google Programmable Search Engine. 

42 

43 Args: 

44 max_results: Maximum number of search results 

45 region: Region code for search results 

46 safe_search: Whether to enable safe search 

47 search_language: Language for search results 

48 api_key: Google API key (can also be set via LDR_SEARCH_ENGINE_WEB_GOOGLE_PSE_API_KEY env var or in UI settings) 

49 search_engine_id: Google CSE ID (can also be set via LDR_SEARCH_ENGINE_WEB_GOOGLE_PSE_ENGINE_ID env var or in UI settings) 

50 llm: Language model for relevance filtering 

51 include_full_content: Whether to include full webpage content in results 

52 max_filtered_results: Maximum number of results to keep after filtering 

53 max_retries: Maximum number of retry attempts for API requests 

54 retry_delay: Base delay in seconds between retry attempts 

55 **kwargs: Additional parameters (ignored but accepted for compatibility) 

56 """ 

57 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results 

58 super().__init__( 

59 llm=llm, 

60 max_filtered_results=max_filtered_results, 

61 max_results=max_results, 

62 include_full_content=include_full_content, 

63 settings_snapshot=settings_snapshot, 

64 **kwargs, 

65 ) 

66 

67 # Google PSE returns full content via its API (snippet + htmlSnippet), 

68 # so _init_full_search() is intentionally not called here. 

69 

70 # Retry configuration 

71 self.max_retries = max_retries 

72 self.retry_delay = retry_delay 

73 

74 # Rate limiting - keep track of last request time 

75 self.last_request_time: float = 0.0 

76 self.min_request_interval = ( 

77 0.5 # Minimum time between requests in seconds 

78 ) 

79 

80 # Language code mapping — Google PSE uses "zh-CN" for Chinese 

81 from ...utilities.search_utilities import LANGUAGE_CODE_MAP 

82 

83 language_code_mapping = {**LANGUAGE_CODE_MAP, "chinese": "zh-CN"} 

84 

85 # Get language code 

86 search_language = search_language.lower() 

87 self.language = language_code_mapping.get(search_language, "en") 

88 

89 # Safe search setting 

90 self.safe = "active" if safe_search else "off" 

91 

92 # Region/Country setting 

93 self.region = region 

94 

95 # API key and Search Engine ID - check params, env vars, or database 

96 from ...config.thread_settings import ( 

97 get_setting_from_snapshot, 

98 NoSettingsContextError, 

99 ) 

100 

101 self.api_key = api_key 

102 if not self.api_key: 

103 try: 

104 self.api_key = get_setting_from_snapshot( 

105 "search.engine.web.google_pse.api_key", 

106 default=None, 

107 settings_snapshot=self.settings_snapshot, 

108 ) 

109 except NoSettingsContextError: 

110 # No settings context available 

111 logger.debug( 

112 "No settings context available for Google PSE API key" 

113 ) 

114 pass 

115 

116 self.search_engine_id = search_engine_id 

117 if not self.search_engine_id: 

118 try: 

119 self.search_engine_id = get_setting_from_snapshot( 

120 "search.engine.web.google_pse.engine_id", 

121 default=None, 

122 settings_snapshot=self.settings_snapshot, 

123 ) 

124 except NoSettingsContextError: 

125 # No settings context available 

126 logger.debug( 

127 "No settings context available for Google PSE engine ID" 

128 ) 

129 pass 

130 

131 if not self.api_key: 

132 raise ValueError( 

133 "Google API key is required. Set it in the UI settings, use the api_key parameter, or set the LDR_SEARCH_ENGINE_WEB_GOOGLE_PSE_API_KEY environment variable." 

134 ) 

135 if not self.search_engine_id: 

136 raise ValueError( 

137 "Google Search Engine ID is required. Set it in the UI settings, use the search_engine_id parameter, or set the LDR_SEARCH_ENGINE_WEB_GOOGLE_PSE_ENGINE_ID environment variable." 

138 ) 

139 

140 # Validate connection and credentials 

141 self._validate_connection() 

142 

143 def _validate_connection(self): 

144 """Test the connection to ensure API key and Search Engine ID are valid""" 

145 try: 

146 # Make a minimal test query 

147 response = self._make_request("test") 

148 

149 # Check if we got a valid response 

150 if response.get("error"): 

151 error_msg = response["error"].get("message", "Unknown error") 

152 raise ValueError(f"Google PSE API error: {error_msg}") # noqa: TRY301 — except only adds logging before re-raise 

153 

154 # If we get here, the connection is valid 

155 logger.info("Google PSE connection validated successfully") 

156 return True 

157 

158 except Exception as e: 

159 # Log the error and re-raise a sanitized exception. Use 

160 # logger.warning with the api_key redacted from str(e) so 

161 # the upstream exception message — which can embed the key 

162 # in the URL — does not leak. The re-raised exception uses 

163 # `type(e)(safe_msg) from None` to preserve the original 

164 # exception type (for callers that dispatch on it) while 

165 # replacing the args with the redacted message and 

166 # suppressing the traceback chain (which carries the URL 

167 # in earlier frames). 

168 safe_msg = self._scrub_error(e) 

169 logger.warning( 

170 f"Error validating Google PSE connection: {safe_msg}" 

171 ) 

172 raise type(e)(safe_msg) from None 

173 

174 def _respect_rate_limit(self): 

175 """Ensure we don't exceed rate limits by adding appropriate delay between requests""" 

176 current_time = time.time() 

177 elapsed = current_time - self.last_request_time 

178 

179 # If we've made a request recently, wait until the minimum interval has passed 

180 if elapsed < self.min_request_interval: 

181 sleep_time = self.min_request_interval - elapsed 

182 logger.debug("Rate limiting: sleeping for {:.2f} s", sleep_time) 

183 time.sleep(sleep_time) 

184 

185 # Update the last request time 

186 self.last_request_time = time.time() 

187 

188 def _make_request(self, query: str, start_index: int = 1) -> Dict: 

189 """ 

190 Make a request to the Google PSE API with retry logic and rate limiting 

191 

192 Args: 

193 query: Search query string 

194 start_index: Starting index for pagination 

195 

196 Returns: 

197 JSON response from the API 

198 

199 Raises: 

200 RequestException: If all retry attempts fail 

201 """ 

202 # Base URL for the API 

203 url = "https://www.googleapis.com/customsearch/v1" 

204 

205 # Parameters for the request 

206 params = { 

207 "key": self.api_key, 

208 "cx": self.search_engine_id, 

209 "q": query, 

210 "num": min(10, self.max_results), # Max 10 per request 

211 "start": start_index, 

212 "safe": self.safe, 

213 "lr": f"lang_{self.language}", 

214 "gl": self.region, 

215 } 

216 

217 # Implement retry logic with exponential backoff 

218 attempt = 0 

219 last_exception: Exception | None = None 

220 

221 while attempt < self.max_retries: 

222 try: 

223 # Add jitter to retries after the first attempt 

224 if attempt > 0: 

225 # Security: random jitter for exponential backoff retry, not security-sensitive 

226 jitter = random.uniform(0.5, 1.5) 

227 sleep_time = ( 

228 self.retry_delay * (2 ** (attempt - 1)) * jitter 

229 ) 

230 logger.info( 

231 "Retry attempt {} / {} for query '{}'. Waiting {} s", 

232 attempt + 1, 

233 self.max_retries, 

234 query, 

235 f"{sleep_time:.2f}", 

236 ) 

237 time.sleep(sleep_time) 

238 

239 # Make the request 

240 logger.debug( 

241 "Making request to Google PSE API: {} (start_index={})", 

242 query, 

243 start_index, 

244 ) 

245 # Apply rate limiting before request 

246 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

247 self.engine_type 

248 ) 

249 

250 response = safe_get(url, params=params, timeout=10) 

251 

252 # Check for HTTP errors 

253 response.raise_for_status() 

254 

255 # Return the JSON response 

256 return response.json() # type: ignore[no-any-return] 

257 

258 except RequestException as e: 

259 error_msg = str(e) 

260 sanitized = self._sanitize_error_message(error_msg) 

261 logger.warning( 

262 "Request error on attempt {} / {}: {}", 

263 attempt + 1, 

264 self.max_retries, 

265 sanitized, 

266 ) 

267 

268 # Check for rate limiting patterns 

269 if ( 

270 "quota" in error_msg.lower() 

271 or "quotaExceeded" in error_msg 

272 or "dailyLimitExceeded" in error_msg 

273 or "rateLimitExceeded" in error_msg 

274 or "429" in error_msg 

275 or "403" in error_msg 

276 ): 

277 raise RateLimitError( 

278 f"Google PSE rate limit/quota exceeded: {sanitized}" 

279 ) 

280 

281 last_exception = e 

282 except Exception as e: 

283 error_msg = str(e) 

284 sanitized = self._sanitize_error_message(error_msg) 

285 logger.warning( 

286 "Error on attempt {} / {}: {}", 

287 attempt + 1, 

288 self.max_retries, 

289 sanitized, 

290 ) 

291 

292 # Check for rate limiting patterns in general errors 

293 if "quota" in error_msg.lower() or "limit" in error_msg.lower(): 

294 raise RateLimitError( 

295 f"Google PSE error (possible rate limit): {sanitized}" 

296 ) 

297 

298 last_exception = e 

299 

300 attempt += 1 

301 

302 # If we get here, all retries failed 

303 error_msg = f"Failed to get response from Google PSE API after {self.max_retries} attempts" 

304 logger.error(error_msg) 

305 

306 if last_exception: 306 ↛ 310line 306 didn't jump to line 310 because the condition on line 306 was always true

307 raise RequestException( 

308 f"{error_msg}: {self._sanitize_error_message(str(last_exception))}" 

309 ) 

310 raise RequestException(error_msg) 

311 

312 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

313 """Get search result previews/snippets""" 

314 results = [] 

315 

316 # Google PSE API returns a maximum of 10 results per request 

317 # We may need to make multiple requests to get the desired number 

318 start_index = 1 

319 total_results = 0 

320 

321 while total_results < self.max_results: 321 ↛ 366line 321 didn't jump to line 366 because the condition on line 321 was always true

322 try: 

323 response = self._make_request(query, start_index) 

324 

325 # Break if no items 

326 if "items" not in response: 

327 break 

328 

329 items = response.get("items", []) 

330 

331 # Process each result 

332 for item in items: 

333 title = item.get("title", "") 

334 snippet = item.get("snippet", "") 

335 url = item.get("link", "") 

336 

337 # Skip results without URL 

338 if not url: 

339 continue 

340 

341 results.append( 

342 { 

343 "title": title, 

344 "snippet": snippet, 

345 "link": url, 

346 "source": "Google Programmable Search", 

347 } 

348 ) 

349 

350 total_results += 1 

351 if total_results >= self.max_results: 

352 break 

353 

354 # Check if there are more results 

355 if not items or total_results >= self.max_results: 

356 break 

357 

358 # Update start index for next request 

359 start_index += len(items) 

360 

361 except Exception as e: 

362 safe_msg = self._scrub_error(e) 

363 logger.warning(f"Error getting search results: {safe_msg}") 

364 break 

365 

366 logger.info( 

367 "Retrieved {} search results for query: '{}'", len(results), query 

368 ) 

369 return results