search_pubmed.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. #!/usr/bin/env python3
  2. """
  3. PubMed Search Tool
  4. Search PubMed using E-utilities API and export results.
  5. """
  6. import sys
  7. import os
  8. import requests
  9. import argparse
  10. import json
  11. import time
  12. import xml.etree.ElementTree as ET
  13. from typing import List, Dict, Optional
  14. from datetime import datetime
  15. class PubMedSearcher:
  16. """Search PubMed using NCBI E-utilities API."""
  17. def __init__(self, api_key: Optional[str] = None, email: Optional[str] = None):
  18. """
  19. Initialize searcher.
  20. Args:
  21. api_key: NCBI API key (optional but recommended)
  22. email: Email for Entrez (optional but recommended)
  23. """
  24. self.api_key = api_key or os.getenv('NCBI_API_KEY', '')
  25. self.email = email or os.getenv('NCBI_EMAIL', '')
  26. self.base_url = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/'
  27. self.session = requests.Session()
  28. # Rate limiting
  29. self.delay = 0.11 if self.api_key else 0.34 # 10/sec with key, 3/sec without
  30. def search(self, query: str, max_results: int = 100,
  31. date_start: Optional[str] = None, date_end: Optional[str] = None,
  32. publication_types: Optional[List[str]] = None) -> List[str]:
  33. """
  34. Search PubMed and return PMIDs.
  35. Args:
  36. query: Search query
  37. max_results: Maximum number of results
  38. date_start: Start date (YYYY/MM/DD or YYYY)
  39. date_end: End date (YYYY/MM/DD or YYYY)
  40. publication_types: List of publication types to filter
  41. Returns:
  42. List of PMIDs
  43. """
  44. # Build query with filters
  45. full_query = query
  46. # Add date range
  47. if date_start or date_end:
  48. start = date_start or '1900'
  49. end = date_end or datetime.now().strftime('%Y')
  50. full_query += f' AND {start}:{end}[Publication Date]'
  51. # Add publication types
  52. if publication_types:
  53. pub_type_query = ' OR '.join([f'"{pt}"[Publication Type]' for pt in publication_types])
  54. full_query += f' AND ({pub_type_query})'
  55. print(f'Searching PubMed: {full_query}', file=sys.stderr)
  56. # ESearch to get PMIDs
  57. esearch_url = self.base_url + 'esearch.fcgi'
  58. params = {
  59. 'db': 'pubmed',
  60. 'term': full_query,
  61. 'retmax': max_results,
  62. 'retmode': 'json'
  63. }
  64. if self.email:
  65. params['email'] = self.email
  66. if self.api_key:
  67. params['api_key'] = self.api_key
  68. try:
  69. response = self.session.get(esearch_url, params=params, timeout=30)
  70. response.raise_for_status()
  71. data = response.json()
  72. pmids = data['esearchresult']['idlist']
  73. count = int(data['esearchresult']['count'])
  74. print(f'Found {count} results, retrieving {len(pmids)}', file=sys.stderr)
  75. return pmids
  76. except Exception as e:
  77. print(f'Error searching PubMed: {e}', file=sys.stderr)
  78. return []
  79. def fetch_metadata(self, pmids: List[str]) -> List[Dict]:
  80. """
  81. Fetch metadata for PMIDs.
  82. Args:
  83. pmids: List of PubMed IDs
  84. Returns:
  85. List of metadata dictionaries
  86. """
  87. if not pmids:
  88. return []
  89. metadata_list = []
  90. # Fetch in batches of 200
  91. batch_size = 200
  92. for i in range(0, len(pmids), batch_size):
  93. batch = pmids[i:i+batch_size]
  94. print(f'Fetching metadata for PMIDs {i+1}-{min(i+batch_size, len(pmids))}...', file=sys.stderr)
  95. efetch_url = self.base_url + 'efetch.fcgi'
  96. params = {
  97. 'db': 'pubmed',
  98. 'id': ','.join(batch),
  99. 'retmode': 'xml',
  100. 'rettype': 'abstract'
  101. }
  102. if self.email:
  103. params['email'] = self.email
  104. if self.api_key:
  105. params['api_key'] = self.api_key
  106. try:
  107. response = self.session.get(efetch_url, params=params, timeout=60)
  108. response.raise_for_status()
  109. # Parse XML
  110. root = ET.fromstring(response.content)
  111. articles = root.findall('.//PubmedArticle')
  112. for article in articles:
  113. metadata = self._extract_metadata_from_xml(article)
  114. if metadata:
  115. metadata_list.append(metadata)
  116. # Rate limiting
  117. time.sleep(self.delay)
  118. except Exception as e:
  119. print(f'Error fetching metadata for batch: {e}', file=sys.stderr)
  120. continue
  121. return metadata_list
  122. def _extract_metadata_from_xml(self, article: ET.Element) -> Optional[Dict]:
  123. """Extract metadata from PubmedArticle XML element."""
  124. try:
  125. medline_citation = article.find('.//MedlineCitation')
  126. article_elem = medline_citation.find('.//Article')
  127. journal = article_elem.find('.//Journal')
  128. # Get PMID
  129. pmid = medline_citation.findtext('.//PMID', '')
  130. # Get DOI
  131. doi = None
  132. article_ids = article.findall('.//ArticleId')
  133. for article_id in article_ids:
  134. if article_id.get('IdType') == 'doi':
  135. doi = article_id.text
  136. break
  137. # Get authors
  138. authors = []
  139. author_list = article_elem.find('.//AuthorList')
  140. if author_list is not None:
  141. for author in author_list.findall('.//Author'):
  142. last_name = author.findtext('.//LastName', '')
  143. fore_name = author.findtext('.//ForeName', '')
  144. if last_name:
  145. if fore_name:
  146. authors.append(f'{last_name}, {fore_name}')
  147. else:
  148. authors.append(last_name)
  149. # Get year
  150. year = article_elem.findtext('.//Journal/JournalIssue/PubDate/Year', '')
  151. if not year:
  152. medline_date = article_elem.findtext('.//Journal/JournalIssue/PubDate/MedlineDate', '')
  153. if medline_date:
  154. import re
  155. year_match = re.search(r'\d{4}', medline_date)
  156. if year_match:
  157. year = year_match.group()
  158. metadata = {
  159. 'pmid': pmid,
  160. 'doi': doi,
  161. 'title': article_elem.findtext('.//ArticleTitle', ''),
  162. 'authors': ' and '.join(authors),
  163. 'journal': journal.findtext('.//Title', ''),
  164. 'year': year,
  165. 'volume': journal.findtext('.//JournalIssue/Volume', ''),
  166. 'issue': journal.findtext('.//JournalIssue/Issue', ''),
  167. 'pages': article_elem.findtext('.//Pagination/MedlinePgn', ''),
  168. 'abstract': article_elem.findtext('.//Abstract/AbstractText', '')
  169. }
  170. return metadata
  171. except Exception as e:
  172. print(f'Error extracting metadata: {e}', file=sys.stderr)
  173. return None
  174. def metadata_to_bibtex(self, metadata: Dict) -> str:
  175. """Convert metadata to BibTeX format."""
  176. # Generate citation key
  177. if metadata.get('authors'):
  178. first_author = metadata['authors'].split(' and ')[0]
  179. if ',' in first_author:
  180. last_name = first_author.split(',')[0].strip()
  181. else:
  182. last_name = first_author.split()[0]
  183. else:
  184. last_name = 'Unknown'
  185. year = metadata.get('year', 'XXXX')
  186. citation_key = f'{last_name}{year}pmid{metadata.get("pmid", "")}'
  187. # Build BibTeX entry
  188. lines = [f'@article{{{citation_key},']
  189. if metadata.get('authors'):
  190. lines.append(f' author = {{{metadata["authors"]}}},')
  191. if metadata.get('title'):
  192. lines.append(f' title = {{{metadata["title"]}}},')
  193. if metadata.get('journal'):
  194. lines.append(f' journal = {{{metadata["journal"]}}},')
  195. if metadata.get('year'):
  196. lines.append(f' year = {{{metadata["year"]}}},')
  197. if metadata.get('volume'):
  198. lines.append(f' volume = {{{metadata["volume"]}}},')
  199. if metadata.get('issue'):
  200. lines.append(f' number = {{{metadata["issue"]}}},')
  201. if metadata.get('pages'):
  202. pages = metadata['pages'].replace('-', '--')
  203. lines.append(f' pages = {{{pages}}},')
  204. if metadata.get('doi'):
  205. lines.append(f' doi = {{{metadata["doi"]}}},')
  206. if metadata.get('pmid'):
  207. lines.append(f' note = {{PMID: {metadata["pmid"]}}},')
  208. # Remove trailing comma
  209. if lines[-1].endswith(','):
  210. lines[-1] = lines[-1][:-1]
  211. lines.append('}')
  212. return '\n'.join(lines)
  213. def main():
  214. """Command-line interface."""
  215. parser = argparse.ArgumentParser(
  216. description='Search PubMed using E-utilities API',
  217. epilog='Example: python search_pubmed.py "CRISPR gene editing" --limit 100'
  218. )
  219. parser.add_argument(
  220. 'query',
  221. nargs='?',
  222. help='Search query (PubMed syntax)'
  223. )
  224. parser.add_argument(
  225. '--query',
  226. dest='query_arg',
  227. help='Search query (alternative to positional argument)'
  228. )
  229. parser.add_argument(
  230. '--query-file',
  231. help='File containing search query'
  232. )
  233. parser.add_argument(
  234. '--limit',
  235. type=int,
  236. default=100,
  237. help='Maximum number of results (default: 100)'
  238. )
  239. parser.add_argument(
  240. '--date-start',
  241. help='Start date (YYYY/MM/DD or YYYY)'
  242. )
  243. parser.add_argument(
  244. '--date-end',
  245. help='End date (YYYY/MM/DD or YYYY)'
  246. )
  247. parser.add_argument(
  248. '--publication-types',
  249. help='Comma-separated publication types (e.g., "Review,Clinical Trial")'
  250. )
  251. parser.add_argument(
  252. '-o', '--output',
  253. help='Output file (default: stdout)'
  254. )
  255. parser.add_argument(
  256. '--format',
  257. choices=['json', 'bibtex'],
  258. default='json',
  259. help='Output format (default: json)'
  260. )
  261. parser.add_argument(
  262. '--api-key',
  263. help='NCBI API key (or set NCBI_API_KEY env var)'
  264. )
  265. parser.add_argument(
  266. '--email',
  267. help='Email for Entrez (or set NCBI_EMAIL env var)'
  268. )
  269. args = parser.parse_args()
  270. # Get query
  271. query = args.query or args.query_arg
  272. if args.query_file:
  273. try:
  274. with open(args.query_file, 'r', encoding='utf-8') as f:
  275. query = f.read().strip()
  276. except Exception as e:
  277. print(f'Error reading query file: {e}', file=sys.stderr)
  278. sys.exit(1)
  279. if not query:
  280. parser.print_help()
  281. sys.exit(1)
  282. # Parse publication types
  283. pub_types = None
  284. if args.publication_types:
  285. pub_types = [pt.strip() for pt in args.publication_types.split(',')]
  286. # Search PubMed
  287. searcher = PubMedSearcher(api_key=args.api_key, email=args.email)
  288. pmids = searcher.search(
  289. query,
  290. max_results=args.limit,
  291. date_start=args.date_start,
  292. date_end=args.date_end,
  293. publication_types=pub_types
  294. )
  295. if not pmids:
  296. print('No results found', file=sys.stderr)
  297. sys.exit(1)
  298. # Fetch metadata
  299. metadata_list = searcher.fetch_metadata(pmids)
  300. # Format output
  301. if args.format == 'json':
  302. output = json.dumps({
  303. 'query': query,
  304. 'count': len(metadata_list),
  305. 'results': metadata_list
  306. }, indent=2)
  307. else: # bibtex
  308. bibtex_entries = [searcher.metadata_to_bibtex(m) for m in metadata_list]
  309. output = '\n\n'.join(bibtex_entries) + '\n'
  310. # Write output
  311. if args.output:
  312. with open(args.output, 'w', encoding='utf-8') as f:
  313. f.write(output)
  314. print(f'Wrote {len(metadata_list)} results to {args.output}', file=sys.stderr)
  315. else:
  316. print(output)
  317. if __name__ == '__main__':
  318. main()