Files
anti-plagiarism/scripts/parsers/pmc.py
jze9 2aac40ed3e 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>
2026-08-24 14:47:15 +05:00

216 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Парсер 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"),
}