"""Парсер русской Википедии из дампа 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"(.*?)", 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, 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: """Разобрать в документ; 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"), }