Coverage for src/local_deep_research/web_search_engines/engines/search_engine_scaleserp.py: 99%

79 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1from ...security.secure_logging import logger 

2from typing import Any, Dict, List, Optional 

3import requests 

4 

5from langchain_core.language_models import BaseLLM 

6 

7from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

8from ..rate_limiting import RateLimitError 

9from ...security import safe_get 

10 

11 

12class ScaleSerpSearchEngine(BaseSearchEngine): 

13 """Google search engine implementation using ScaleSerp API with caching support""" 

14 

15 # Mark as public search engine 

16 is_public = True 

17 egress_sensitivity = Sensitivity.NON_SENSITIVE 

18 egress_exposure = Exposure.EXPOSING 

19 # Mark as generic search engine (general web search via Google) 

20 is_generic = True 

21 

22 def __init__( 

23 self, 

24 max_results: int = 10, 

25 location: str = "United States", 

26 language: str = "en", 

27 device: str = "desktop", 

28 safe_search: bool = True, 

29 api_key: Optional[str] = None, 

30 llm: Optional[BaseLLM] = None, 

31 include_full_content: bool = False, 

32 max_filtered_results: Optional[int] = None, 

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

34 enable_cache: bool = True, 

35 **kwargs, 

36 ): 

37 """ 

38 Initialize the ScaleSerp search engine. 

39 

40 Args: 

41 max_results: Maximum number of search results (default 10, max 100) 

42 location: Location for localized results (e.g., 'United States', 'London,England,United Kingdom') 

43 language: Language code for results (e.g., 'en', 'es', 'fr') 

44 device: Device type for search ('desktop' or 'mobile') 

45 safe_search: Whether to enable safe search 

46 api_key: ScaleSerp API key (can also be set in settings) 

47 llm: Language model for relevance filtering 

48 include_full_content: Whether to include full webpage content in results 

49 max_filtered_results: Maximum number of results to keep after filtering 

50 settings_snapshot: Settings snapshot for thread context 

51 enable_cache: Whether to use ScaleSerp's 1-hour caching (saves costs for repeated searches) 

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

53 """ 

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

55 super().__init__( 

56 llm=llm, 

57 max_filtered_results=max_filtered_results, 

58 max_results=max_results, 

59 include_full_content=include_full_content, 

60 settings_snapshot=settings_snapshot, 

61 ) 

62 self.location = location 

63 self.language = language 

64 self.device = device 

65 self.safe_search = safe_search 

66 self.enable_cache = enable_cache # ScaleSerp's unique caching feature 

67 

68 # Get API key - check params, settings, or env vars 

69 scaleserp_api_key = self._resolve_api_key( 

70 api_key, 

71 "search.engine.web.scaleserp.api_key", 

72 engine_name="ScaleSerp", 

73 settings_snapshot=settings_snapshot, 

74 ) 

75 

76 self.api_key = scaleserp_api_key 

77 self.base_url = "https://api.scaleserp.com/search" 

78 

79 # Initialize per-query attributes (reset in _get_previews per search) 

80 self._knowledge_graph = None 

81 

82 # If full content is requested, initialize FullSearchResults 

83 self._init_full_search( 

84 web_search=None, # We'll handle the search ourselves 

85 language=language, 

86 max_results=max_results, 

87 region=location, 

88 time_period=None, 

89 safe_search="Moderate" if safe_search else "Off", 

90 ) 

91 

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

93 """ 

94 Get preview information from ScaleSerp API. 

95 

96 Args: 

97 query: The search query 

98 

99 Returns: 

100 List of preview dictionaries 

101 """ 

102 logger.info("Getting search results from ScaleSerp API") 

103 

104 # Reset per-query attributes to prevent leakage between searches 

105 self._knowledge_graph = None 

106 

107 try: 

108 # Build request parameters 

109 params = { 

110 "api_key": self.api_key, 

111 "q": query, 

112 "num": min(self.max_results, 100), # ScaleSerp max is 100 

113 "location": self.location, 

114 "hl": self.language, 

115 "device": self.device, 

116 } 

117 

118 # Add safe search if enabled 

119 if self.safe_search: 

120 params["safe"] = "on" 

121 

122 # ScaleSerp automatically caches identical queries for 1 hour 

123 # Cached results are served instantly and don't consume API credits 

124 if self.enable_cache: 

125 params["output"] = ( 

126 "json" # Ensure JSON output for cache detection 

127 ) 

128 logger.debug( 

129 "ScaleSerp caching enabled - identical searches within 1 hour are free" 

130 ) 

131 

132 # Apply rate limiting before request 

133 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

134 self.engine_type 

135 ) 

136 

137 # Make API request 

138 response = safe_get(self.base_url, params=params, timeout=30) 

139 

140 # Check for rate limits 

141 self._raise_if_rate_limit(response.status_code) 

142 

143 response.raise_for_status() 

144 

145 data = response.json() 

146 

147 # Extract organic results 

148 organic_results = data.get("organic_results", []) 

149 

150 # Format results as previews 

151 previews = [] 

152 

153 # Check if results were served from cache for monitoring 

154 from_cache = data.get("request_info", {}).get("cached", False) 

155 

156 for idx, result in enumerate(organic_results): 

157 # Extract display link — ScaleSerp uses truncated URL as fallback 

158 # `or ""` guards against a JSON null link: the fallback slice 

159 # is evaluated eagerly and would raise TypeError on None. 

160 link = result.get("link") or "" 

161 display_link = self._extract_display_link( 

162 link, fallback=link[:50] 

163 ) 

164 

165 preview = { 

166 "id": idx, 

167 "title": result.get("title", ""), 

168 "link": link, 

169 "snippet": result.get("snippet", ""), 

170 "displayed_link": display_link, 

171 "position": result.get("position", idx + 1), 

172 "from_cache": from_cache, # Add cache status for monitoring 

173 } 

174 

175 # Store full ScaleSerp result for later 

176 preview["_full_result"] = result 

177 

178 # Include rich snippets if available 

179 if "rich_snippet" in result: 

180 preview["rich_snippet"] = result["rich_snippet"] 

181 

182 # Include date if available 

183 if "date" in result: 

184 preview["date"] = result["date"] 

185 

186 # Include sitelinks if available 

187 if "sitelinks" in result: 

188 preview["sitelinks"] = result["sitelinks"] 

189 

190 previews.append(preview) 

191 

192 # Store the previews for potential full content retrieval 

193 self._search_results = previews 

194 

195 # Store knowledge graph if available 

196 if "knowledge_graph" in data: 

197 self._knowledge_graph = data["knowledge_graph"] 

198 logger.info( 

199 f"Found knowledge graph for query: {data['knowledge_graph'].get('title', 'Unknown')}" 

200 ) 

201 

202 # Log if result was served from cache 

203 if from_cache: 

204 logger.debug( 

205 "Result served from ScaleSerp cache - no API credit used!" 

206 ) 

207 

208 return previews 

209 

210 except RateLimitError: 

211 raise # Re-raise rate limit errors 

212 except requests.exceptions.RequestException as e: 

213 safe_msg = self._scrub_error(e) 

214 logger.warning( 

215 f"Error getting ScaleSerp API results: {safe_msg}. " 

216 "Check API docs: https://docs.scaleserp.com" 

217 ) 

218 self._raise_if_rate_limit(e) 

219 return [] 

220 except Exception as e: 

221 safe_msg = self._scrub_error(e) 

222 logger.warning( 

223 f"Unexpected error getting ScaleSerp API results: {safe_msg}" 

224 ) 

225 return [] 

226 

227 def _get_full_content( 

228 self, relevant_items: List[Dict[str, Any]] 

229 ) -> List[Dict[str, Any]]: 

230 """ 

231 Get full content for the relevant search results. 

232 Extends base implementation to include knowledge graph data. 

233 

234 Args: 

235 relevant_items: List of relevant preview dictionaries 

236 

237 Returns: 

238 List of result dictionaries with full content if requested 

239 """ 

240 results = super()._get_full_content(relevant_items) 

241 

242 # Include knowledge graph if available 

243 if results and hasattr(self, "_knowledge_graph"): 243 ↛ 246line 243 didn't jump to line 246 because the condition on line 243 was always true

244 results[0]["knowledge_graph"] = self._knowledge_graph 

245 

246 return results 

247 

248 def _temp_attributes(self): 

249 """Return list of temporary attribute names to clean up after run().""" 

250 return super()._temp_attributes() + [ 

251 "_knowledge_graph", 

252 ]