Coverage for src/local_deep_research/web_search_engines/engines/search_engine_tinyfish.py: 85%

117 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1from typing import Any, Dict, List, Optional 

2from urllib.parse import urlparse 

3 

4import requests 

5from langchain_core.language_models import BaseLLM 

6 

7from ...security import safe_get, safe_post 

8from ...security.secure_logging import logger 

9from ..rate_limiting import RateLimitError 

10from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

11 

12 

13class TinyFishSearchEngine(BaseSearchEngine): 

14 """TinyFish Search + Fetch API search engine implementation.""" 

15 

16 is_public = True 

17 egress_sensitivity = Sensitivity.NON_SENSITIVE 

18 egress_exposure = Exposure.EXPOSING 

19 is_generic = True 

20 

21 SEARCH_URL = "https://api.search.tinyfish.ai" 

22 FETCH_URL = "https://api.fetch.tinyfish.ai" 

23 SEARCH_TIMEOUT = 10 

24 FETCH_TIMEOUT = 150 

25 MAX_QUERY_LEN = 400 

26 MAX_FETCH_URLS = 10 

27 

28 def __init__( 

29 self, 

30 max_results: int = 10, 

31 location: str = "US", 

32 language: str = "en", 

33 api_key: Optional[str] = None, 

34 llm: Optional[BaseLLM] = None, 

35 include_full_content: bool = True, 

36 fetch_format: str = "markdown", 

37 max_filtered_results: Optional[int] = None, 

38 search_snippets_only: Optional[bool] = None, 

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

40 **kwargs, 

41 ): 

42 """ 

43 Initialize the TinyFish search engine. 

44 

45 Args: 

46 max_results: Maximum number of search results 

47 location: Country code for geo-targeted search results 

48 language: Language code for search results 

49 api_key: TinyFish API key 

50 llm: Language model for relevance filtering 

51 include_full_content: Whether to fetch extracted page content 

52 fetch_format: TinyFish Fetch output format: markdown, html, or json 

53 max_filtered_results: Maximum results to keep after filtering 

54 search_snippets_only: Whether to return only search snippets 

55 settings_snapshot: Settings snapshot for thread context 

56 **kwargs: Additional parameters ignored for compatibility 

57 """ 

58 if search_snippets_only is None: 

59 search_snippets_only = not include_full_content 

60 

61 super().__init__( 

62 llm=llm, 

63 max_filtered_results=max_filtered_results, 

64 max_results=max_results, 

65 include_full_content=include_full_content, 

66 search_snippets_only=search_snippets_only, 

67 settings_snapshot=settings_snapshot, 

68 ) 

69 self.location = self._normalize_location(location) 

70 self.language = self._normalize_language(language) 

71 self.fetch_format = fetch_format 

72 self.api_key = self._resolve_api_key( 

73 api_key, 

74 "search.engine.web.tinyfish.api_key", 

75 engine_name="TinyFish", 

76 settings_snapshot=settings_snapshot, 

77 ) 

78 

79 @staticmethod 

80 def _normalize_location(location: str) -> str: 

81 return (location or "US").strip().upper() 

82 

83 @staticmethod 

84 def _normalize_language(language: str) -> str: 

85 language_value = (language or "en").strip() 

86 language_map = { 

87 "english": "en", 

88 "spanish": "es", 

89 "french": "fr", 

90 "german": "de", 

91 "italian": "it", 

92 "portuguese": "pt", 

93 "japanese": "ja", 

94 "chinese": "zh", 

95 "korean": "ko", 

96 } 

97 return language_map.get(language_value.lower(), language_value) 

98 

99 def _headers(self) -> Dict[str, str]: 

100 return {"X-API-Key": self.api_key} 

101 

102 def _raise_request_exception_if_rate_limited( 

103 self, error: requests.exceptions.RequestException 

104 ) -> None: 

105 response = getattr(error, "response", None) 

106 status_code = getattr(response, "status_code", None) 

107 if status_code is not None: 

108 self._raise_if_rate_limit(status_code) 

109 

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

111 """ 

112 Get preview information from TinyFish Search. 

113 

114 Args: 

115 query: The search query 

116 

117 Returns: 

118 List of preview dictionaries 

119 """ 

120 logger.info("Getting search results from TinyFish") 

121 

122 try: 

123 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

124 self.engine_type 

125 ) 

126 

127 response = safe_get( 

128 self.SEARCH_URL, 

129 params={ 

130 "query": query[: self.MAX_QUERY_LEN], 

131 "location": self.location, 

132 "language": self.language, 

133 }, 

134 headers=self._headers(), 

135 timeout=self.SEARCH_TIMEOUT, 

136 ) 

137 self._raise_if_rate_limit(response.status_code) 

138 response.raise_for_status() 

139 

140 data = response.json() 

141 results = data.get("results", [])[: self.max_results] 

142 

143 previews = [] 

144 for idx, result in enumerate(results): 

145 link = result.get("url", "") 

146 display_link = result.get("site_name", "") 

147 if not display_link and link: 

148 try: 

149 display_link = urlparse(link).netloc or "" 

150 except Exception: 

151 logger.debug("Failed to parse URL for display") 

152 

153 preview = { 

154 "id": link or str(idx), 

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

156 "link": link, 

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

158 "displayed_link": display_link, 

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

160 } 

161 preview["_full_result"] = result 

162 previews.append(preview) 

163 

164 self._search_results = previews 

165 return previews 

166 

167 except RateLimitError: 

168 raise 

169 except requests.exceptions.RequestException as e: 

170 # The search query rides in the request URL (params=query=...), so 

171 # the requests exception string/traceback would leak it into logs. 

172 # Log only a static message plus the HTTP status — never the 

173 # URL-bearing error. (_scrub_error does not strip a plain query=.) 

174 status_code = getattr( 

175 getattr(e, "response", None), "status_code", None 

176 ) 

177 logger.warning( 

178 f"Error getting TinyFish search results (status={status_code})" 

179 ) 

180 self._raise_request_exception_if_rate_limited(e) 

181 return [] 

182 except Exception as e: 

183 safe_msg = self._scrub_error(e) 

184 logger.exception( 

185 f"Unexpected error getting TinyFish search results ({type(e).__name__}): {safe_msg}" 

186 ) 

187 return [] 

188 

189 def _get_full_content( 

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

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

192 """ 

193 Fetch clean page content from TinyFish Fetch for selected results. 

194 

195 Args: 

196 relevant_items: List of relevant preview dictionaries 

197 

198 Returns: 

199 List of result dictionaries enriched with extracted content 

200 """ 

201 results = [ 

202 {k: v for k, v in item.items() if k != "_full_result"} 

203 for item in relevant_items 

204 ] 

205 if not self.include_full_content: 

206 return results 

207 

208 url_to_result = { 

209 result.get("link") or result.get("url"): result 

210 for result in results 

211 if result.get("link") or result.get("url") 

212 } 

213 urls = list(url_to_result.keys())[: self.MAX_FETCH_URLS] 

214 if not urls: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 return results 

216 

217 try: 

218 response = safe_post( 

219 self.FETCH_URL, 

220 json={"urls": urls, "format": self.fetch_format}, 

221 headers={**self._headers(), "Content-Type": "application/json"}, 

222 timeout=self.FETCH_TIMEOUT, 

223 ) 

224 self._raise_if_rate_limit(response.status_code) 

225 response.raise_for_status() 

226 

227 data = response.json() 

228 for page in data.get("results", []): 

229 url = page.get("url") or page.get("final_url") 

230 result = url_to_result.get(url) 

231 if result is None: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 result = url_to_result.get(page.get("final_url")) 

233 if result is None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 continue 

235 

236 content = page.get("text") 

237 if content: 237 ↛ 239line 237 didn't jump to line 239 because the condition on line 237 was always true

238 result["content"] = content 

239 for field in ("final_url", "description", "language"): 

240 if page.get(field): 240 ↛ 239line 240 didn't jump to line 239 because the condition on line 240 was always true

241 result[field] = page[field] 

242 if page.get("title") and not result.get("title"): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

243 result["title"] = page["title"] 

244 

245 errors = data.get("errors", []) 

246 if errors: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 logger.info( 

248 f"TinyFish Fetch returned {len(errors)} per-URL errors" 

249 ) 

250 

251 except RateLimitError: 

252 raise 

253 except requests.exceptions.RequestException as e: 

254 # Mirror _get_previews: avoid logging the URL-bearing exception 

255 # string; record a static message plus the HTTP status only. 

256 status_code = getattr( 

257 getattr(e, "response", None), "status_code", None 

258 ) 

259 logger.warning( 

260 f"Error fetching TinyFish page content (status={status_code})" 

261 ) 

262 self._raise_request_exception_if_rate_limited(e) 

263 except Exception as e: 

264 safe_msg = self._scrub_error(e) 

265 logger.exception( 

266 f"Unexpected error fetching TinyFish page content ({type(e).__name__}): {safe_msg}" 

267 ) 

268 

269 return results