#!/usr/bin/env python3 """Extract clean Russian sentences from an official Wikisource XML dump.""" from __future__ import annotations import argparse import bz2 import heapq import hashlib import json import re import sys from datetime import datetime, timezone from pathlib import Path from xml.etree import ElementTree SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?…])\s+") WHITESPACE = re.compile(r"\s+") RUSSIAN_LETTER = re.compile(r"[А-Яа-яЁё]") ANY_LETTER = re.compile(r"[^\W\d_]", re.UNICODE) BAD_MARKUP = ("{{", "}}", "[[", "]]", "http://", "https://", " str: return tag.rsplit("}", 1)[-1] def descendant_text(element, name: str) -> str: for child in element.iter(): if local_name(child.tag) == name: return child.text or "" return "" def clean_page_text(source: str) -> str: import mwparserfromhell source = re.sub(r"]*>.*?|]*/>", " ", source, flags=re.IGNORECASE | re.DOTALL) source = re.sub(r"\{\|.*?\|\}", " ", source, flags=re.DOTALL) plain = mwparserfromhell.parse(source).strip_code(normalize=True, collapse=True) return WHITESPACE.sub(" ", plain).strip() def acceptable(sentence: str) -> bool: if not 40 <= len(sentence) <= 240 or any(marker in sentence for marker in BAD_MARKUP): return False if sentence[-1:] not in ".!?…": return False words = sentence.split() if not 6 <= len(words) <= 45: return False letters = ANY_LETTER.findall(sentence) if not letters: return False russian = RUSSIAN_LETTER.findall(sentence) return len(russian) / len(letters) >= 0.75 def extract(args: argparse.Namespace) -> None: temporary = args.output.with_suffix(args.output.suffix + ".part") if args.output.exists() or args.metadata.exists() or temporary.exists(): raise FileExistsError("Refusing to overwrite output, metadata, or partial output") args.output.parent.mkdir(parents=True, exist_ok=True) seen: set[bytes] = set() sample_heap: list[tuple[int, bytes, str]] = [] pages = 0 accepted = 0 candidates = 0 with bz2.open(args.dump, "rb") as source, temporary.open("w", encoding="utf-8", newline="\n") as output: for _, element in ElementTree.iterparse(source, events=("end",)): if local_name(element.tag) != "page": continue pages += 1 namespace = descendant_text(element, "ns") title = descendant_text(element, "title") wiki_text = descendant_text(element, "text") if namespace == "0" and wiki_text and not title.startswith(("Категория:", "Шаблон:", "Справка:")): for sentence in SENTENCE_BOUNDARY.split(clean_page_text(wiki_text)): # Leading dashes carry dialogue/prosody information and must survive extraction. sentence = sentence.strip(" \t\r\n") if not acceptable(sentence): continue digest = hashlib.blake2b(sentence.encode("utf-8"), digest_size=16).digest() candidates += 1 if args.sampling == "first": if digest in seen: continue seen.add(digest) output.write(sentence + "\n") accepted += 1 if accepted % 10_000 == 0: print(f"sentences={accepted} pages={pages}", flush=True) if accepted >= args.max_sentences: break else: score = int.from_bytes(digest, "big") if digest in seen: continue if len(sample_heap) < args.max_sentences: heapq.heappush(sample_heap, (-score, digest, sentence)); seen.add(digest) elif score < -sample_heap[0][0]: _, removed_digest, _ = heapq.heapreplace(sample_heap, (-score, digest, sentence)) seen.remove(removed_digest); seen.add(digest) element.clear() if args.sampling == "first" and accepted >= args.max_sentences: break if pages % 10_000 == 0: print(f"pages={pages} candidates={candidates} retained={len(sample_heap)}", flush=True) if args.sampling == "hash": selected = sorted((-negative_score, sentence) for negative_score, _, sentence in sample_heap) for _, sentence in selected: output.write(sentence + "\n") accepted = len(selected) temporary.replace(args.output) metadata = { "source_url": args.source_url, "dump_file": args.dump.name, "dump_bytes": args.dump.stat().st_size, "pages_scanned": pages, "candidate_sentences": candidates, "sentences": accepted, "output_bytes": args.output.stat().st_size, "created_utc": datetime.now(timezone.utc).isoformat(), "sampling": args.sampling, "filters": {"min_chars": 40, "max_chars": 240, "min_words": 6, "max_words": 45, "min_russian_letter_ratio": 0.75}, } args.metadata.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(metadata, ensure_ascii=False, indent=2)) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--dump", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--metadata", type=Path, required=True) parser.add_argument("--max-sentences", type=int, default=200_000) parser.add_argument( "--sampling", choices=("first", "hash"), default="hash", help="hash scans the complete dump and keeps a deterministic min-hash sample", ) parser.add_argument( "--source-url", default="https://dumps.wikimedia.org/ruwikisource/latest/ruwikisource-latest-pages-articles-multistream.xml.bz2", ) return parser.parse_args() if __name__ == "__main__": sys.exit(extract(parse_args()) or 0)