Skills › Scientific Agent Skills › citation-management › doi_to_bibtex.py
doi_to_bibtex.py
212 lines · skills/citation-management/scripts/doi_to_bibtex.py at 5f2bc03
1#!/usr/bin/env python3
2"""
3DOI to BibTeX Converter
4Convert DOIs to BibTeX through DOI content negotiation (Crossref/DataCite/mEDRA).
5"""
7import sys
8import requests
9import argparse
10import time
11import json
12import re
13from typing import Optional, List
14from urllib.parse import quote, unquote
16from _common import citation_key, parse_bibtex, render_entry
18class DOIConverter:
19 """Convert DOIs to BibTeX through their registration agency."""
21 def __init__(self):
22 self.session = requests.Session()
23 self.session.headers.update({
24 'User-Agent': 'DOIConverter/1.0 (Citation Management Tool)'
25 })
27 def doi_to_bibtex(self, doi: str) -> Optional[str]:
28 """
29 Convert a single DOI to BibTeX format.
31 Args:
32 doi: Digital Object Identifier
34 Returns:
35 BibTeX string or None if conversion fails
36 """
37 # Clean DOI (remove URL prefix if present)
38 doi = doi.strip()
39 if re.match(r'^https?://(?:dx\.)?doi\.org/', doi, re.IGNORECASE):
40 doi = unquote(re.sub(r'^https?://(?:dx\.)?doi\.org/', '', doi, flags=re.IGNORECASE))
41 doi = re.sub(r'^doi:\s*', '', doi, flags=re.IGNORECASE)
43 # Request BibTeX from CrossRef content negotiation
44 url = f'https://doi.org/{quote(doi, safe="/")}'
45 headers = {
46 'Accept': 'application/x-bibtex',
47 'User-Agent': 'DOIConverter/1.0 (Citation Management Tool)'
48 }
50 try:
51 response = self.session.get(url, headers=headers, timeout=15)
53 if response.status_code == 200:
54 bibtex = response.text.strip()
55 entries = parse_bibtex(bibtex)
56 if len(entries) != 1:
57 print(f'Error: DOI service returned no single BibTeX entry for {doi}', file=sys.stderr)
58 return None
59 entry = entries[0]
60 fields = entry['fields']
61 key = citation_key(fields.get('author', ''), fields.get('year', ''), fields.get('title', ''))
62 entry_type = 'misc' if entry['type'] in ('data', 'dataset') else entry['type']
63 return render_entry(entry_type, key, fields)
64 elif response.status_code == 404:
65 print(f'Error: DOI not found: {doi}', file=sys.stderr)
66 return None
67 else:
68 print(f'Error: Failed to retrieve BibTeX for {doi} (status {response.status_code})', file=sys.stderr)
69 return None
71 except requests.exceptions.Timeout:
72 print(f'Error: Request timeout for DOI: {doi}', file=sys.stderr)
73 return None
74 except requests.exceptions.RequestException as e:
75 print(f'Error: Request failed for {doi}: {e}', file=sys.stderr)
76 return None
78 def convert_multiple(self, dois: List[str], delay: float = 0.5) -> List[str]:
79 """
80 Convert multiple DOIs to BibTeX.
82 Args:
83 dois: List of DOIs
84 delay: Delay between requests (seconds) for rate limiting
86 Returns:
87 List of BibTeX entries (excludes failed conversions)
88 """
89 bibtex_entries = []
91 for i, doi in enumerate(dois):
92 print(f'Converting DOI {i+1}/{len(dois)}: {doi}', file=sys.stderr)
93 bibtex = self.doi_to_bibtex(doi)
95 if bibtex:
96 bibtex_entries.append(bibtex)
98 # Rate limiting
99 if i < len(dois) - 1: # Don't delay after last request
100 time.sleep(delay)
102 return bibtex_entries
105def main():
106 """Command-line interface."""
107 parser = argparse.ArgumentParser(
108 description='Convert DOIs to BibTeX using DOI content negotiation',
109 epilog='Example: python doi_to_bibtex.py 10.1038/s41586-021-03819-2'
110 )
112 parser.add_argument(
113 'dois',
114 nargs='*',
115 help='DOI(s) to convert (can provide multiple)'
116 )
118 parser.add_argument(
119 '-i', '--input',
120 help='Input file with DOIs (one per line)'
121 )
123 parser.add_argument(
124 '-o', '--output',
125 help='Output file for BibTeX (default: stdout)'
126 )
128 parser.add_argument(
129 '--delay',
130 type=float,
131 default=0.5,
132 help='Delay between requests in seconds (default: 0.5)'
133 )
135 parser.add_argument(
136 '--format',
137 choices=['bibtex', 'json'],
138 default='bibtex',
139 help='Output format (default: bibtex)'
140 )
142 args = parser.parse_args()
144 # Collect DOIs from command line and/or file
145 dois = []
147 if args.dois:
148 dois.extend(args.dois)
150 if args.input:
151 try:
152 with open(args.input, 'r', encoding='utf-8') as f:
153 file_dois = [line.strip() for line in f if line.strip()]
154 dois.extend(file_dois)
155 except FileNotFoundError:
156 print(f'Error: Input file not found: {args.input}', file=sys.stderr)
157 sys.exit(1)
158 except Exception as e:
159 print(f'Error reading input file: {e}', file=sys.stderr)
160 sys.exit(1)
162 if not dois:
163 parser.print_help()
164 sys.exit(1)
166 # Convert DOIs
167 converter = DOIConverter()
169 if len(dois) == 1:
170 bibtex = converter.doi_to_bibtex(dois[0])
171 if bibtex:
172 bibtex_entries = [bibtex]
173 else:
174 sys.exit(1)
175 else:
176 bibtex_entries = converter.convert_multiple(dois, delay=args.delay)
178 if not bibtex_entries:
179 print('Error: No successful conversions', file=sys.stderr)
180 sys.exit(1)
182 # Format output
183 if args.format == 'bibtex':
184 output = '\n\n'.join(bibtex_entries) + '\n'
185 else: # json
186 output = json.dumps({
187 'count': len(bibtex_entries),
188 'entries': bibtex_entries
189 }, indent=2)
191 # Write output
192 if args.output:
193 try:
194 with open(args.output, 'w', encoding='utf-8') as f:
195 f.write(output)
196 print(f'Successfully wrote {len(bibtex_entries)} entries to {args.output}', file=sys.stderr)
197 except Exception as e:
198 print(f'Error writing output file: {e}', file=sys.stderr)
199 sys.exit(1)
200 else:
201 print(output)
203 # Summary
204 if len(dois) > 1:
205 success_rate = len(bibtex_entries) / len(dois) * 100
206 print(f'\nConverted {len(bibtex_entries)}/{len(dois)} DOIs ({success_rate:.1f}%)', file=sys.stderr)
207 if len(bibtex_entries) < len(dois):
208 sys.exit(2)
211if __name__ == '__main__':
212 main()