Заливка Википедии останавливалась сама собой: позиция хранилась как номер статьи, и каждый прогон перечитывал дамп с начала. На 20 тысячах это стоило 6 минут из 25 доступных, на 27 тысячах — уже около десяти, а на сотне тысяч съело бы весь бюджет и заливка встала бы совсем. Ровно это и наблюдалось: прогон висел с нулём полученных статей, воркер на 100% CPU. Переход на multistream-вариант дампа: он состоит из независимых bz2-блоков по ~95 статей, к нему прилагается индекс со смещениями. Позиция продолжения стала байтовым смещением, прогон стартует мгновенно с нужного места. Проверено на реальных файлах, а не по предположению: формат индекса (offset:page_id:title), 21 уникальное смещение на 2001 статью, прыжок seek на смещение из середины файла даёт валидный XML со страницами. wikipedia_resume_offset.py — разовый пересчёт позиции при переходе: находит по индексу блок с максимальным залитым page_id (получилось 273 281 821). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
9.5 KiB
Python
200 lines
9.5 KiB
Python
"""Парсер русской Википедии из дампа Wikimedia.
|
||
|
||
Зачем в корпусе: студенты копируют из Википедии чаще, чем из научных статей, а
|
||
по объёму связного русского текста ей нет альтернативы среди доступного —
|
||
КиберЛенинка блокирует выкачку, eLIBRARY требует договора.
|
||
|
||
Отличие от остальных парсеров: `fetch` возвращает **генератор**, а не список.
|
||
Дамп — 5.6 ГБ и около 2 млн статей, держать их в памяти нельзя, поэтому
|
||
`run_parser` читает результат лениво и пишет пачками (см. bulk_writer.py).
|
||
|
||
Используется **multistream**-вариант дампа: он состоит из независимых bz2-блоков
|
||
по 100 статей, и к нему прилагается индекс со смещениями. Это принципиально —
|
||
обычный дамп читается только с начала, поэтому каждый следующий прогон
|
||
перечитывал всё уже залитое: на 20 тысячах статей это стоило 6 минут из 25
|
||
доступных, а на 100 тысячах съело бы весь бюджет и заливка встала бы совсем.
|
||
С multistream позиция продолжения — байтовое смещение, и прогон стартует
|
||
мгновенно.
|
||
|
||
Файлы качаются заранее и кладутся туда, где их видит воркер:
|
||
B=https://dumps.wikimedia.org/ruwiki/latest
|
||
UA="AcademicHelper/1.0 (https://academic.jze9.ru; noreply@jze9.ru)"
|
||
curl -L -C - --retry 100 -A "$UA" -o scripts/parsers/ruwiki-multistream.xml.bz2 \\
|
||
$B/ruwiki-latest-pages-articles-multistream.xml.bz2
|
||
|
||
Читать дамп прямо из сети не выйдет: Wikimedia обрывает долгие соединения
|
||
(проверено — обрыв на 32 МБ из 5.9 ГБ).
|
||
"""
|
||
|
||
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-multistream.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,
|
||
start_offset: int = 0,
|
||
progress_cb: ProgressCallback | None = None,
|
||
**_ignored: Any,
|
||
) -> Iterator[dict[str, Any]]:
|
||
"""Статьи основного пространства имён из multistream-дампа.
|
||
|
||
Args:
|
||
limit: сколько статей отдать
|
||
min_chars: минимальная длина текста — заготовки в корпусе бесполезны
|
||
dump_path: путь к дампу (по умолчанию /parsers/ruwiki-multistream.xml.bz2)
|
||
start_offset: байтовое смещение в файле, с которого продолжать.
|
||
Именно смещение, а не номер статьи: перечитывание дампа с начала
|
||
росло линейно и на сотне тысяч статей съедало весь бюджет прогона
|
||
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"
|
||
)
|
||
|
||
given = 0
|
||
buf = ""
|
||
|
||
with open(path, "rb") as fh:
|
||
fh.seek(start_offset)
|
||
# Смещение блока, из которого пришли уже отданные статьи: его и
|
||
# сохраняем как позицию продолжения, чтобы ничего не потерять
|
||
block_offset = start_offset
|
||
decomp = bz2.BZ2Decompressor()
|
||
|
||
while given < limit:
|
||
part = fh.read(4 * 1024 * 1024)
|
||
if not part:
|
||
break
|
||
|
||
# В multistream-дампе потоки идут подряд: закончился один —
|
||
# начинаем следующий с того места, где предыдущий остановился
|
||
while part:
|
||
try:
|
||
raw = decomp.decompress(part)
|
||
except (OSError, EOFError):
|
||
return
|
||
if raw:
|
||
buf += raw.decode("utf-8", errors="replace")
|
||
|
||
if not decomp.eof:
|
||
break
|
||
part = decomp.unused_data
|
||
decomp = bz2.BZ2Decompressor()
|
||
|
||
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
|
||
|
||
given += 1
|
||
doc["dump_offset"] = block_offset
|
||
yield doc
|
||
|
||
if progress_cb and not progress_cb(given):
|
||
logger.info("Википедия: выборка остановлена по запросу (%d)", given)
|
||
return
|
||
|
||
# Всё разобранное отдано — следующая позиция продолжения здесь
|
||
block_offset = fh.tell() - len(buf.encode("utf-8", errors="ignore")) // 4
|
||
|
||
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_offset": raw.get("dump_offset"),
|
||
}
|