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:
215
scripts/parsers/pmc.py
Normal file
215
scripts/parsers/pmc.py
Normal 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"),
|
||||
}
|
||||
Reference in New Issue
Block a user