Ctrl K
ComunidadGuías…

scripts/fetch_pubmed.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
PubMed / PMC Fetcher — XKB knowledge pipeline

從 PubMed Central 搜尋並下載開放取用論文全文(XML API)。
只抓 Open Access 論文,使用 NCBI 官方 API,不需要訂閱。

Usage:
  python3 fetch_pubmed.py "CRISPR gene editing" --limit 20
  python3 fetch_pubmed.py "AI drug discovery" --limit 30 --out /tmp/papers
  python3 fetch_pubmed.py "cancer immunotherapy" --limit 10 --dry-run
  python3 fetch_pubmed.py "alzheimer treatment" --since 2022 --limit 25

After fetching, ingest into XKB:
  python3 pdf_ingest.py /tmp/papers/ --category research --rebuild-index
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
from pathlib import Path

ESEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
EFETCH_URL  = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"

HEADERS = {"User-Agent": "XKB-Research-Fetcher/1.0 (education/research use)"}
RATE_LIMIT = 0.35  # NCBI allows ~3 req/sec


def _get(url: str, params: dict) -> bytes:
    full_url = url + "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(full_url, headers=HEADERS)
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()


# ── Search ─────────────────────────────────────────────────────────────────────

def search_pmc(query: str, limit: int, since_year: int | None) -> list[str]:
    full_query = f"({query}) AND pmc open access[filter]"
    if since_year:
        full_query += f" AND {since_year}:2030[pdat]"

    print(f"🔍 Searching PMC: {query}")
    data = json.loads(_get(ESEARCH_URL, {
        "db": "pmc",
        "term": full_query,
        "retmax": min(limit * 3, 500),
        "retmode": "json",
        "sort": "relevance",
    }))
    ids = data.get("esearchresult", {}).get("idlist", [])
    total = data.get("esearchresult", {}).get("count", "?")
    print(f"  {total} open-access results found, processing up to {limit}")
    return ids


# ── Full text extraction ───────────────────────────────────────────────────────

def _xml_text(elem) -> str:
    """Recursively get all text from an XML element."""
    parts = []
    if elem.text:
        parts.append(elem.text.strip())
    for child in elem:
        parts.append(_xml_text(child))
        if child.tail:
            parts.append(child.tail.strip())
    return " ".join(p for p in parts if p)


def fetch_fulltext_xml(pmc_id: str) -> dict | None:
    """Fetch full text from PMC using official efetch XML API."""
    try:
        data = _get(EFETCH_URL, {
            "db": "pmc",
            "id": pmc_id,
            "rettype": "full",
            "retmode": "xml",
        })
        root = ET.fromstring(data)
    except Exception as e:
        return None

    # Extract title
    title = ""
    for t in root.iter("article-title"):
        title = _xml_text(t).strip()
        if title:
            break

    # Extract authors
    authors = []
    for contrib in root.iter("contrib"):
        if contrib.get("contrib-type") == "author":
            surname = next(contrib.iter("surname"), None)
            given = next(contrib.iter("given-names"), None)
            if surname is not None:
                name = _xml_text(surname)
                if given is not None:
                    name = f"{_xml_text(given)} {name}"
                authors.append(name)

    # Extract abstract
    abstract_parts = []
    for abs_elem in root.iter("abstract"):
        abstract_parts.append(_xml_text(abs_elem))
    abstract = " ".join(abstract_parts).strip()

    # Extract body sections
    body_parts = []
    for body in root.iter("body"):
        for sec in body.iter("sec"):
            sec_title_elem = sec.find("title")
            sec_title = _xml_text(sec_title_elem).strip() if sec_title_elem is not None else ""
            # Get paragraphs in this section
            paras = []
            for p in sec.iter("p"):
                text = _xml_text(p).strip()
                if len(text) > 30:
                    paras.append(text)
            if paras:
                if sec_title:
                    body_parts.append(f"\n## {sec_title}\n")
                body_parts.extend(paras)

    # If no structured body, get all paragraphs
    if not body_parts:
        for p in root.iter("p"):
            text = _xml_text(p).strip()
            if len(text) > 30:
                body_parts.append(text)

    full_text = f"# {title}\n\n"
    if authors:
        full_text += f"**Authors:** {', '.join(authors[:5])}\n\n"
    if abstract:
        full_text += f"## Abstract\n{abstract}\n\n"
    full_text += "\n".join(body_parts)

    # Clean up whitespace
    full_text = re.sub(r" {2,}", " ", full_text)
    full_text = re.sub(r"\n{3,}", "\n\n", full_text)
    full_text = full_text.strip()

    if len(full_text) < 300:
        return None  # Probably empty / not available

    return {
        "pmc_id": pmc_id,
        "title": title or f"PMC{pmc_id}",
        "authors": authors,
        "abstract": abstract,
        "full_text": full_text,
        "char_count": len(full_text),
    }


# ── Save as text file (for pdf_ingest.py to pick up) ──────────────────────────

def save_as_text(article: dict, out_dir: Path) -> Path:
    safe_title = re.sub(r'[^\w\s-]', '', article["title"])[:60].strip().replace(" ", "_")
    filename = f"PMC{article['pmc_id']}_{safe_title}.md"
    dest = out_dir / filename
    dest.write_text(article["full_text"], encoding="utf-8")
    return dest


# ── Main fetch loop ────────────────────────────────────────────────────────────

def fetch(
    query: str,
    limit: int,
    out_dir: Path,
    since_year: int | None,
    dry_run: bool,
) -> int:
    out_dir.mkdir(parents=True, exist_ok=True)
    pmc_ids = search_pmc(query, limit, since_year)
    if not pmc_ids:
        print("No results found.")
        return 0

    time.sleep(RATE_LIMIT)

    saved = 0
    failed = 0
    skipped = 0

    for i, pmc_id in enumerate(pmc_ids):
        if saved >= limit:
            break

        # Check if already fetched
        existing = list(out_dir.glob(f"PMC{pmc_id}_*.md"))
        if existing:
            print(f"[{i+1}] PMC{pmc_id} — already fetched, skipping")
            skipped += 1
            saved += 1
            continue

        print(f"[{i+1}/{len(pmc_ids)}] PMC{pmc_id} ", end="", flush=True)

        if dry_run:
            print(f"[dry-run]")
            saved += 1
            continue

        article = fetch_fulltext_xml(pmc_id)
        time.sleep(RATE_LIMIT)

        if not article:
            print(f"✗ No full text available")
            failed += 1
            continue

        dest = save_as_text(article, out_dir)
        print(f"✓ {article['title'][:55]} ({article['char_count']:,} chars)")
        saved += 1

    print(f"\n{'='*55}")
    print(f"✅ Saved:   {saved - skipped}")
    print(f"↩  Skipped: {skipped} (already fetched)")
    print(f"✗  Failed:  {failed} (no full text in PMC)")

    if not dry_run and (saved - skipped) > 0:
        print(f"\n📂 Output: {out_dir}")
        print(f"\nNext — ingest into XKB knowledge base:")
        print(f"  python3 scripts/pdf_ingest.py {out_dir} --category research --rebuild-index")

    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Fetch open-access full-text papers from PubMed Central")
    parser.add_argument("query", help="Search query (e.g. 'CRISPR cancer 2023')")
    parser.add_argument("--limit", type=int, default=20, help="Max papers to fetch (default: 20)")
    parser.add_argument("--out", default="/tmp/pubmed_papers", help="Output directory")
    parser.add_argument("--since", type=int, dest="since_year", help="Only papers from this year onwards")
    parser.add_argument("--dry-run", action="store_true", help="Preview without downloading")
    args = parser.parse_args()

    return fetch(
        query=args.query,
        limit=args.limit,
        out_dir=Path(args.out),
        since_year=args.since_year,
        dry_run=args.dry_run,
    )


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())