Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на 20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery. Теперь это обычные типы источника — wikipedia_ru и pmc_bulk: - заводятся и запускаются из админки, как OpenAlex или КиберЛенинка; - показывают ту же шкалу, журнал и кнопку остановки; - падение воркера больше не теряет прогресс: позиция продолжения хранится в parse_sources.resume_token (номер статьи в дампе / токен страницы бакета), повторный запуск берёт следующую порцию; - укладываются в бюджет времени таска — заливка идёт порциями, а не одним многосуточным процессом. Чего не хватало конвейеру для миллионов и что добавлено: - парсеры отдают генератор, а не список: 2 млн статей в память не влезают; - app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против 6.7 тыс. построчно) с переподключением к базе при обрыве; - эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py). scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер. Проверено на проде: оба парсера отдают документы, прогон через run_parser завершается штатно, позиция продолжения сдвигается (300 → 600), повторный запуск продолжает с неё. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""Парсер PubMed Central из открытого бакета AWS — массовый путь.
|
||
|
||
Отличие от `pmc.py`: тот ходит в API E-utilities и годится для точечной
|
||
докачки по теме (1-2 статьи в секунду, есть rate limit). Этот берёт статьи из
|
||
бакета `pmc-oa-opendata`, где у каждой лежит **уже извлечённый текст**, и
|
||
качает их пачками в несколько потоков — путь к сотням тысяч и миллионам.
|
||
|
||
Ключ для доступа не нужен, бакет открыт. Замер: 12 статей/с в 10 потоков.
|
||
|
||
Как и парсер Википедии, отдаёт статьи генератором: складывать миллионы
|
||
документов в список нельзя (см. bulk_writer.py).
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
from collections.abc import Iterator
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
from base import BaseParser, ProgressCallback
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
|
||
KEY_RE = re.compile(r"<Key>(PMC\d+\.\d+)/\1\.txt</Key>")
|
||
TOKEN_RE = re.compile(r"<NextContinuationToken>([^<]+)</NextContinuationToken>")
|
||
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
|
||
MIN_CHARS = 1500 # меньше — обрывок, а не статья
|
||
|
||
|
||
class PMCBulkParser(BaseParser):
|
||
"""PMC Open Access из бакета AWS. Отдаёт статьи потоком."""
|
||
|
||
source_name = "pmc" # тот же источник в корпусе, что и у API-парсера
|
||
bulk = True
|
||
|
||
def __init__(self, workers: int = 12) -> None:
|
||
super().__init__()
|
||
self.workers = workers
|
||
self.client = httpx.Client(
|
||
timeout=60,
|
||
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
|
||
)
|
||
# Позиция листинга: бакет отдаётся страницами, и продолжать прогон
|
||
# нужно с той же страницы, а не с начала
|
||
self.last_token: str | None = None
|
||
|
||
def fetch( # type: ignore[override]
|
||
self,
|
||
limit: int = 1000,
|
||
resume_token: str | None = None,
|
||
progress_cb: ProgressCallback | None = None,
|
||
**_ignored: Any,
|
||
) -> Iterator[dict[str, Any]]:
|
||
"""Статьи из бакета: листинг страницами, скачивание в потоках.
|
||
|
||
Args:
|
||
limit: сколько статей отдать
|
||
resume_token: продолжить листинг с сохранённой страницы бакета
|
||
progress_cb: см. base.ProgressCallback
|
||
"""
|
||
token = resume_token
|
||
given = 0
|
||
|
||
while given < limit:
|
||
try:
|
||
ids, token = self._list_page(token, want=min(200, limit - given))
|
||
except Exception as e:
|
||
logger.error("PMC bulk: листинг не удался: %s", e)
|
||
return
|
||
if not ids:
|
||
logger.info("PMC bulk: бакет закончился")
|
||
return
|
||
|
||
with ThreadPoolExecutor(max_workers=self.workers) as pool:
|
||
for raw in pool.map(self._fetch_article, ids):
|
||
if raw is None:
|
||
continue
|
||
given += 1
|
||
raw["resume_token"] = token
|
||
yield raw
|
||
if given >= limit:
|
||
break
|
||
|
||
self.last_token = token
|
||
if progress_cb and not progress_cb(given):
|
||
logger.info("PMC bulk: выборка остановлена по запросу (%d)", given)
|
||
return
|
||
if token is None:
|
||
return
|
||
|
||
def _list_page(self, token: str | None, want: int) -> tuple[list[str], str | None]:
|
||
"""Одна страница листинга бакета: id статей и токен следующей страницы."""
|
||
url = f"{BUCKET}/?list-type=2&max-keys=1000"
|
||
if token:
|
||
# В токене бывают + и / — без экранирования S3 отвечает 400
|
||
url += f"&continuation-token={quote(token, safe='')}"
|
||
resp = self.client.get(url)
|
||
resp.raise_for_status()
|
||
ids = KEY_RE.findall(resp.text)[:want]
|
||
m = TOKEN_RE.search(resp.text)
|
||
return ids, (m.group(1) if m else None)
|
||
|
||
def _fetch_article(self, art_id: str) -> dict[str, Any] | None:
|
||
"""Текст статьи и метаданные; None — статья недоступна или пустая."""
|
||
try:
|
||
txt = self.client.get(f"{BUCKET}/{art_id}/{art_id}.txt")
|
||
if txt.status_code != 200 or len(txt.text) < MIN_CHARS:
|
||
return None
|
||
meta_resp = self.client.get(f"{BUCKET}/{art_id}/{art_id}.json")
|
||
meta = meta_resp.json() if meta_resp.status_code == 200 else {}
|
||
except Exception:
|
||
return None
|
||
|
||
return {"art_id": art_id, "meta": meta, "text": txt.text}
|
||
|
||
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||
"""Привести статью к унифицированному формату документов корпуса."""
|
||
meta = raw.get("meta") or {}
|
||
art_id = raw.get("art_id", "")
|
||
pmcid = meta.get("pmcid") or art_id.split(".")[0]
|
||
if not pmcid:
|
||
return {}
|
||
|
||
citation = meta.get("citation") or ""
|
||
year_match = YEAR_RE.search(citation)
|
||
text = raw.get("text", "")
|
||
|
||
return {
|
||
"source": self.source_name,
|
||
"ext_id": f"pmc:{pmcid}",
|
||
"title": (meta.get("title") or pmcid)[:1000],
|
||
"authors": [],
|
||
"doi": meta.get("doi"),
|
||
"year": int(year_match.group(0)) if year_match else None,
|
||
"lang": "en",
|
||
"journal": citation[:300] or None,
|
||
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/",
|
||
"abstract": text[:2000],
|
||
"text": text,
|
||
"resume_token": raw.get("resume_token"),
|
||
}
|