Coverage for src/local_deep_research/research_library/downloaders/semantic_scholar.py: 97%
75 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Semantic Scholar PDF Downloader
4Downloads PDFs from Semantic Scholar using their API to find open access PDFs.
5"""
7import re
8from typing import Optional
9from urllib.parse import urlparse
11import requests
12from loguru import logger
14from ...security.log_sanitizer import scrub_error
15from ...security.ssrf_validator import redact_url_for_log
16from .base import BaseDownloader, ContentType, DownloadResult
19class SemanticScholarDownloader(BaseDownloader):
20 """Downloader for Semantic Scholar papers with open access PDF support."""
22 def __init__(self, timeout: int = 30, api_key: Optional[str] = None):
23 """
24 Initialize Semantic Scholar downloader.
26 Args:
27 timeout: Request timeout in seconds
28 api_key: Optional Semantic Scholar API key for higher rate limits
29 """
30 super().__init__(timeout)
31 self.api_key = api_key
32 self.base_api_url = "https://api.semanticscholar.org/graph/v1"
34 def can_handle(self, url: str) -> bool:
35 """Check if URL is from Semantic Scholar."""
36 try:
37 hostname = urlparse(url).hostname
38 return bool(
39 hostname
40 and (
41 hostname == "semanticscholar.org"
42 or hostname.endswith(".semanticscholar.org")
43 )
44 )
45 except (ValueError, AttributeError, TypeError):
46 return False
48 def download(
49 self, url: str, content_type: ContentType = ContentType.PDF
50 ) -> Optional[bytes]:
51 """Download content from Semantic Scholar."""
52 result = self.download_with_result(url, content_type)
53 return result.content if result.is_success else None
55 def download_with_result(
56 self, url: str, content_type: ContentType = ContentType.PDF
57 ) -> DownloadResult:
58 """Download PDF and return detailed result with skip reason."""
59 # Only support PDF downloads for now
60 if content_type != ContentType.PDF:
61 return DownloadResult(
62 skip_reason="Text extraction not yet supported for Semantic Scholar"
63 )
65 # Extract paper ID from URL
66 paper_id = self._extract_paper_id(url)
67 if not paper_id:
68 return DownloadResult(
69 skip_reason="Invalid Semantic Scholar URL - could not extract paper ID"
70 )
72 logger.info(f"Looking up Semantic Scholar paper: {paper_id}")
74 # Get paper details from API to find PDF URL
75 pdf_url = self._get_pdf_url(paper_id)
77 if not pdf_url: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 return DownloadResult(
79 skip_reason="Not open access - subscription required"
80 )
82 # Download the PDF from the open access URL
83 logger.info(
84 f"Downloading open access PDF from: {redact_url_for_log(pdf_url)}"
85 )
86 pdf_content = super()._download_pdf(pdf_url)
88 if pdf_content:
89 return DownloadResult(content=pdf_content, is_success=True)
90 return DownloadResult(
91 skip_reason="Open access PDF URL found but download failed"
92 )
94 def _extract_paper_id(self, url: str) -> Optional[str]:
95 """
96 Extract Semantic Scholar paper ID from URL.
98 Handles formats like:
99 - https://www.semanticscholar.org/paper/abc123...
100 - https://www.semanticscholar.org/paper/Title-Here/abc123...
102 Returns:
103 Paper ID (hash) or None if not found
104 """
105 # Use urlparse for more robust URL handling (handles query strings, fragments)
106 parsed = urlparse(url)
107 if not parsed.netloc or "semanticscholar.org" not in parsed.netloc:
108 return None
110 # Extract paper ID from path (40 character hex hash)
111 # Handles /paper/{hash} or /paper/{title}/{hash}
112 path = parsed.path
113 match = re.search(r"/paper/(?:[^/]+/)?([a-f0-9]{40})", path)
114 return match.group(1) if match else None
116 def _get_pdf_url(self, paper_id: str) -> Optional[str]:
117 """
118 Get open access PDF URL from Semantic Scholar API.
120 Args:
121 paper_id: Semantic Scholar paper ID (hash)
123 Returns:
124 PDF URL if available, None otherwise
125 """
126 try:
127 # Construct API request
128 api_url = f"{self.base_api_url}/paper/{paper_id}"
129 params = {"fields": "openAccessPdf"}
131 # Add API key header if available
132 headers = {}
133 if self.api_key:
134 headers["x-api-key"] = self.api_key
136 # Make API request
137 response = self.session.get(
138 api_url, params=params, headers=headers, timeout=self.timeout
139 )
141 if response.status_code == 200:
142 data = response.json()
144 # Extract PDF URL from openAccessPdf field
145 open_access_pdf = data.get("openAccessPdf")
146 if open_access_pdf and isinstance(open_access_pdf, dict):
147 pdf_url = open_access_pdf.get("url")
148 if pdf_url: 148 ↛ 155line 148 didn't jump to line 155 because the condition on line 148 was always true
149 logger.info(
150 f"Found open access PDF for paper {paper_id}: "
151 f"{redact_url_for_log(str(pdf_url))}"
152 )
153 return str(pdf_url)
155 logger.info(
156 f"No open access PDF available for paper {paper_id}"
157 )
158 return None
160 if response.status_code == 404:
161 logger.warning(
162 f"Paper not found in Semantic Scholar: {paper_id}"
163 )
164 return None
165 logger.warning(
166 f"Semantic Scholar API error: {response.status_code}"
167 )
168 return None
170 # Both handlers keep the scrubbed message rather than dropping the
171 # API error -- the ``safe_msg`` shape the sibling ``openalex``
172 # downloader uses for the same handler pair. No traceback: these
173 # frames hold the Semantic Scholar API key (``self.api_key`` and the
174 # ``headers`` dict built from it), which loguru's ``diagnose``
175 # renders for every frame-local, so ``scrub_error`` is passed that
176 # key as a known literal on top of its shape-based scrubbing.
177 #
178 # WARNING rather than the ERROR main's ``logger.exception`` logged
179 # at: open-access PDF discovery is optional -- the caller falls back
180 # to the landing page -- so a network blip or a malformed response
181 # here is routine, not an outage.
182 except requests.exceptions.RequestException as exc:
183 safe_msg = scrub_error(exc, self.api_key)
184 logger.opt(exception=False).warning(
185 "Failed to query Semantic Scholar API for paper {}: {}",
186 paper_id,
187 safe_msg,
188 )
189 return None
190 except ValueError as exc:
191 # JSON decode errors are expected runtime errors
192 safe_msg = scrub_error(exc, self.api_key)
193 logger.opt(exception=False).warning(
194 "Failed to parse Semantic Scholar API response for paper "
195 "{}: {}",
196 paper_id,
197 safe_msg,
198 )
199 return None
200 # Note: KeyError and TypeError are not caught - they indicate programming
201 # bugs that should propagate for debugging