Skip to content

Commit 59564e5

Browse files
noreplyclaude
andcommitted
feat: Add OpenAlex API integration (250M+ scholarly works)
- New OpenAlexClient with search_paper, search_papers, get_paper_by_doi - Integrated into citation orchestrator fallback chain - Crossref -> OpenAlex -> Semantic Scholar -> Gemini pipeline - Parallel queries for maximum source diversity - Free API with optional key for higher limits Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9951beb commit 59564e5

3 files changed

Lines changed: 394 additions & 11 deletions

File tree

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""
2-
ABOUTME: API-backed citation research using Crossref and Semantic Scholar
2+
ABOUTME: API-backed citation research using Crossref, OpenAlex, and Semantic Scholar
33
ABOUTME: Provides reliable paper lookup with 95%+ success rate (vs 40% LLM-only)
44
"""
55

66
from .orchestrator import CitationResearcher
77
from .crossref import CrossrefClient
8+
from .openalex import OpenAlexClient
89
from .semantic_scholar import SemanticScholarClient
910

1011
__all__ = [
1112
"CitationResearcher",
1213
"CrossrefClient",
14+
"OpenAlexClient",
1315
"SemanticScholarClient",
1416
]
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/usr/bin/env python3
2+
"""
3+
ABOUTME: OpenAlex API client for comprehensive academic paper search
4+
ABOUTME: 250M+ scholarly works, free API with optional key for higher limits
5+
"""
6+
7+
import logging
8+
import os
9+
from typing import Optional, Dict, Any, List
10+
from .base import BaseAPIClient, validate_author_name
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class OpenAlexClient(BaseAPIClient):
16+
"""
17+
OpenAlex API client for academic paper search.
18+
19+
OpenAlex is a free, open catalog of the world's scholarly works.
20+
Provides comprehensive metadata for 250M+ papers across all disciplines.
21+
22+
API Documentation: https://docs.openalex.org/
23+
"""
24+
25+
def __init__(
26+
self,
27+
api_key: Optional[str] = None,
28+
rate_limit_per_second: float = 10.0,
29+
timeout: int = 15,
30+
max_retries: int = 3,
31+
):
32+
"""
33+
Initialize OpenAlex API client.
34+
35+
Args:
36+
api_key: Optional API key for higher rate limits (get free key at openalex.org)
37+
rate_limit_per_second: Maximum requests per second (10 without key, 100 with key)
38+
timeout: Request timeout in seconds
39+
max_retries: Maximum retry attempts
40+
"""
41+
# Use provided key or fall back to environment variable
42+
self.openalex_key = api_key or os.getenv('OPENALEX_API_KEY')
43+
44+
# Build headers
45+
headers = {}
46+
if self.openalex_key:
47+
headers['api_key'] = self.openalex_key
48+
rate_limit_per_second = min(rate_limit_per_second, 50.0) # With key: up to 100/sec
49+
logger.info("OpenAlex: Using API key for higher rate limits")
50+
else:
51+
rate_limit_per_second = min(rate_limit_per_second, 10.0) # Without key: 10/sec
52+
logger.debug("OpenAlex: No API key, using polite pool (10 req/sec)")
53+
54+
# OpenAlex asks for email in User-Agent for polite pool
55+
polite_email = os.getenv('OPENALEX_EMAIL', 'opendraft@users.noreply.github.com')
56+
57+
super().__init__(
58+
base_url="https://api.openalex.org",
59+
rate_limit_per_second=rate_limit_per_second,
60+
timeout=timeout,
61+
max_retries=max_retries,
62+
)
63+
64+
# Override default headers with polite user-agent
65+
self.session.headers.update({
66+
'User-Agent': f'OpenDraft/1.7 (mailto:{polite_email})',
67+
})
68+
if self.openalex_key:
69+
self.session.headers['api_key'] = self.openalex_key
70+
71+
def search_paper(self, query: str) -> Optional[Dict[str, Any]]:
72+
"""
73+
Search for a paper by title, author, or keywords.
74+
75+
Args:
76+
query: Search query (title, authors, keywords)
77+
78+
Returns:
79+
Paper metadata dict with standardized fields or None if not found
80+
"""
81+
# OpenAlex uses filter-based search
82+
# search= does full-text search across title, abstract, etc.
83+
response = self._make_request(
84+
method="GET",
85+
endpoint="/works",
86+
params={
87+
"search": query,
88+
"per_page": 5,
89+
"select": "id,doi,title,authorships,publication_year,primary_location,type,cited_by_count,abstract_inverted_index",
90+
},
91+
)
92+
93+
if not response:
94+
logger.debug(f"OpenAlex: No results for query '{query[:50]}...'")
95+
return None
96+
97+
try:
98+
results = response.get("results", [])
99+
if not results:
100+
logger.debug(f"OpenAlex: Empty results for '{query[:50]}...'")
101+
return None
102+
103+
paper = results[0]
104+
metadata = self._extract_metadata(paper)
105+
106+
if metadata:
107+
logger.info(
108+
f"OpenAlex: Found '{metadata['title'][:50]}...' by {metadata['authors'][0]} ({metadata['year']})"
109+
)
110+
return metadata
111+
else:
112+
logger.debug(f"OpenAlex: Incomplete metadata for '{query[:50]}...'")
113+
return None
114+
115+
except Exception as e:
116+
logger.error(f"OpenAlex: Error parsing response: {e}")
117+
return None
118+
119+
def search_papers(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
120+
"""
121+
Search for multiple papers by query.
122+
123+
Args:
124+
query: Search query
125+
limit: Maximum number of results (default 10, max 200)
126+
127+
Returns:
128+
List of paper metadata dicts
129+
"""
130+
response = self._make_request(
131+
method="GET",
132+
endpoint="/works",
133+
params={
134+
"search": query,
135+
"per_page": min(limit, 200),
136+
"select": "id,doi,title,authorships,publication_year,primary_location,type,cited_by_count,abstract_inverted_index",
137+
},
138+
)
139+
140+
if not response:
141+
return []
142+
143+
results = []
144+
for paper in response.get("results", []):
145+
metadata = self._extract_metadata(paper)
146+
if metadata:
147+
results.append(metadata)
148+
149+
return results
150+
151+
def get_paper_by_doi(self, doi: str) -> Optional[Dict[str, Any]]:
152+
"""
153+
Get paper metadata by DOI.
154+
155+
Args:
156+
doi: DOI string (e.g., "10.1038/nature12373")
157+
158+
Returns:
159+
Paper metadata dict or None if not found
160+
"""
161+
# OpenAlex uses DOI as identifier with https://doi.org/ prefix
162+
response = self._make_request(
163+
method="GET",
164+
endpoint=f"/works/https://doi.org/{doi}",
165+
params={
166+
"select": "id,doi,title,authorships,publication_year,primary_location,type,cited_by_count,abstract_inverted_index",
167+
},
168+
)
169+
170+
if not response:
171+
return None
172+
173+
return self._extract_metadata(response)
174+
175+
def _extract_metadata(self, paper: Dict[str, Any]) -> Optional[Dict[str, Any]]:
176+
"""
177+
Extract and normalize paper metadata from OpenAlex response.
178+
179+
Args:
180+
paper: OpenAlex work object
181+
182+
Returns:
183+
Normalized metadata dict or None if required fields missing
184+
"""
185+
try:
186+
# Title (required)
187+
title = paper.get("title", "")
188+
if not title:
189+
return None
190+
191+
# Authors (required)
192+
authorships = paper.get("authorships", [])
193+
authors = []
194+
for authorship in authorships:
195+
author_info = authorship.get("author", {})
196+
display_name = author_info.get("display_name", "")
197+
if display_name:
198+
# Extract last name (OpenAlex gives "First Last" format)
199+
parts = display_name.split()
200+
if parts:
201+
last_name = parts[-1]
202+
is_valid, _ = validate_author_name(last_name)
203+
if is_valid:
204+
authors.append(last_name)
205+
206+
if not authors:
207+
return None
208+
209+
# Year (required)
210+
year = paper.get("publication_year", 0)
211+
if not year:
212+
return None
213+
214+
# DOI
215+
doi_url = paper.get("doi", "")
216+
doi = ""
217+
if doi_url:
218+
# OpenAlex returns full URL like "https://doi.org/10.1038/xxx"
219+
doi = doi_url.replace("https://doi.org/", "")
220+
221+
# URL
222+
url = doi_url if doi_url else paper.get("id", "")
223+
224+
# Journal/Venue from primary_location
225+
primary_location = paper.get("primary_location", {}) or {}
226+
source = primary_location.get("source", {}) or {}
227+
journal = source.get("display_name", "")
228+
publisher = source.get("host_organization_name", "")
229+
230+
# Source type
231+
work_type = paper.get("type", "")
232+
source_type = self._map_source_type(work_type)
233+
234+
# Citation count (useful for ranking)
235+
citation_count = paper.get("cited_by_count", 0)
236+
237+
# Abstract (OpenAlex uses inverted index format)
238+
abstract = self._reconstruct_abstract(paper.get("abstract_inverted_index"))
239+
240+
# Calculate confidence
241+
confidence = self._calculate_confidence(
242+
has_doi=bool(doi),
243+
has_journal=bool(journal),
244+
citation_count=citation_count,
245+
author_count=len(authors)
246+
)
247+
248+
return {
249+
"title": title,
250+
"authors": authors,
251+
"year": year,
252+
"doi": doi,
253+
"url": url,
254+
"journal": journal,
255+
"publisher": publisher,
256+
"volume": "", # Not in basic select
257+
"issue": "",
258+
"pages": "",
259+
"source_type": source_type,
260+
"confidence": confidence,
261+
"abstract": abstract,
262+
"citation_count": citation_count,
263+
}
264+
265+
except Exception as e:
266+
logger.error(f"OpenAlex: Error extracting metadata: {e}")
267+
return None
268+
269+
def _reconstruct_abstract(self, inverted_index: Optional[Dict[str, List[int]]]) -> Optional[str]:
270+
"""
271+
Reconstruct abstract from OpenAlex inverted index format.
272+
273+
OpenAlex stores abstracts as {word: [positions]} for efficiency.
274+
We need to reconstruct the original text.
275+
"""
276+
if not inverted_index:
277+
return None
278+
279+
try:
280+
# Find max position to size the array
281+
max_pos = 0
282+
for positions in inverted_index.values():
283+
if positions:
284+
max_pos = max(max_pos, max(positions))
285+
286+
# Build word array
287+
words = [""] * (max_pos + 1)
288+
for word, positions in inverted_index.items():
289+
for pos in positions:
290+
words[pos] = word
291+
292+
# Join and clean
293+
abstract = " ".join(words).strip()
294+
return abstract if abstract else None
295+
296+
except Exception as e:
297+
logger.debug(f"OpenAlex: Failed to reconstruct abstract: {e}")
298+
return None
299+
300+
def _map_source_type(self, work_type: str) -> str:
301+
"""
302+
Map OpenAlex work type to our source_type enum.
303+
"""
304+
type_mapping = {
305+
"journal-article": "journal",
306+
"article": "journal",
307+
"proceedings-article": "conference",
308+
"book": "book",
309+
"book-chapter": "book",
310+
"dissertation": "report",
311+
"dataset": "report",
312+
"preprint": "report",
313+
"report": "report",
314+
}
315+
return type_mapping.get(work_type, "journal")
316+
317+
def _calculate_confidence(
318+
self,
319+
has_doi: bool,
320+
has_journal: bool,
321+
citation_count: int,
322+
author_count: int
323+
) -> float:
324+
"""
325+
Calculate confidence score for paper metadata.
326+
"""
327+
score = 0.5 # Base score
328+
329+
if has_doi:
330+
score += 0.25
331+
if has_journal:
332+
score += 0.1
333+
if citation_count > 10:
334+
score += 0.1
335+
elif citation_count > 0:
336+
score += 0.05
337+
if author_count > 0:
338+
score += 0.05
339+
340+
return min(score, 1.0)

0 commit comments

Comments
 (0)