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