Coverage for src/local_deep_research/web_search_engines/engines/search_engine_zenodo.py: 96%
175 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""Zenodo search engine for open research data and publications."""
3import html
4import re
5from typing import Any, Dict, List, Optional
7import requests
8from langchain_core.language_models import BaseLLM
10from ...constants import USER_AGENT
11from ...security.safe_requests import safe_get
12from ...security.secure_logging import logger
13from ..rate_limiting import RateLimitError
14from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity
17class ZenodoSearchEngine(BaseSearchEngine):
18 """
19 Zenodo search engine for open research data and publications.
21 Provides access to millions of research outputs including datasets,
22 software, publications, and more. No authentication required for search.
23 """
25 is_public = True
26 egress_sensitivity = Sensitivity.NON_SENSITIVE
27 egress_exposure = Exposure.EXPOSING
28 is_generic = False
29 is_scientific = True
30 is_code = False
31 is_lexical = True
32 needs_llm_relevance_filter = True
34 def __init__(
35 self,
36 max_results: int = 10,
37 resource_type: Optional[str] = None,
38 access_right: Optional[str] = None,
39 communities: Optional[str] = None,
40 sort: str = "bestmatch",
41 llm: Optional[BaseLLM] = None,
42 max_filtered_results: Optional[int] = None,
43 settings_snapshot: Optional[Dict[str, Any]] = None,
44 **kwargs,
45 ):
46 """
47 Initialize the Zenodo search engine.
49 Args:
50 max_results: Maximum number of search results
51 resource_type: Filter by type (dataset, software, publication, etc.)
52 access_right: Filter by access (open, closed, embargoed, restricted)
53 communities: Filter by Zenodo community
54 sort: Sort order (bestmatch, mostrecent, -mostrecent)
55 llm: Language model for relevance filtering
56 max_filtered_results: Maximum results after filtering
57 settings_snapshot: Settings snapshot for thread context
58 """
59 super().__init__(
60 llm=llm,
61 max_filtered_results=max_filtered_results,
62 max_results=max_results,
63 settings_snapshot=settings_snapshot,
64 **kwargs,
65 )
67 self.resource_type = resource_type
68 self.access_right = access_right
69 self.communities = communities
70 self.sort = sort
72 self.base_url = "https://zenodo.org"
73 self.search_url = f"{self.base_url}/api/records"
75 # User-Agent header for API requests
76 self.headers = {"User-Agent": USER_AGENT}
78 def _build_query_params(self, query: str) -> Dict[str, Any]:
79 """Build query parameters for the API request."""
80 params = {
81 "q": query,
82 "size": min(self.max_results, 100),
83 "sort": self.sort,
84 }
86 if self.resource_type:
87 params["type"] = self.resource_type
89 if self.access_right:
90 params["access_right"] = self.access_right
92 if self.communities:
93 params["communities"] = self.communities
95 return params
97 def _parse_creators(self, creators: List[Dict]) -> List[str]:
98 """Parse creator/author information."""
99 result = []
100 for creator in creators[:5]:
101 name = creator.get("name", "")
102 if name: 102 ↛ 100line 102 didn't jump to line 100 because the condition on line 102 was always true
103 result.append(name)
104 return result
106 def _get_resource_type_label(self, resource_type: Dict) -> str:
107 """Get human-readable resource type label."""
108 if not resource_type:
109 return "Unknown"
110 return (
111 resource_type.get("title") or resource_type.get("type") or "Unknown"
112 )
114 def _get_previews(self, query: str) -> List[Dict[str, Any]]:
115 """
116 Get preview information for Zenodo records.
118 Args:
119 query: The search query
121 Returns:
122 List of preview dictionaries
123 """
124 logger.info(f"Getting Zenodo previews for query: {query}")
126 # Apply rate limiting
127 self._last_wait_time = self.rate_tracker.apply_rate_limit(
128 self.engine_type
129 )
131 try:
132 params = self._build_query_params(query)
133 response = safe_get(
134 self.search_url,
135 params=params,
136 headers=self.headers,
137 timeout=30,
138 )
140 self._raise_if_rate_limit(response.status_code)
142 response.raise_for_status()
143 data = response.json()
145 hits = data.get("hits", {})
146 results = hits.get("hits", [])
147 total = hits.get("total", 0)
148 logger.info(
149 f"Found {total} Zenodo results, returning {len(results)}"
150 )
152 previews = []
153 for record in results[: self.max_results]:
154 try:
155 record_id = record.get("id")
156 metadata = record.get("metadata", {})
158 title = metadata.get("title", "Untitled")
160 # Get creators
161 creators = self._parse_creators(
162 metadata.get("creators", [])
163 )
165 # Get description/abstract
166 description = metadata.get("description", "")
167 # Strip HTML tags and decode entities for snippet
168 if description:
169 description = html.unescape(
170 re.sub(r"<[^>]+>", "", description)
171 )
172 description = description[:500]
174 # Get DOI
175 doi = metadata.get("doi", "")
177 # Get publication date
178 pub_date = metadata.get("publication_date", "")
180 # Get resource type
181 resource_type = metadata.get("resource_type", {})
182 type_label = self._get_resource_type_label(resource_type)
184 # Get access right
185 access = metadata.get("access_right", "open")
187 # Get keywords
188 keywords = metadata.get("keywords", [])[:10]
190 # Get license
191 license_info = metadata.get("license", {})
192 license_id = (
193 license_info.get("id", "") if license_info else ""
194 )
196 # Get links
197 links = record.get("links", {})
198 record_url = links.get(
199 "self_html", f"{self.base_url}/records/{record_id}"
200 )
201 doi_url = links.get("doi", "")
203 # Build snippet
204 snippet_parts = []
205 if creators:
206 snippet_parts.append(f"By {', '.join(creators[:2])}")
207 if type_label: 207 ↛ 220line 207 didn't jump to line 220 because the condition on line 207 was always true
208 type_str = f"Type: {type_label}"
209 # Add access status and license inline
210 access_license = []
211 if access: 211 ↛ 215line 211 didn't jump to line 215 because the condition on line 211 was always true
212 access_license.append(
213 access.replace("_", " ").title()
214 )
215 if license_id:
216 access_license.append(license_id.upper())
217 if access_license: 217 ↛ 219line 217 didn't jump to line 219 because the condition on line 217 was always true
218 type_str += f" ({', '.join(access_license)})"
219 snippet_parts.append(type_str)
220 if pub_date:
221 snippet_parts.append(f"Published: {pub_date}")
222 if description:
223 snippet_parts.append(description[:200])
224 snippet = ". ".join(snippet_parts)
226 preview = {
227 "id": str(record_id),
228 "title": title,
229 "link": record_url,
230 "snippet": snippet,
231 "authors": creators,
232 "doi": doi,
233 "doi_url": doi_url,
234 "publication_date": pub_date,
235 "resource_type": type_label,
236 "access_right": access,
237 "keywords": keywords,
238 "license": license_id,
239 "description": description,
240 "source": "Zenodo",
241 "_raw": record,
242 }
244 previews.append(preview)
246 except Exception as e:
247 safe_msg = self._scrub_error(e)
248 logger.exception(
249 f"Error parsing Zenodo record ({type(e).__name__}): {safe_msg}"
250 )
251 continue
253 return previews
255 except (requests.RequestException, ValueError) as e:
256 safe_msg = self._scrub_error(e)
257 logger.exception(
258 f"Zenodo API request failed ({type(e).__name__}): {safe_msg}"
259 )
260 self._raise_if_rate_limit(e)
261 return []
263 def _get_full_content(
264 self, relevant_items: List[Dict[str, Any]]
265 ) -> List[Dict[str, Any]]:
266 """
267 Get full content for the relevant Zenodo records.
269 Args:
270 relevant_items: List of relevant preview dictionaries
272 Returns:
273 List of result dictionaries with full content
274 """
275 logger.info(
276 f"Getting full content for {len(relevant_items)} Zenodo records"
277 )
279 results = []
280 for item in relevant_items:
281 result = item.copy()
283 raw = item.get("_raw", {})
284 if raw:
285 metadata = raw.get("metadata", {})
287 # Get full description (strip HTML tags and decode entities)
288 desc = metadata.get("description", "")
289 if desc:
290 desc = html.unescape(re.sub(r"<[^>]+>", "", desc))
291 result["description"] = desc
293 # Get all keywords
294 result["keywords"] = metadata.get("keywords", [])
296 # Get related identifiers
297 result["related_identifiers"] = metadata.get(
298 "related_identifiers", []
299 )
301 # Get files info
302 files = raw.get("files") or []
303 result["files"] = [
304 {
305 "filename": f.get("key", ""),
306 "size": f.get("size", 0),
307 "checksum": f.get("checksum", ""),
308 }
309 for f in files[:10]
310 ]
312 # Get references
313 result["references"] = metadata.get("references", [])
315 # Build content summary
316 content_parts = []
317 if result.get("authors"):
318 content_parts.append(
319 f"Authors: {', '.join(result['authors'])}"
320 )
321 if result.get("resource_type"):
322 content_parts.append(f"Type: {result['resource_type']}")
323 if result.get("publication_date"):
324 content_parts.append(
325 f"Published: {result['publication_date']}"
326 )
327 if result.get("doi"):
328 content_parts.append(f"DOI: {result['doi']}")
329 if result.get("keywords"):
330 content_parts.append(
331 f"Keywords: {', '.join(str(k) for k in result['keywords'][:5])}"
332 )
333 if result.get("license"):
334 content_parts.append(f"License: {result['license']}")
335 if result.get("description"):
336 content_parts.append(
337 f"\nDescription: {result['description'][:1000]}"
338 )
340 result["content"] = "\n".join(content_parts)
342 # Clean up internal fields
343 if "_raw" in result:
344 del result["_raw"]
346 results.append(result)
348 return results
350 def get_record(self, record_id: int) -> Optional[Dict[str, Any]]:
351 """
352 Get a specific record by Zenodo ID.
354 Args:
355 record_id: The Zenodo record ID
357 Returns:
358 Record dictionary or None
359 """
360 try:
361 url = f"{self.search_url}/{record_id}"
362 response = safe_get(url, headers=self.headers, timeout=30)
363 self._raise_if_rate_limit(response.status_code)
364 response.raise_for_status()
365 return response.json() # type: ignore[no-any-return]
366 except RateLimitError:
367 raise
368 except Exception as e:
369 safe_msg = self._scrub_error(e)
370 logger.exception(
371 f"Error fetching Zenodo record {record_id} ({type(e).__name__}): {safe_msg}"
372 )
373 return None
375 def search_datasets(self, query: str) -> List[Dict[str, Any]]:
376 """
377 Search specifically for datasets.
379 Args:
380 query: The search query
382 Returns:
383 List of matching datasets
384 """
385 original_type = self.resource_type
386 try:
387 self.resource_type = "dataset"
388 return self.run(query)
389 finally:
390 self.resource_type = original_type
392 def search_software(self, query: str) -> List[Dict[str, Any]]:
393 """
394 Search specifically for software.
396 Args:
397 query: The search query
399 Returns:
400 List of matching software records
401 """
402 original_type = self.resource_type
403 try:
404 self.resource_type = "software"
405 return self.run(query)
406 finally:
407 self.resource_type = original_type