refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s

Заливку Википедии и 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:
jze9
2026-09-05 20:32:53 +05:00
parent 1b1ca3b7e3
commit 26de1b9de1
15 changed files with 732 additions and 584 deletions

View File

@@ -0,0 +1,179 @@
"""Парсер русской Википедии из дампа Wikimedia.
Зачем в корпусе: студенты копируют из Википедии чаще, чем из научных статей, а
по объёму связного русского текста ей нет альтернативы среди доступного —
КиберЛенинка блокирует выкачку, eLIBRARY требует договора.
Отличие от остальных парсеров: `fetch` возвращает **генератор**, а не список.
Дамп — 5.6 ГБ и около 2 млн статей, держать их в памяти нельзя, поэтому
`run_parser` читает результат лениво и пишет пачками (см. bulk_writer.py).
Дамп качается заранее и кладётся туда, где его видит воркер:
curl -L -C - --retry 100 -A "AcademicHelper/1.0 (noreply@jze9.ru)" \\
-o scripts/parsers/ruwiki.xml.bz2 \\
https://dumps.wikimedia.org/ruwiki/latest/ruwiki-latest-pages-articles.xml.bz2
Читать дамп прямо из сети не выйдет: Wikimedia обрывает долгие соединения
(проверено — обрыв на 32 МБ из 5.9 ГБ), да и продолжить с места разрыва
распакованный поток bz2 нельзя.
"""
import bz2
import logging
import os
import re
from collections.abc import Iterator
from typing import Any
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
DEFAULT_DUMP = "/parsers/ruwiki.xml.bz2"
PAGE_RE = re.compile(r"<page>(.*?)</page>", re.DOTALL)
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
ID_RE = re.compile(r"<id>(\d+)</id>")
NS_RE = re.compile(r"<ns>(\d+)</ns>")
TEXT_RE = re.compile(r"<text[^>]*>(.*?)</text>", re.DOTALL)
REDIRECT_RE = re.compile(r"<redirect ")
# Разметку чистим регулярками: для отпечатков нужен связный текст, а не точное
# восстановление вёрстки — полноценный парсер вики-разметки тут неоправдан
TEMPLATE_RE = re.compile(r"\{\{[^{}]*\}\}")
CLEAN_RULES = [
(re.compile(r"\[\[[^\]|]*\|"), ""), # [[ссылка|текст → текст
(re.compile(r"\[\[|\]\]"), ""),
(re.compile(r"<ref[^>]*>.*?</ref>", re.DOTALL), " "),
(re.compile(r"<[^>]+>"), " "),
(re.compile(r"^[*#:;|!].*$", re.MULTILINE), " "), # списки и таблицы
(re.compile(r"^=+.*?=+$", re.MULTILINE), " "), # == заголовки разделов ==
(re.compile(r"'{2,}"), ""),
(re.compile(r"&[a-z]+;"), " "),
(re.compile(r"[ \t]+"), " "),
(re.compile(r"\n{2,}"), "\n"),
]
def clean_wikitext(raw: str) -> str:
"""Убрать вики-разметку, оставив читаемый текст статьи."""
text = raw
for _ in range(3): # шаблоны бывают вложенными
text = TEMPLATE_RE.sub(" ", text)
for rx, repl in CLEAN_RULES:
text = rx.sub(repl, text)
return text.strip()
class WikipediaRuParser(BaseParser):
"""Русская Википедия из локального дампа. Отдаёт статьи потоком."""
source_name = "wikipedia_ru"
# Признак для run_parser: результат читается лениво и пишется пачками,
# а не собирается в список и не идёт через add_document по одному
bulk = True
def fetch( # type: ignore[override]
self,
limit: int = 1000,
min_chars: int = 2000,
dump_path: str | None = None,
skip: int = 0,
progress_cb: ProgressCallback | None = None,
**_ignored: Any,
) -> Iterator[dict[str, Any]]:
"""Статьи основного пространства имён из дампа.
Args:
limit: сколько статей отдать
min_chars: минимальная длина текста — заготовки в корпусе бесполезны
dump_path: путь к дампу (по умолчанию /parsers/ruwiki.xml.bz2)
skip: сколько статей пропустить — так прогон продолжается с места
обрыва, не перечитывая уже залитое
progress_cb: см. base.ProgressCallback
"""
path = dump_path or os.environ.get("WIKIPEDIA_DUMP_PATH") or DEFAULT_DUMP
if not os.path.exists(path):
raise FileNotFoundError(
f"дамп Википедии не найден: {path} — скачайте его (см. модуль) "
f"или укажите WIKIPEDIA_DUMP_PATH"
)
decomp = bz2.BZ2Decompressor()
buf = ""
seen = 0 # статей прошло через парсер — позиция в дампе
given = 0 # статей отдано наружу
with open(path, "rb") as fh:
while given < limit:
part = fh.read(4 * 1024 * 1024)
if not part:
break
try:
raw = decomp.decompress(part)
except EOFError:
break
if not raw:
continue
buf += raw.decode("utf-8", errors="replace")
while given < limit:
m = PAGE_RE.search(buf)
if not m:
break
page, buf = m.group(1), buf[m.end():]
doc = self._page_to_doc(page, min_chars)
if doc is None:
continue
seen += 1
if seen <= skip: # быстро проматываем уже обработанное
continue
given += 1
doc["dump_position"] = seen
yield doc
if progress_cb and not progress_cb(given):
logger.info("Википедия: выборка остановлена по запросу (%d)", given)
return
if len(buf) > 20 * 1024 * 1024: # страховка от разбухания
buf = buf[-1024 * 1024:]
def _page_to_doc(self, page: str, min_chars: int) -> dict[str, Any] | None:
"""Разобрать <page> в документ; None — страница нам не подходит."""
if REDIRECT_RE.search(page):
return None
ns = NS_RE.search(page)
if not ns or ns.group(1) != "0": # только статьи
return None
tm, im, xm = TITLE_RE.search(page), ID_RE.search(page), TEXT_RE.search(page)
if not (tm and im and xm):
return None
text = clean_wikitext(xm.group(1))
if len(text) < min_chars:
return None
return {"pageid": im.group(1), "title": tm.group(1), "text": text}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Привести статью к унифицированному формату документов корпуса."""
pid = raw.get("pageid")
if not pid:
return {}
return {
"source": self.source_name,
"ext_id": f"wikipedia_ru:{pid}",
"title": raw.get("title", ""),
"authors": [],
"year": None,
"lang": "ru",
"url": f"https://ru.wikipedia.org/?curid={pid}",
"abstract": (raw.get("text") or "")[:2000],
"text": raw.get("text", ""),
"dump_position": raw.get("dump_position"),
}