extract_metadata.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. #!/usr/bin/env python3
  2. """
  3. Metadata Extraction Tool
  4. Extract citation metadata from DOI, PMID, arXiv ID, or URL using various APIs.
  5. """
  6. import sys
  7. import os
  8. import requests
  9. import argparse
  10. import time
  11. import re
  12. import json
  13. import xml.etree.ElementTree as ET
  14. from typing import Optional, Dict, List, Tuple
  15. from urllib.parse import urlparse
  16. class MetadataExtractor:
  17. """Extract metadata from various sources and generate BibTeX."""
  18. def __init__(self, email: Optional[str] = None):
  19. """
  20. Initialize extractor.
  21. Args:
  22. email: Email for Entrez API (recommended for PubMed)
  23. """
  24. self.session = requests.Session()
  25. self.session.headers.update({
  26. 'User-Agent': 'MetadataExtractor/1.0 (Citation Management Tool)'
  27. })
  28. self.email = email or os.getenv('NCBI_EMAIL', '')
  29. def identify_type(self, identifier: str) -> Tuple[str, str]:
  30. """
  31. Identify the type of identifier.
  32. Args:
  33. identifier: DOI, PMID, arXiv ID, or URL
  34. Returns:
  35. Tuple of (type, cleaned_identifier)
  36. """
  37. identifier = identifier.strip()
  38. # Check if URL
  39. if identifier.startswith('http://') or identifier.startswith('https://'):
  40. return self._parse_url(identifier)
  41. # Check for DOI
  42. if identifier.startswith('10.'):
  43. return ('doi', identifier)
  44. # Check for arXiv ID
  45. if re.match(r'^\d{4}\.\d{4,5}(v\d+)?$', identifier):
  46. return ('arxiv', identifier)
  47. if identifier.startswith('arXiv:'):
  48. return ('arxiv', identifier.replace('arXiv:', ''))
  49. # Check for PMID (8-digit number typically)
  50. if identifier.isdigit() and len(identifier) >= 7:
  51. return ('pmid', identifier)
  52. # Check for PMCID
  53. if identifier.upper().startswith('PMC') and identifier[3:].isdigit():
  54. return ('pmcid', identifier.upper())
  55. return ('unknown', identifier)
  56. def _parse_url(self, url: str) -> Tuple[str, str]:
  57. """Parse URL to extract identifier type and value."""
  58. parsed = urlparse(url)
  59. # DOI URLs
  60. if 'doi.org' in parsed.netloc:
  61. doi = parsed.path.lstrip('/')
  62. return ('doi', doi)
  63. # PubMed URLs
  64. if 'pubmed.ncbi.nlm.nih.gov' in parsed.netloc or 'ncbi.nlm.nih.gov/pubmed' in url:
  65. pmid = re.search(r'/(\d+)', parsed.path)
  66. if pmid:
  67. return ('pmid', pmid.group(1))
  68. # arXiv URLs
  69. if 'arxiv.org' in parsed.netloc:
  70. arxiv_id = re.search(r'/abs/(\d{4}\.\d{4,5})', parsed.path)
  71. if arxiv_id:
  72. return ('arxiv', arxiv_id.group(1))
  73. # Nature, Science, Cell, etc. - try to extract DOI from URL
  74. doi_match = re.search(r'10\.\d{4,}/[^\s/]+', url)
  75. if doi_match:
  76. return ('doi', doi_match.group())
  77. return ('url', url)
  78. def extract_from_doi(self, doi: str) -> Optional[Dict]:
  79. """
  80. Extract metadata from DOI using CrossRef API.
  81. Args:
  82. doi: Digital Object Identifier
  83. Returns:
  84. Metadata dictionary or None
  85. """
  86. url = f'https://api.crossref.org/works/{doi}'
  87. try:
  88. response = self.session.get(url, timeout=15)
  89. if response.status_code == 200:
  90. data = response.json()
  91. message = data.get('message', {})
  92. metadata = {
  93. 'type': 'doi',
  94. 'entry_type': self._crossref_type_to_bibtex(message.get('type')),
  95. 'doi': doi,
  96. 'title': message.get('title', [''])[0],
  97. 'authors': self._format_authors_crossref(message.get('author', [])),
  98. 'year': self._extract_year_crossref(message),
  99. 'journal': message.get('container-title', [''])[0] if message.get('container-title') else '',
  100. 'volume': str(message.get('volume', '')) if message.get('volume') else '',
  101. 'issue': str(message.get('issue', '')) if message.get('issue') else '',
  102. 'pages': message.get('page', ''),
  103. 'publisher': message.get('publisher', ''),
  104. 'url': f'https://doi.org/{doi}'
  105. }
  106. return metadata
  107. else:
  108. print(f'Error: CrossRef API returned status {response.status_code} for DOI: {doi}', file=sys.stderr)
  109. return None
  110. except Exception as e:
  111. print(f'Error extracting metadata from DOI {doi}: {e}', file=sys.stderr)
  112. return None
  113. def extract_from_pmid(self, pmid: str) -> Optional[Dict]:
  114. """
  115. Extract metadata from PMID using PubMed E-utilities.
  116. Args:
  117. pmid: PubMed ID
  118. Returns:
  119. Metadata dictionary or None
  120. """
  121. url = f'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi'
  122. params = {
  123. 'db': 'pubmed',
  124. 'id': pmid,
  125. 'retmode': 'xml',
  126. 'rettype': 'abstract'
  127. }
  128. if self.email:
  129. params['email'] = self.email
  130. api_key = os.getenv('NCBI_API_KEY')
  131. if api_key:
  132. params['api_key'] = api_key
  133. try:
  134. response = self.session.get(url, params=params, timeout=15)
  135. if response.status_code == 200:
  136. root = ET.fromstring(response.content)
  137. article = root.find('.//PubmedArticle')
  138. if article is None:
  139. print(f'Error: No article found for PMID: {pmid}', file=sys.stderr)
  140. return None
  141. # Extract metadata from XML
  142. medline_citation = article.find('.//MedlineCitation')
  143. article_elem = medline_citation.find('.//Article')
  144. journal = article_elem.find('.//Journal')
  145. # Get DOI if available
  146. doi = None
  147. article_ids = article.findall('.//ArticleId')
  148. for article_id in article_ids:
  149. if article_id.get('IdType') == 'doi':
  150. doi = article_id.text
  151. break
  152. metadata = {
  153. 'type': 'pmid',
  154. 'entry_type': 'article',
  155. 'pmid': pmid,
  156. 'title': article_elem.findtext('.//ArticleTitle', ''),
  157. 'authors': self._format_authors_pubmed(article_elem.findall('.//Author')),
  158. 'year': self._extract_year_pubmed(article_elem),
  159. 'journal': journal.findtext('.//Title', ''),
  160. 'volume': journal.findtext('.//JournalIssue/Volume', ''),
  161. 'issue': journal.findtext('.//JournalIssue/Issue', ''),
  162. 'pages': article_elem.findtext('.//Pagination/MedlinePgn', ''),
  163. 'doi': doi
  164. }
  165. return metadata
  166. else:
  167. print(f'Error: PubMed API returned status {response.status_code} for PMID: {pmid}', file=sys.stderr)
  168. return None
  169. except Exception as e:
  170. print(f'Error extracting metadata from PMID {pmid}: {e}', file=sys.stderr)
  171. return None
  172. def extract_from_arxiv(self, arxiv_id: str) -> Optional[Dict]:
  173. """
  174. Extract metadata from arXiv ID using arXiv API.
  175. Args:
  176. arxiv_id: arXiv identifier
  177. Returns:
  178. Metadata dictionary or None
  179. """
  180. url = 'http://export.arxiv.org/api/query'
  181. params = {
  182. 'id_list': arxiv_id,
  183. 'max_results': 1
  184. }
  185. try:
  186. response = self.session.get(url, params=params, timeout=15)
  187. if response.status_code == 200:
  188. # Parse Atom XML
  189. root = ET.fromstring(response.content)
  190. ns = {'atom': 'http://www.w3.org/2005/Atom', 'arxiv': 'http://arxiv.org/schemas/atom'}
  191. entry = root.find('atom:entry', ns)
  192. if entry is None:
  193. print(f'Error: No entry found for arXiv ID: {arxiv_id}', file=sys.stderr)
  194. return None
  195. # Extract DOI if published
  196. doi_elem = entry.find('arxiv:doi', ns)
  197. doi = doi_elem.text if doi_elem is not None else None
  198. # Extract journal reference if published
  199. journal_ref_elem = entry.find('arxiv:journal_ref', ns)
  200. journal_ref = journal_ref_elem.text if journal_ref_elem is not None else None
  201. # Get publication date
  202. published = entry.findtext('atom:published', '', ns)
  203. year = published[:4] if published else ''
  204. # Get authors
  205. authors = []
  206. for author in entry.findall('atom:author', ns):
  207. name = author.findtext('atom:name', '', ns)
  208. if name:
  209. authors.append(name)
  210. metadata = {
  211. 'type': 'arxiv',
  212. 'entry_type': 'misc' if not doi else 'article',
  213. 'arxiv_id': arxiv_id,
  214. 'title': entry.findtext('atom:title', '', ns).strip().replace('\n', ' '),
  215. 'authors': ' and '.join(authors),
  216. 'year': year,
  217. 'doi': doi,
  218. 'journal_ref': journal_ref,
  219. 'abstract': entry.findtext('atom:summary', '', ns).strip().replace('\n', ' '),
  220. 'url': f'https://arxiv.org/abs/{arxiv_id}'
  221. }
  222. return metadata
  223. else:
  224. print(f'Error: arXiv API returned status {response.status_code} for ID: {arxiv_id}', file=sys.stderr)
  225. return None
  226. except Exception as e:
  227. print(f'Error extracting metadata from arXiv {arxiv_id}: {e}', file=sys.stderr)
  228. return None
  229. def metadata_to_bibtex(self, metadata: Dict, citation_key: Optional[str] = None) -> str:
  230. """
  231. Convert metadata dictionary to BibTeX format.
  232. Args:
  233. metadata: Metadata dictionary
  234. citation_key: Optional custom citation key
  235. Returns:
  236. BibTeX string
  237. """
  238. if not citation_key:
  239. citation_key = self._generate_citation_key(metadata)
  240. entry_type = metadata.get('entry_type', 'misc')
  241. # Build BibTeX entry
  242. lines = [f'@{entry_type}{{{citation_key},']
  243. # Add fields
  244. if metadata.get('authors'):
  245. lines.append(f' author = {{{metadata["authors"]}}},')
  246. if metadata.get('title'):
  247. # Protect capitalization
  248. title = self._protect_title(metadata['title'])
  249. lines.append(f' title = {{{title}}},')
  250. if entry_type == 'article' and metadata.get('journal'):
  251. lines.append(f' journal = {{{metadata["journal"]}}},')
  252. elif entry_type == 'misc' and metadata.get('type') == 'arxiv':
  253. lines.append(f' howpublished = {{arXiv}},')
  254. if metadata.get('year'):
  255. lines.append(f' year = {{{metadata["year"]}}},')
  256. if metadata.get('volume'):
  257. lines.append(f' volume = {{{metadata["volume"]}}},')
  258. if metadata.get('issue'):
  259. lines.append(f' number = {{{metadata["issue"]}}},')
  260. if metadata.get('pages'):
  261. pages = metadata['pages'].replace('-', '--') # En-dash
  262. lines.append(f' pages = {{{pages}}},')
  263. if metadata.get('doi'):
  264. lines.append(f' doi = {{{metadata["doi"]}}},')
  265. elif metadata.get('url'):
  266. lines.append(f' url = {{{metadata["url"]}}},')
  267. if metadata.get('pmid'):
  268. lines.append(f' note = {{PMID: {metadata["pmid"]}}},')
  269. if metadata.get('type') == 'arxiv' and not metadata.get('doi'):
  270. lines.append(f' note = {{Preprint}},')
  271. # Remove trailing comma from last field
  272. if lines[-1].endswith(','):
  273. lines[-1] = lines[-1][:-1]
  274. lines.append('}')
  275. return '\n'.join(lines)
  276. def _crossref_type_to_bibtex(self, crossref_type: str) -> str:
  277. """Map CrossRef type to BibTeX entry type."""
  278. type_map = {
  279. 'journal-article': 'article',
  280. 'book': 'book',
  281. 'book-chapter': 'incollection',
  282. 'proceedings-article': 'inproceedings',
  283. 'posted-content': 'misc',
  284. 'dataset': 'misc',
  285. 'report': 'techreport'
  286. }
  287. return type_map.get(crossref_type, 'misc')
  288. def _format_authors_crossref(self, authors: List[Dict]) -> str:
  289. """Format author list from CrossRef data."""
  290. if not authors:
  291. return ''
  292. formatted = []
  293. for author in authors:
  294. given = author.get('given', '')
  295. family = author.get('family', '')
  296. if family:
  297. if given:
  298. formatted.append(f'{family}, {given}')
  299. else:
  300. formatted.append(family)
  301. return ' and '.join(formatted)
  302. def _format_authors_pubmed(self, authors: List) -> str:
  303. """Format author list from PubMed XML."""
  304. formatted = []
  305. for author in authors:
  306. last_name = author.findtext('.//LastName', '')
  307. fore_name = author.findtext('.//ForeName', '')
  308. if last_name:
  309. if fore_name:
  310. formatted.append(f'{last_name}, {fore_name}')
  311. else:
  312. formatted.append(last_name)
  313. return ' and '.join(formatted)
  314. def _extract_year_crossref(self, message: Dict) -> str:
  315. """Extract year from CrossRef message."""
  316. # Try published-print first, then published-online
  317. date_parts = message.get('published-print', {}).get('date-parts', [[]])
  318. if not date_parts or not date_parts[0]:
  319. date_parts = message.get('published-online', {}).get('date-parts', [[]])
  320. if date_parts and date_parts[0]:
  321. return str(date_parts[0][0])
  322. return ''
  323. def _extract_year_pubmed(self, article: ET.Element) -> str:
  324. """Extract year from PubMed XML."""
  325. year = article.findtext('.//Journal/JournalIssue/PubDate/Year', '')
  326. if not year:
  327. medline_date = article.findtext('.//Journal/JournalIssue/PubDate/MedlineDate', '')
  328. if medline_date:
  329. year_match = re.search(r'\d{4}', medline_date)
  330. if year_match:
  331. year = year_match.group()
  332. return year
  333. def _generate_citation_key(self, metadata: Dict) -> str:
  334. """Generate a citation key from metadata."""
  335. # Get first author last name
  336. authors = metadata.get('authors', '')
  337. if authors:
  338. first_author = authors.split(' and ')[0]
  339. if ',' in first_author:
  340. last_name = first_author.split(',')[0].strip()
  341. else:
  342. last_name = first_author.split()[-1] if first_author else 'Unknown'
  343. else:
  344. last_name = 'Unknown'
  345. # Get year
  346. year = metadata.get('year', '').strip()
  347. if not year:
  348. year = 'XXXX'
  349. # Clean last name (remove special characters)
  350. last_name = re.sub(r'[^a-zA-Z]', '', last_name)
  351. # Get keyword from title
  352. title = metadata.get('title', '')
  353. words = re.findall(r'\b[a-zA-Z]{4,}\b', title)
  354. keyword = words[0].lower() if words else 'paper'
  355. return f'{last_name}{year}{keyword}'
  356. def _protect_title(self, title: str) -> str:
  357. """Protect capitalization in title for BibTeX."""
  358. # Protect common acronyms and proper nouns
  359. protected_words = [
  360. 'DNA', 'RNA', 'CRISPR', 'COVID', 'HIV', 'AIDS', 'AlphaFold',
  361. 'Python', 'AI', 'ML', 'GPU', 'CPU', 'USA', 'UK', 'EU'
  362. ]
  363. for word in protected_words:
  364. title = re.sub(rf'\b{word}\b', f'{{{word}}}', title, flags=re.IGNORECASE)
  365. return title
  366. def extract(self, identifier: str) -> Optional[str]:
  367. """
  368. Extract metadata and return BibTeX.
  369. Args:
  370. identifier: DOI, PMID, arXiv ID, or URL
  371. Returns:
  372. BibTeX string or None
  373. """
  374. id_type, clean_id = self.identify_type(identifier)
  375. print(f'Identified as {id_type}: {clean_id}', file=sys.stderr)
  376. metadata = None
  377. if id_type == 'doi':
  378. metadata = self.extract_from_doi(clean_id)
  379. elif id_type == 'pmid':
  380. metadata = self.extract_from_pmid(clean_id)
  381. elif id_type == 'arxiv':
  382. metadata = self.extract_from_arxiv(clean_id)
  383. else:
  384. print(f'Error: Unknown identifier type: {identifier}', file=sys.stderr)
  385. return None
  386. if metadata:
  387. return self.metadata_to_bibtex(metadata)
  388. else:
  389. return None
  390. def main():
  391. """Command-line interface."""
  392. parser = argparse.ArgumentParser(
  393. description='Extract citation metadata from DOI, PMID, arXiv ID, or URL',
  394. epilog='Example: python extract_metadata.py --doi 10.1038/s41586-021-03819-2'
  395. )
  396. parser.add_argument('--doi', help='Digital Object Identifier')
  397. parser.add_argument('--pmid', help='PubMed ID')
  398. parser.add_argument('--arxiv', help='arXiv ID')
  399. parser.add_argument('--url', help='URL to article')
  400. parser.add_argument('-i', '--input', help='Input file with identifiers (one per line)')
  401. parser.add_argument('-o', '--output', help='Output file for BibTeX (default: stdout)')
  402. parser.add_argument('--format', choices=['bibtex', 'json'], default='bibtex', help='Output format')
  403. parser.add_argument('--email', help='Email for NCBI E-utilities (recommended)')
  404. args = parser.parse_args()
  405. # Collect identifiers
  406. identifiers = []
  407. if args.doi:
  408. identifiers.append(args.doi)
  409. if args.pmid:
  410. identifiers.append(args.pmid)
  411. if args.arxiv:
  412. identifiers.append(args.arxiv)
  413. if args.url:
  414. identifiers.append(args.url)
  415. if args.input:
  416. try:
  417. with open(args.input, 'r', encoding='utf-8') as f:
  418. file_ids = [line.strip() for line in f if line.strip()]
  419. identifiers.extend(file_ids)
  420. except Exception as e:
  421. print(f'Error reading input file: {e}', file=sys.stderr)
  422. sys.exit(1)
  423. if not identifiers:
  424. parser.print_help()
  425. sys.exit(1)
  426. # Extract metadata
  427. extractor = MetadataExtractor(email=args.email)
  428. bibtex_entries = []
  429. for i, identifier in enumerate(identifiers):
  430. print(f'\nProcessing {i+1}/{len(identifiers)}...', file=sys.stderr)
  431. bibtex = extractor.extract(identifier)
  432. if bibtex:
  433. bibtex_entries.append(bibtex)
  434. # Rate limiting
  435. if i < len(identifiers) - 1:
  436. time.sleep(0.5)
  437. if not bibtex_entries:
  438. print('Error: No successful extractions', file=sys.stderr)
  439. sys.exit(1)
  440. # Format output
  441. if args.format == 'bibtex':
  442. output = '\n\n'.join(bibtex_entries) + '\n'
  443. else: # json
  444. output = json.dumps({
  445. 'count': len(bibtex_entries),
  446. 'entries': bibtex_entries
  447. }, indent=2)
  448. # Write output
  449. if args.output:
  450. with open(args.output, 'w', encoding='utf-8') as f:
  451. f.write(output)
  452. print(f'\nSuccessfully wrote {len(bibtex_entries)} entries to {args.output}', file=sys.stderr)
  453. else:
  454. print(output)
  455. print(f'\nExtracted {len(bibtex_entries)}/{len(identifiers)} entries', file=sys.stderr)
  456. if __name__ == '__main__':
  457. main()