Spaces:
Sleeping
Sleeping
| """PMC XML full-text fetcher for Open Access papers.""" | |
| from __future__ import annotations | |
| import os | |
| import time | |
| import xml.etree.ElementTree as ET | |
| from Bio import Entrez | |
| from logging_config import get_logger | |
| _logger = get_logger("ingestion.pmc") | |
| def _configure_entrez() -> None: | |
| email = os.environ.get("ENTREZ_EMAIL") | |
| if not email: | |
| raise EnvironmentError("ENTREZ_EMAIL environment variable is required") | |
| Entrez.email = email | |
| api_key = os.getenv("NCBI_API_KEY") | |
| if api_key: | |
| Entrez.api_key = api_key | |
| def _sleep() -> None: | |
| # NCBI rate limit: 10 req/s with API key, 3 req/s without | |
| time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4) | |
| _IDCONV_URL = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/" | |
| _IDCONV_BATCH = 200 | |
| def get_pmcids(pmids: list[str]) -> dict[str, str]: | |
| """ | |
| Map PubMed IDs to PMC IDs for papers with Open Access full text. | |
| Uses the NCBI ID Converter API (idconv) which accepts batches of 200 PMIDs | |
| and returns a proper PMID→PMCID mapping — dramatically faster than one | |
| elink call per PMID. | |
| Returns {pmid: pmcid}. | |
| """ | |
| import urllib.request | |
| import urllib.parse | |
| if not pmids: | |
| return {} | |
| from rich.progress import Progress, SpinnerColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn, TextColumn | |
| email = os.environ.get("ENTREZ_EMAIL", "") | |
| result: dict[str, str] = {} | |
| batches = [pmids[i : i + _IDCONV_BATCH] for i in range(0, len(pmids), _IDCONV_BATCH)] | |
| with Progress( | |
| SpinnerColumn(), | |
| TextColumn("[progress.description]{task.description}"), | |
| BarColumn(), | |
| TaskProgressColumn(), | |
| TimeRemainingColumn(), | |
| ) as progress: | |
| task = progress.add_task("Looking up PMC IDs...", total=len(batches)) | |
| for batch in batches: | |
| params = urllib.parse.urlencode({ | |
| "ids": ",".join(batch), | |
| "format": "json", | |
| "email": email, | |
| }) | |
| url = f"{_IDCONV_URL}?{params}" | |
| for attempt in range(3): | |
| try: | |
| with urllib.request.urlopen(url, timeout=30) as resp: | |
| import json as _json | |
| data = _json.loads(resp.read()) | |
| break | |
| except Exception as exc: | |
| if attempt == 2: | |
| _logger.warning(f"idconv batch failed: {exc}") | |
| data = {} | |
| break | |
| time.sleep(2 ** attempt) | |
| for record in data.get("records", []): | |
| pmid = record.get("pmid") | |
| pmcid = record.get("pmcid") | |
| # pmid comes back as int from the API; pmcid is like "PMC1234567" | |
| if pmid and pmcid and pmcid.startswith("PMC"): | |
| result[str(pmid)] = pmcid[3:] | |
| time.sleep(0.4) | |
| progress.advance(task) | |
| _logger.info("PMC ID lookup", extra={"data": {"pmids": len(pmids), "found": len(result)}}) | |
| return result | |
| def fetch_full_text(pmcid: str) -> str | None: | |
| """ | |
| Fetch PMC XML for a single PMCID and parse into section-labeled text. | |
| Returns None if the fetch fails or the article has no body sections. | |
| """ | |
| _configure_entrez() | |
| for attempt in range(3): | |
| try: | |
| handle = Entrez.efetch(db="pmc", id=pmcid, rettype="xml", retmode="xml") | |
| xml_data = handle.read() | |
| handle.close() | |
| break | |
| except Exception as exc: | |
| if attempt == 2: | |
| _logger.warning(f"PMC fetch failed for PMCID {pmcid}: {exc}") | |
| return None | |
| time.sleep(2 ** attempt) | |
| _sleep() | |
| return _parse_jats_xml(xml_data) | |
| def _parse_jats_xml(xml_data: bytes) -> str | None: | |
| """Extract section-labeled text from JATS/PMC XML.""" | |
| try: | |
| root = ET.fromstring(xml_data) | |
| except ET.ParseError: | |
| return None | |
| body = root.find(".//body") | |
| if body is None: | |
| return None | |
| sections: list[str] = [] | |
| # Try structured sections first (standard JATS) | |
| top_secs = [sec for sec in body.findall(".//sec") if sec in body] | |
| for sec in top_secs: | |
| title_el = sec.find("title") | |
| title = (title_el.text or "Section").strip() if title_el is not None else "Section" | |
| paragraphs = ["".join(p.itertext()).strip() for p in sec.findall(".//p")] | |
| paragraphs = [t for t in paragraphs if t] | |
| if paragraphs: | |
| sections.append(f"[{title}]\n" + "\n".join(paragraphs)) | |
| # Fall back to bare paragraphs when there are no top-level sec elements | |
| if not sections: | |
| paragraphs = ["".join(p.itertext()).strip() for p in body.findall(".//p")] | |
| paragraphs = [t for t in paragraphs if t] | |
| if paragraphs: | |
| sections.append("[Body]\n" + "\n".join(paragraphs)) | |
| return "\n\n".join(sections) if sections else None | |