Coverage for src/local_deep_research/research_library/downloaders/openalex.py: 98%

105 statements  

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

1""" 

2OpenAlex PDF Downloader 

3 

4Downloads PDFs from OpenAlex using their API to find open access PDFs. 

5OpenAlex aggregates open access information from multiple sources. 

6""" 

7 

8import re 

9from typing import Optional 

10from urllib.parse import urlparse 

11 

12import requests 

13from loguru import logger 

14 

15from ...security import redact_url_for_log 

16from ...security.log_sanitizer import scrub_error 

17from ...utilities.openalex_enrichment import ( 

18 normalize_openalex_api_key, 

19 send_with_key_fallback, 

20) 

21from .base import BaseDownloader, ContentType, DownloadResult 

22 

23 

24class OpenAlexDownloader(BaseDownloader): 

25 """Downloader for OpenAlex papers with open access PDF support.""" 

26 

27 def __init__( 

28 self, 

29 timeout: int = 30, 

30 polite_pool_email: Optional[str] = None, 

31 api_key: Optional[str] = None, 

32 ): 

33 """ 

34 Initialize OpenAlex downloader. 

35 

36 Args: 

37 timeout: Request timeout in seconds 

38 polite_pool_email: Optional email included in the User-Agent for 

39 identification; retained under its existing name for compatibility 

40 api_key: Optional OpenAlex API key for a higher daily request budget. 

41 Placeholders and sentinel values normalize to None so the 

42 downloader falls back to the keyless free tier instead of 

43 sending a Bearer token OpenAlex will reject. A real key 

44 OpenAlex *does* reject is dropped on first use and every 

45 later lookup goes out keyless, so a bad key never turns 

46 an available PDF into "no PDF available". 

47 """ 

48 super().__init__(timeout) 

49 self.polite_pool_email = polite_pool_email 

50 self.api_key: Optional[str] = normalize_openalex_api_key(api_key) 

51 # Set only once OpenAlex refuses the key; kept so ``_scrub`` 

52 # still has the literal to redact after ``api_key`` is cleared. 

53 self._rejected_api_key: Optional[str] = None 

54 self.base_api_url = "https://api.openalex.org" 

55 

56 def _scrub(self, error: Exception) -> str: 

57 """Return a log-safe rendering of *error*. 

58 

59 logger.warning + this helper replaces logger.exception throughout 

60 ``_get_pdf_url``: that frame's locals hold ``headers`` (the 

61 "Authorization: Bearer <key>" value), which loguru's diagnose 

62 would render into the traceback. Same documented trade-off as 

63 ``search_engine_nasa_ads``. This class is not a 

64 ``BaseSearchEngine``, so it does its own literal-key redaction 

65 instead of relying on ``_scrub_error``/``_secret_attrs``. 

66 

67 ``getattr`` with a default, like ``BaseSearchEngine._scrub_error``: 

68 this runs inside ``except`` handlers, and a partially-constructed 

69 instance (``__new__``, or a ``super().__init__`` that raised) 

70 must degrade to a shape-only scrub rather than raise 

71 ``AttributeError`` from inside the handler. 

72 """ 

73 return scrub_error( 

74 error, 

75 getattr(self, "api_key", None), 

76 getattr(self, "_rejected_api_key", None), 

77 ) 

78 

79 def can_handle(self, url: str) -> bool: 

80 """Check if URL is from OpenAlex.""" 

81 try: 

82 hostname = urlparse(url).hostname 

83 return bool( 

84 hostname 

85 and ( 

86 hostname == "openalex.org" 

87 or hostname.endswith(".openalex.org") 

88 ) 

89 ) 

90 except (ValueError, AttributeError, TypeError): 

91 return False 

92 

93 def download( 

94 self, url: str, content_type: ContentType = ContentType.PDF 

95 ) -> Optional[bytes]: 

96 """Download content from OpenAlex.""" 

97 result = self.download_with_result(url, content_type) 

98 return result.content if result.is_success else None 

99 

100 def download_with_result( 

101 self, url: str, content_type: ContentType = ContentType.PDF 

102 ) -> DownloadResult: 

103 """Download PDF and return detailed result with skip reason.""" 

104 # Only support PDF downloads for now 

105 if content_type != ContentType.PDF: 

106 return DownloadResult( 

107 skip_reason="Text extraction not yet supported for OpenAlex" 

108 ) 

109 

110 # Extract work ID from URL 

111 work_id = self._extract_work_id(url) 

112 if not work_id: 

113 return DownloadResult( 

114 skip_reason="Invalid OpenAlex URL - could not extract work ID" 

115 ) 

116 

117 logger.info(f"Looking up OpenAlex work: {work_id}") 

118 

119 # Get work details from API to find PDF URL 

120 pdf_url = self._get_pdf_url(work_id) 

121 

122 if not pdf_url: 

123 return DownloadResult( 

124 skip_reason="Not open access - no free PDF available" 

125 ) 

126 

127 # Download the PDF from the open access URL 

128 logger.info( 

129 f"Downloading open access PDF from: {redact_url_for_log(pdf_url)}" 

130 ) 

131 pdf_content = super()._download_pdf(pdf_url) 

132 

133 if pdf_content: 

134 return DownloadResult(content=pdf_content, is_success=True) 

135 return DownloadResult( 

136 skip_reason="Open access PDF URL found but download failed" 

137 ) 

138 

139 def _extract_work_id(self, url: str) -> Optional[str]: 

140 """ 

141 Extract OpenAlex work ID from URL. 

142 

143 Handles formats like: 

144 - https://openalex.org/W123456789 

145 - https://openalex.org/works/W123456789 

146 

147 Returns: 

148 Work ID (e.g., W123456789) or None if not found 

149 """ 

150 # Use urlparse for more robust URL handling (handles query strings, fragments) 

151 parsed = urlparse(url) 

152 if not parsed.netloc or "openalex.org" not in parsed.netloc: 

153 return None 

154 

155 # Extract work ID from path (W followed by digits) 

156 # Handles /works/W123 or /W123 

157 path = parsed.path 

158 match = re.search(r"(?:/works/)?(W\d+)", path) 

159 return match.group(1) if match else None 

160 

161 def _get_pdf_url(self, work_id: str) -> Optional[str]: 

162 """ 

163 Get open access PDF URL from OpenAlex API. 

164 

165 Args: 

166 work_id: OpenAlex work ID (e.g., W123456789) 

167 

168 Returns: 

169 PDF URL if available, None otherwise 

170 """ 

171 try: 

172 # Construct API request 

173 api_url = f"{self.base_api_url}/works/{work_id}" 

174 params = {"select": "id,open_access,best_oa_location"} 

175 

176 # Identify the client by email when configured. 

177 headers = {} 

178 if self.polite_pool_email: 

179 headers["User-Agent"] = f"mailto:{self.polite_pool_email}" 

180 

181 def _send(with_api_key: bool) -> requests.Response: 

182 request_headers = dict(headers) 

183 if with_api_key and self.api_key: 

184 # Bearer header rather than the equally-supported 

185 # ?api_key= query parameter, so the key stays out of 

186 # URLs. https://help.openalex.org/api/authentication/ 

187 request_headers["Authorization"] = f"Bearer {self.api_key}" 

188 return self.session.get( 

189 api_url, 

190 params=params, 

191 headers=request_headers, 

192 timeout=self.timeout, 

193 ) 

194 

195 def _drop_rejected_key() -> None: 

196 # The single mutation point: this runs *before* the 

197 # keyless retry, so even if that retry raises, the 

198 # refused key is already gone and cannot be sent again. 

199 # ``or self._rejected_api_key``: a second call must not 

200 # store None over the literal and drop it out of 

201 # ``_scrub``'s redaction set. 

202 self._rejected_api_key = self.api_key or self._rejected_api_key 

203 self.api_key = None 

204 

205 # Make API request. The shared helper retries keylessly once 

206 # if OpenAlex refuses the key and drops the key for this 

207 # downloader's lifetime, so a stale key costs one extra 

208 # request rather than every open-access PDF. If the keyless 

209 # retry fails too, the status branches below apply the 

210 # pre-existing behaviour (warn, return None). 

211 response, _ = send_with_key_fallback( 

212 _send, 

213 api_key=self.api_key, 

214 context="OpenAlex download", 

215 on_key_rejected=_drop_rejected_key, 

216 ) 

217 

218 if response.status_code == 200: 

219 data = response.json() 

220 

221 # Check if it's open access 

222 open_access_info = data.get("open_access", {}) 

223 is_oa = open_access_info.get("is_oa", False) 

224 

225 if not is_oa: 

226 logger.info(f"Work {work_id} is not open access") 

227 return None 

228 

229 # Get PDF URL from best open access location 

230 best_oa_location = data.get("best_oa_location", {}) 

231 if best_oa_location: 

232 # Try pdf_url first, fall back to landing_page_url 

233 pdf_url = best_oa_location.get("pdf_url") 

234 if pdf_url: 

235 logger.info( 

236 f"Found open access PDF for work {work_id}: {redact_url_for_log(pdf_url)}" 

237 ) 

238 return str(pdf_url) 

239 

240 # Some works have landing page but no direct PDF 

241 landing_url = best_oa_location.get("landing_page_url") 

242 if landing_url: 242 ↛ 273line 242 didn't jump to line 273 because the condition on line 242 was always true

243 logger.info( 

244 f"Found landing page for work {work_id}: {redact_url_for_log(landing_url)}" 

245 ) 

246 # Validate that landing page is actually a PDF before returning 

247 try: 

248 head_response = self.session.head( 

249 landing_url, 

250 timeout=self.timeout, 

251 allow_redirects=True, 

252 ) 

253 content_type = head_response.headers.get( 

254 "Content-Type", "" 

255 ).lower() 

256 if "application/pdf" in content_type: 

257 logger.info( 

258 f"Landing page is a direct PDF link for work {work_id}" 

259 ) 

260 return str(landing_url) 

261 logger.info( 

262 f"Landing page is not a PDF (Content-Type: {content_type}), skipping" 

263 ) 

264 except Exception as exc: 

265 # Type only: urllib3 errors embed the scheme-less 

266 # path and query, which the scrubber misses. 

267 logger.warning( 

268 f"Failed to validate landing page URL " 

269 f"({type(exc).__name__}) for work {work_id}: " 

270 f"{redact_url_for_log(landing_url)}" 

271 ) 

272 

273 logger.info( 

274 f"No PDF URL available for open access work {work_id}" 

275 ) 

276 return None 

277 

278 if response.status_code == 404: 

279 logger.warning(f"Work not found in OpenAlex: {work_id}") 

280 return None 

281 logger.warning(f"OpenAlex API error: {response.status_code}") 

282 return None 

283 

284 except requests.exceptions.RequestException as exc: 

285 safe_msg = self._scrub(exc) 

286 logger.warning(f"Failed to query OpenAlex API: {safe_msg}") 

287 return None 

288 except ValueError as exc: 

289 # JSON decode errors are expected runtime errors 

290 safe_msg = self._scrub(exc) 

291 logger.warning(f"Failed to parse OpenAlex API response: {safe_msg}") 

292 return None 

293 # Note: KeyError and TypeError are not caught - they indicate programming 

294 # bugs that should propagate for debugging