feat(parsers): новый источник PubMed Central (PMC) — реальный полный текст

Отвечает на "неужели больше нет библиотек" — добавлен четвёртый источник.
PMC Open Access Subset (NCBI E-utilities) — крупнейший биомедицинский открытый
архив, бесплатный API без обязательного ключа (NCBI_API_KEY — опционально,
поднимает лимит 3→10 запросов/сек). В отличие от остальных источников отдаёт
РЕАЛЬНЫЙ полный текст статьи (bodyJATS XML) прямо в ответе efetch — 17-53К
символов на статью в проверке вживую, не аннотацию и не 700-символьный OCR-
фрагмент. Никаких новых зависимостей — xml.etree.ElementTree (stdlib) + httpx,
по образцу arxiv.py.

- scripts/parsers/pmc.py: esearch (open access[filter]) → efetch (батчи по 20,
  JATS XML) → плоские словари → unified schema. Год-фильтр, свой User-Agent.
- Зарегистрирован в index.run_parser (services/worker-indexer/app/tasks/index.py)
  и в CLI run_parser.py — доступен как source_type="pmc" наравне с остальными.
- 7 юнит-тестов на реальных JATS XML-фрагментах (без сети): извлечение полей,
  фильтр contrib-type=author (не editor), пустой/битый XML, батч из 2 статей.

Рассмотрены и НЕ добавлены: Semantic Scholar (общий rate-limit исчерпан без
API-ключа, ключ — самостоятельная регистрация юзера) и CORE.ac.uk (обязателен
ключ). Google Scholar/ResearchGate — намеренно не трогаем, скрейпинг нарушает ToS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-24 14:47:15 +05:00
parent cc87a11f07
commit 2aac40ed3e
4 changed files with 326 additions and 0 deletions

215
scripts/parsers/pmc.py Normal file
View File

@@ -0,0 +1,215 @@
"""Парсер PubMed Central (PMC) — NCBI E-utilities.
PMC Open Access Subset — крупнейший биомедицинский открытый архив с реальным
полным текстом статей (не только аннотацией). API бесплатный, ключ не нужен
(рекомендуется для повышения лимита с 3 до 10 запросов/сек — NCBI_API_KEY).
Документация: https://www.ncbi.nlm.nih.gov/books/NBK25501/
Два запроса на пачку: esearch (ID) → efetch (полные JATS XML статьи, откуда
разом достаём метаданные + abstract + body — тело статьи, реальный полный
текст, а не аннотация или OCR-фрагмент).
"""
import logging
import os
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser
logger = logging.getLogger(__name__)
EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
BATCH_SIZE = 20
# 3 запроса/сек без ключа, 10/сек с ключом (NCBI_API_KEY в окружении)
RATE_LIMIT_DELAY = 0.12 if os.environ.get("NCBI_API_KEY") else 0.35
class PMCParser(BaseParser):
"""Парсер PubMed Central через NCBI E-utilities."""
source_name = "pmc"
def __init__(self) -> None:
super().__init__()
self.api_key = os.environ.get("NCBI_API_KEY")
self.client = httpx.Client(
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
timeout=30.0,
)
def _params(self, **extra: Any) -> dict[str, Any]:
p = dict(extra)
if self.api_key:
p["api_key"] = self.api_key
return p
def fetch(
self,
query: str = "",
limit: int = 500,
year_from: int | None = None,
year_to: int | None = None,
) -> list[dict[str, Any]]:
"""
Получить статьи из PMC Open Access Subset.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
year_from: Год публикации от
year_to: Год публикации до
Returns:
Список сырых словарей (уже с извлечёнными метаданными + full_text)
"""
term = f"{query} AND open access[filter]" if query else "open access[filter]"
if year_from or year_to:
lo = year_from or 1900
hi = year_to or 3000
term += f' AND ("{lo}"[PDAT] : "{hi}"[PDAT])'
ids = self._search_ids(term, limit)
if not ids:
return []
results: list[dict[str, Any]] = []
for i in range(0, len(ids), BATCH_SIZE):
batch = ids[i : i + BATCH_SIZE]
try:
response = self.client.get(
f"{EUTILS}/efetch.fcgi",
params=self._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"),
)
response.raise_for_status()
results.extend(self._parse_articles(response.text))
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC efetch HTTP ошибка: {e.response.status_code}")
if e.response.status_code == 429:
time.sleep(5)
continue
except Exception as e:
logger.error(f"Ошибка efetch PMC (батч {i}): {e}")
continue
return results[:limit]
def _search_ids(self, term: str, limit: int) -> list[str]:
"""Собрать PMC ID постранично через esearch."""
ids: list[str] = []
retstart = 0
page = min(200, limit)
while len(ids) < limit:
try:
response = self.client.get(
f"{EUTILS}/esearch.fcgi",
params=self._params(
db="pmc", term=term, retstart=retstart,
retmax=min(page, limit - len(ids)), retmode="json",
),
)
response.raise_for_status()
page_ids = response.json().get("esearchresult", {}).get("idlist", [])
if not page_ids:
break
ids.extend(page_ids)
retstart += len(page_ids)
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC esearch HTTP ошибка: {e.response.status_code}")
break
except Exception as e:
logger.error(f"Ошибка esearch PMC: {e}")
break
return ids[:limit]
def _parse_articles(self, xml_text: str) -> list[dict[str, Any]]:
"""Разобрать JATS XML (пачка статей из efetch) в плоские словари."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logger.error(f"Ошибка парсинга XML PMC: {e}")
return []
return [self._parse_article(art) for art in root.findall("article")]
@staticmethod
def _text(el: ET.Element | None) -> str | None:
"""Склеить весь текст элемента, включая вложенные теги форматирования."""
return "".join(el.itertext()).strip() if el is not None else None
def _parse_article(self, article: ET.Element) -> dict[str, Any]:
"""Извлечь метаданные + abstract + полный текст из одной <article>."""
am = article.find(".//article-meta")
if am is None:
return {}
pmcaid = doi = None
for aid in am.findall("article-id"):
if aid.get("pub-id-type") == "pmcaid":
pmcaid = aid.text
elif aid.get("pub-id-type") == "doi":
doi = aid.text
authors = []
for c in am.findall(".//contrib-group/contrib[@contrib-type='author']"):
surname = c.find(".//surname")
given = c.find(".//given-names")
if surname is not None and surname.text:
authors.append({
"last_name": surname.text.strip(),
"first_name": (given.text or "").strip() if given is not None else "",
})
year = None
for y in am.findall(".//pub-date/year"):
if y.text and y.text.isdigit():
year = int(y.text)
break
body = article.find("body")
full_text = None
if body is not None:
paragraphs = [self._text(p) for p in body.findall(".//p")]
full_text = " ".join(p for p in paragraphs if p) or None
return {
"id": pmcaid,
"doi": doi,
"title": self._text(am.find(".//title-group/article-title")),
"journal": self._text(article.find(".//journal-meta/journal-title-group/journal-title")),
"authors": authors,
"year": year,
"abstract": self._text(am.find(".//abstract")),
"full_text": full_text,
}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Преобразовать статью PMC в унифицированный формат."""
ext_id = raw.get("id")
if not ext_id:
return {}
authors = self.normalize_authors(raw.get("authors", []))
return {
"source": self.source_name,
"ext_id": f"pmc:{ext_id}",
"doi": raw.get("doi"),
"title": (raw.get("title") or "").strip() or None,
"authors": authors,
"year": raw.get("year"),
"lang": "en", # PMC — практически полностью англоязычный корпус
"journal": raw.get("journal"),
"volume": None,
"issue": None,
"pages": None,
"abstract": raw.get("abstract"),
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{ext_id}/",
"full_text": raw.get("full_text"),
}