From 06363c740136c13dc334315ec83babf6e58ef38a Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 31 Aug 2026 20:53:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D0=B7=D0=B0=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=92=D0=B8=D0=BA=D0=B8=D0=BF=D0=B5=D0=B4=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=BE=D0=B1=D1=8A=D1=91=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У корпуса катастрофически мало пригодного русского текста: 99 883 документа КиберЛенинки лежат со средней глубиной 30 отпечатков, то есть заголовок с аннотацией. Проверил все русскоязычные источники, о которых имело смысл думать: - Википедия ru — работает: дамп 5.6 ГБ читается потоком (на app-хосте всего 22 ГБ свободно, поэтому без сохранения на диск), ~919 отпечатков на статью. Wikimedia отдаёт 403 без осмысленного User-Agent — учтено; - КиберЛенинка — блокирует выкачку после ~130 запросов (замер 28.08); - OpenAlex language:ru + OA — заявлено 395 410 работ, но по прямым pdf_url скачалось 2 из 10, остальное 403 издателей; метка языка ненадёжна — в выдаче англоязычные журналы. Массово не годится; - eLIBRARY.RU — только по договору, Math-Net.Ru — 403 на архиве. Википедия формально не научный источник, но студенты копируют из неё чаще, чем из статей, а по объёму связного русского текста альтернатив среди доступного нет. Co-Authored-By: Claude Opus 5 --- docs/INGESTION.md | 14 ++ scripts/ops/bulk_ingest_wikipedia_ru.py | 187 ++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100755 scripts/ops/bulk_ingest_wikipedia_ru.py diff --git a/docs/INGESTION.md b/docs/INGESTION.md index f0955bb..0f58d86 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -58,6 +58,20 @@ документов без вектора. Считает новый сервер эмбеддингов (192.168.1.40, 3.6 док/с против 2.2 у прежнего), полный проход занимает около 6.5 часов. +### Источники русского текста — что проверено (31.08.2026) + +| Источник | Объём | Годен для массовой заливки | +|----------|-------|----------------------------| +| **Википедия ru** | дамп 5.6 ГБ, ~2 млн статей | **да** — `bulk_ingest_wikipedia_ru.py`, потоком без блокировок, ~919 отпечатков на статью. Требует осмысленный User-Agent, иначе 403 | +| КиберЛенинка | ~3 млн статей | нет — блокирует выкачку PDF после ~130 запросов | +| OpenAlex `language:ru` + OA | заявлено 395 410 | практически нет — по прямым `pdf_url` скачалось 2 из 10 (остальное 403 издателей), а метка языка ненадёжна: в выдаче попадаются англоязычные журналы | +| eLIBRARY.RU (РИНЦ) | ~40 млн | только по договору, публичной выгрузки нет | +| Math-Net.Ru | российские матжурналы | архив отдаёт 403 | +| НЭБ, BASE, вузовские DSpace | — | открыты, механизм выгрузки не проверялся | + +Википедия формально не научный источник, но студенты копируют из неё чаще всего, +а по объёму связного русского текста ей нет альтернативы среди доступного. + ### Массовая заливка: bulk вместо API Постраничные API дают 1-2 статьи в секунду и упираются в rate limit — миллионы diff --git a/scripts/ops/bulk_ingest_wikipedia_ru.py b/scripts/ops/bulk_ingest_wikipedia_ru.py new file mode 100755 index 0000000..d083546 --- /dev/null +++ b/scripts/ops/bulk_ingest_wikipedia_ru.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Массовая заливка русской Википедии в корпус сравнения. + +Зачем именно она: студенты копируют из Википедии чаще, чем из научных статей, а +в корпусе её нет вовсе. При этом она единственный русскоязычный источник такого +объёма, который отдаётся без блокировок — КиберЛенинка режет выкачку после ~130 +запросов, eLIBRARY требует договора. Дамп `ruwiki-latest-pages-articles.xml.bz2` +(5.6 ГБ, ~2 млн статей) скачивается свободно. + +Дамп не сохраняется на диск: читается потоком и распаковывается на лету — +на app-хосте всего 22 ГБ свободно. + +Что отбрасывается: перенаправления, служебные пространства имён (оставляем +только статьи) и короткие тексты — для детекции нужен объём, а не заготовки. + +Запуск (в контейнере worker-indexer): + cd /home/user/anti-plagiarism + C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -" + $C --limit 100 < scripts/ops/bulk_ingest_wikipedia_ru.py # проба + $C --limit 500000 < scripts/ops/bulk_ingest_wikipedia_ru.py # объём + +Идемпотентно: ext_id вида `wikipedia_ru:`, повторная заливка пропускает +уже существующие статьи (ON CONFLICT DO NOTHING). +""" + +import argparse +import bz2 +import io +import re +import time + +DUMP_URL = "https://dumps.wikimedia.org/ruwiki/latest/ruwiki-latest-pages-articles.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"<[^>]+>"), " "), # html-теги + (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 = CLEAN_RULES[0][0].sub(CLEAN_RULES[0][1], text) + for rx, repl in CLEAN_RULES[1:]: + text = rx.sub(repl, text) + return text.strip() + + +def iter_pages(min_chars: int): + """Потоково читать дамп и отдавать статьи основного пространства имён.""" + import httpx + + decomp = bz2.BZ2Decompressor() + buf = "" + # Wikimedia отдаёт 403 без осмысленного User-Agent — по их правилам он должен + # называть приложение и давать контакт + headers = {"User-Agent": "AcademicHelper/1.0 (https://academic.jze9.ru; noreply@jze9.ru)"} + with httpx.stream("GET", DUMP_URL, timeout=120, follow_redirects=True, headers=headers) as resp: + resp.raise_for_status() + for chunk in resp.iter_bytes(4 * 1024 * 1024): + try: + raw = decomp.decompress(chunk) + except EOFError: + break + if not raw: + continue + buf += raw.decode("utf-8", errors="replace") + + while True: + m = PAGE_RE.search(buf) + if not m: + break + page, buf = m.group(1), buf[m.end():] + + if REDIRECT_RE.search(page): + continue + ns = NS_RE.search(page) + if not ns or ns.group(1) != "0": # только статьи + continue + tm, im, xm = TITLE_RE.search(page), ID_RE.search(page), TEXT_RE.search(page) + if not (tm and im and xm): + continue + text = clean_wikitext(xm.group(1)) + if len(text) < min_chars: + continue + yield im.group(1), tm.group(1), text + + if len(buf) > 20 * 1024 * 1024: # страховка от разбухания + buf = buf[-1024 * 1024:] + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--limit", type=int, default=100, help="сколько статей залить") + ap.add_argument("--batch", type=int, default=500, help="статей в транзакции") + ap.add_argument("--min-chars", type=int, default=2000, help="минимальная длина текста") + ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на статью") + args = ap.parse_args() + + import psycopg2 + from app.algorithms.winnowing import winnow + from app.config import settings + + conn = psycopg2.connect(host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT, + dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER, + password=settings.POSTGRES_PASSWORD) + added = skipped = 0 + fp_total = 0 + t0 = time.time() + batch: list[tuple[str, str, str]] = [] + + def flush(batch): + nonlocal added, skipped, fp_total + if not batch: + return + with conn.cursor() as cur: + cur.executemany( + """INSERT INTO documents (ext_id, source, title, lang, abstract, url, + authors, indexed_at) + VALUES (%s, 'wikipedia_ru', %s, 'ru', %s, %s, '[]'::json, now()) + ON CONFLICT (ext_id) DO NOTHING""", + [(f"wikipedia_ru:{pid}", title[:1000], text[:2000], + f"https://ru.wikipedia.org/?curid={pid}") for pid, title, text in batch], + ) + cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)", + ([f"wikipedia_ru:{pid}" for pid, _, _ in batch],)) + id_by_ext = {e: i for i, e in cur.fetchall()} + + buf = io.StringIO() + fresh = 0 + for pid, _, text in batch: + doc_id = id_by_ext.get(f"wikipedia_ru:{pid}") + if doc_id is None: + continue + cur.execute("SELECT 1 FROM fingerprints WHERE doc_id=%s LIMIT 1", (doc_id,)) + if cur.fetchone(): + skipped += 1 + continue + hashes = list(winnow(text))[: args.fp_per_doc] + for pos, h in enumerate(hashes): + buf.write(f"{doc_id}\t{h}\t{pos}\n") + fp_total += len(hashes) + fresh += 1 + buf.seek(0) + if fresh: + cur.copy_from(buf, "fingerprints", columns=("doc_id", "hash_value", "position")) + added += fresh + conn.commit() + + for pid, title, text in iter_pages(args.min_chars): + batch.append((pid, title, text)) + if len(batch) >= args.batch: + flush(batch) + batch = [] + el = time.time() - t0 + print(f" залито {added} · пропущено {skipped} · отпечатков {fp_total:,} · " + f"{added / max(el, 1):.1f} ст/с", flush=True) + if added >= args.limit: + break + flush(batch) + conn.close() + + el = time.time() - t0 + print(f"\nГотово за {el / 60:.1f} мин: залито {added}, пропущено {skipped}, " + f"отпечатков {fp_total:,}") + + +if __name__ == "__main__": + main()