"""Парсер русской Википедии из дампа 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"(.*?)", re.DOTALL) TITLE_RE = re.compile(r"(.*?)", re.DOTALL) ID_RE = re.compile(r"(\d+)") NS_RE = re.compile(r"(\d+)") TEXT_RE = re.compile(r"]*>(.*?)", re.DOTALL) REDIRECT_RE = re.compile(r"]*>.*?", 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: """Разобрать в документ; 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"), }