refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
Заливку Википедии и 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>
This commit is contained in:
144
scripts/parsers/pmc_bulk.py
Normal file
144
scripts/parsers/pmc_bulk.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Парсер 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"),
|
||||
}
|
||||
Reference in New Issue
Block a user