refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s

Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей
инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой
видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на
20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс
потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый
механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery.

Теперь это обычные типы источника — wikipedia_ru и pmc_bulk:

- заводятся и запускаются из админки, как OpenAlex или КиберЛенинка;
- показывают ту же шкалу, журнал и кнопку остановки;
- падение воркера больше не теряет прогресс: позиция продолжения хранится в
  parse_sources.resume_token (номер статьи в дампе / токен страницы бакета),
  повторный запуск берёт следующую порцию;
- укладываются в бюджет времени таска — заливка идёт порциями, а не одним
  многосуточным процессом.

Чего не хватало конвейеру для миллионов и что добавлено:
- парсеры отдают генератор, а не список: 2 млн статей в память не влезают;
- app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против
  6.7 тыс. построчно) с переподключением к базе при обрыве;
- эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и
  стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py).

scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер.

Проверено на проде: оба парсера отдают документы, прогон через run_parser
завершается штатно, позиция продолжения сдвигается (300 → 600), повторный
запуск продолжает с неё.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 20:32:53 +05:00
parent 1b1ca3b7e3
commit 26de1b9de1
15 changed files with 732 additions and 584 deletions

View File

@@ -1,247 +0,0 @@
#!/usr/bin/env python3
"""Массовая заливка полнотекстовых статей из открытого бакета PMC (миллионы).
Почему отдельный путь, а не парсеры и Celery: постраничный API даёт ~1-2 статьи
в секунду и упирается в rate limit — миллионы так не залить. У AWS Open Data
бакета `pmc-oa-opendata` (открыт, ключ не нужен) для каждой статьи лежит уже
извлечённый текст `PMC*.txt` (33-130 КБ) и метаданные `PMC*.json`. Замер:
12 статей/с в 10 потоков, то есть миллион — примерно за сутки.
Второе узкое место — запись отпечатков. Построчный INSERT даёт ~6.7 тыс. строк/с
(миллион статей = 2.5 млрд строк = четверо суток только на вставку), поэтому
здесь используется COPY: он на порядки быстрее и не гоняет данные через ORM.
Масштаб для планирования: 1 млн статей ≈ 2.5 млрд отпечатков ≈ 400 ГБ в БД
с индексами. Плотность отпечатков ограничена --fp-per-doc (по умолчанию 2000
вместо 20 000 из настроек воркера) — иначе объём растёт быстрее пользы.
Запуск (в контейнере worker-indexer, где есть psycopg2 и winnowing):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C --limit 200 < scripts/ops/bulk_ingest_pmc.py # пробная порция
$C --limit 100000 --workers 20 < scripts/ops/bulk_ingest_pmc.py
Идемпотентно: документы вставляются с ON CONFLICT DO NOTHING по ext_id, уже
залитые пропускаются. Прогресс листинга сохраняется в --state-file, поэтому
прерванная заливка продолжается с той же страницы бакета.
Проверено на проде: 1831 отпечаток на статью — реальная глубина, а не аннотация.
Темп ~2 статьи/с в один поток (0.3 ст/с из первого замера были получены, когда
база одновременно считала эмбеддинги). Скачивание не узкое место (12 статей/с в
12 потоков), упирается в последовательную обработку документа — для миллионов
её нужно распараллелить по ядрам воркера.
"""
import argparse
import contextlib
import io
import json
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
STATE_FILE = "/tmp/bulk_pmc_state.json"
KEY_RE = re.compile(r"<Key>(PMC\d+\.\d+)/\1\.txt</Key>")
TOKEN_RE = re.compile(r"<NextContinuationToken>([^<]+)</NextContinuationToken>")
def list_articles(client, token: str | None, want: int) -> tuple[list[str], str | None]:
"""Собрать id статей из листинга бакета, продолжая с сохранённой страницы."""
ids: list[str] = []
while len(ids) < want:
url = f"{BUCKET}/?list-type=2&max-keys=1000"
if token:
# В токене бывают + и / — без экранирования S3 отвечает 400
url += f"&continuation-token={quote(token, safe='')}"
resp = client.get(url)
resp.raise_for_status()
ids.extend(KEY_RE.findall(resp.text))
m = TOKEN_RE.search(resp.text)
token = m.group(1) if m else None
if token is None:
break
return ids[:want], token
def fetch_one(client, art_id: str) -> dict | None:
"""Текст статьи и её метаданные. None — если статья недоступна или пустая."""
try:
txt = client.get(f"{BUCKET}/{art_id}/{art_id}.txt")
if txt.status_code != 200 or len(txt.text) < 1500:
return None
meta_resp = client.get(f"{BUCKET}/{art_id}/{art_id}.json")
meta = meta_resp.json() if meta_resp.status_code == 200 else {}
except Exception:
return None
citation = meta.get("citation") or ""
year = None
m = re.search(r"\b(19|20)\d{2}\b", citation)
if m:
year = int(m.group(0))
return {
"ext_id": f"pmc:{meta.get('pmcid') or art_id.split('.')[0]}",
"title": (meta.get("title") or art_id)[:1000],
"doi": meta.get("doi"),
"year": year,
"journal": citation[:300] or None,
"text": txt.text,
}
def write_batch(conn, connect, docs: list[dict], fp_limit: int) -> tuple:
"""Записать пачку статей, пережив обрыв соединения с базой.
Прошлые прогоны умирали целиком от `server closed the connection`, теряя и
позицию в бакете. Здесь обрыв стоит переподключения и повтора.
Returns:
(соединение, добавлено, пропущено, отпечатков) — соединение может быть
новым, если пришлось переподключаться
"""
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
for attempt in range(1, 6):
try:
added = skipped = fp_count = 0
with conn.cursor() as cur:
# ON CONFLICT — повторный прогон не плодит дубли
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, doi, year, lang, journal,
abstract, authors, indexed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
[(d["ext_id"], "pmc", d["title"], d["doi"], d["year"], "en",
d["journal"], d["text"][:2000]) for d in docs],
)
cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([d["ext_id"] for d in docs],))
id_by_ext = {e: i for i, e in cur.fetchall()}
# COPY вместо построчных INSERT — единственный способ писать
# миллиарды строк за разумное время
buf = io.StringIO()
for d in docs:
doc_id = id_by_ext.get(d["ext_id"])
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
# Равномерно по тексту, а не первые N: иначе хвост статьи
# остаётся без отпечатков
hashes = sample_evenly(winnow_ordered(d["text"]), fp_limit)
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fp_count += len(hashes)
added += 1
buf.seek(0)
if added:
cur.copy_from(buf, "fingerprints",
columns=("doc_id", "hash_value", "position"))
conn.commit()
return conn, added, skipped, fp_count
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
print(f" база недоступна ({str(e).strip()[:60]}), повтор через {wait}с "
f"[{attempt}/5]", flush=True)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
with contextlib.suppress(Exception):
conn = connect()
raise RuntimeError("не удалось записать пачку после 5 попыток")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--limit", type=int, default=200, help="сколько статей залить за прогон")
ap.add_argument("--workers", type=int, default=10, help="потоков закачки (вежливо: 10-30)")
ap.add_argument("--batch", type=int, default=200, help="статей в одной транзакции")
ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на документ")
ap.add_argument("--state-file", default=STATE_FILE, help="файл с позицией листинга")
args = ap.parse_args()
import httpx
import psycopg2
from app.config import settings
token = None
if os.path.exists(args.state_file):
with open(args.state_file) as fh:
token = json.load(fh).get("token")
print("продолжаю листинг с сохранённой позиции")
client = httpx.Client(timeout=60,
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"})
def connect():
return psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
conn = connect()
added = skipped = failed = fp_total = done = 0
t0 = time.time()
while done < args.limit:
want = min(args.batch, args.limit - done)
# Сеть у бакета иногда отваливается (прошлый прогон умер на таймауте
# SSL-рукопожатия) — это стоит паузы, а не всей заливки
try:
ids, token = list_articles(client, token, want)
except Exception as e:
print(f" листинг не удался ({type(e).__name__}), пауза 30с", flush=True)
time.sleep(30)
continue
if not ids:
print("бакет закончился")
break
done += len(ids)
with ThreadPoolExecutor(max_workers=args.workers) as pool:
docs = [d for d in pool.map(lambda i: fetch_one(client, i), ids) if d]
failed += len(ids) - len(docs)
if not docs:
continue
conn, n_added, n_skipped, n_fp = write_batch(conn, connect, docs, args.fp_per_doc)
added += n_added
skipped += n_skipped
fp_total += n_fp
with open(args.state_file, "w") as fh:
json.dump({"token": token}, fh)
el = time.time() - t0
print(f" {done}/{args.limit} · залито {added} · пропущено {skipped} · "
f"недоступно {failed} · отпечатков {fp_total:,} · {added / max(el, 1):.1f} ст/с",
flush=True)
if token is None:
break
conn.close()
el = time.time() - t0
print(f"\nГотово за {el / 60:.1f} мин: залито {added}, пропущено {skipped}, "
f"недоступно {failed}, отпечатков {fp_total:,}")
if added:
print(f"темп: {added / el:.1f} статей/с → "
f"миллион за ~{1e6 / max(added / el, 0.01) / 3600:.0f} ч")
if __name__ == "__main__":
main()

View File

@@ -1,269 +0,0 @@
#!/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:<pageid>`, повторная заливка пропускает
уже существующие статьи (ON CONFLICT DO NOTHING).
"""
import argparse
import bz2
import contextlib
import io
import json
import os
import re
import time
DUMP_URL = "https://dumps.wikimedia.org/ruwiki/latest/ruwiki-latest-pages-articles.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 ")
# Разметку чистим регулярками: для отпечатков нужен связный текст, а не точное
# восстановление вёрстки — полноценный парсер вики-разметки тут неоправдан
CLEAN_RULES = [
(re.compile(r"\{\{[^{}]*\}\}"), " "), # шаблоны (в несколько проходов)
(re.compile(r"\[\[[^\]|]*\|"), ""), # [[ссылка|текст → текст
(re.compile(r"\[\[|\]\]"), ""), # остатки скобок
(re.compile(r"<ref[^>]*>.*?</ref>", 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 _pages_from_chunks(chunks, min_chars: int):
"""Разобрать поток распакованного XML на статьи основного пространства имён."""
buf = ""
for raw in chunks:
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 iter_pages(min_chars: int, dump_file: str | None):
"""Статьи из локального дампа либо, если файла нет, прямо из сети.
Локальный файл предпочтителен: Wikimedia рвёт долгие потоковые соединения
(проверено — обрыв на 32 МБ из 5.9 ГБ), а скачать файл можно с докачкой
(`curl -C -`) и потом читать сколько угодно.
"""
decomp = bz2.BZ2Decompressor()
if dump_file:
def chunks():
with open(dump_file, "rb") as fh:
while True:
part = fh.read(4 * 1024 * 1024)
if not part:
return
try:
yield decomp.decompress(part)
except EOFError:
return
yield from _pages_from_chunks(chunks(), min_chars)
return
import httpx
# Wikimedia отдаёт 403 без осмысленного User-Agent — по их правилам он должен
# называть приложение и давать контакт
headers = {"User-Agent": "AcademicHelper/1.0 (https://academic.jze9.ru; noreply@jze9.ru)"}
def net_chunks():
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:
yield decomp.decompress(chunk)
except EOFError:
return
yield from _pages_from_chunks(net_chunks(), min_chars)
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=150, help="статей в транзакции")
ap.add_argument("--min-chars", type=int, default=2000, help="минимальная длина текста")
ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на статью")
ap.add_argument("--dump-file", default=None,
help="путь к заранее скачанному дампу (надёжнее, чем читать из сети)")
ap.add_argument("--state-file", default="/tmp/bulk_wiki_state.json",
help="файл с числом уже обработанных статей — для продолжения после сбоя")
args = ap.parse_args()
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
from app.config import settings
def connect():
return psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
conn = connect()
added = skipped = 0
fp_total = 0
seen = 0 # сколько статей прошло через парсер (позиция в дампе)
t0 = time.time()
batch: list[tuple[str, str, str]] = []
# Позиция в дампе: прошлый прогон обрывался на 20 008 статье и начинал всё
# сначала — дедуп по ext_id спасал от дублей, но время тратилось впустую
skip_until = 0
if os.path.exists(args.state_file):
with open(args.state_file) as fh:
skip_until = json.load(fh).get("processed", 0)
if skip_until:
print(f"продолжаю с {skip_until}-й статьи дампа")
def _flush_once(batch):
"""Одна попытка записать пачку; счётчики трогает только при успехе."""
nonlocal added, skipped, fp_total
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
# Равномерно по тексту, а не первые N: обрезка «с начала»
# оставляла бы хвост статьи без отпечатков
hashes = sample_evenly(winnow_ordered(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()
def flush(batch):
"""Записать пачку, пережив обрыв соединения с базой.
Прошлый прогон умер целиком на `server closed the connection` — потеряв
и позицию в дампе. Здесь обрыв стоит переподключения и повтора.
"""
nonlocal conn
if not batch:
return
for attempt in range(1, 6):
try:
_flush_once(batch)
return
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
print(f" база недоступна ({str(e).strip()[:60]}), повтор через {wait}с "
f"[{attempt}/5]", flush=True)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
try:
conn = connect()
except Exception:
continue
raise RuntimeError("не удалось записать пачку после 5 попыток")
for pid, title, text in iter_pages(args.min_chars, args.dump_file):
seen += 1
if seen <= skip_until: # быстро проматываем уже обработанное
continue
batch.append((pid, title, text))
if len(batch) >= args.batch:
flush(batch)
batch = []
with open(args.state_file, "w") as fh:
json.dump({"processed": seen}, fh)
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()

144
scripts/parsers/pmc_bulk.py Normal file
View File

@@ -0,0 +1,144 @@
"""Парсер PubMed Central из открытого бакета AWS — массовый путь.
Отличие от `pmc.py`: тот ходит в API E-utilities и годится для точечной
докачки по теме (1-2 статьи в секунду, есть rate limit). Этот берёт статьи из
бакета `pmc-oa-opendata`, где у каждой лежит **уже извлечённый текст**, и
качает их пачками в несколько потоков — путь к сотням тысяч и миллионам.
Ключ для доступа не нужен, бакет открыт. Замер: 12 статей/с в 10 потоков.
Как и парсер Википедии, отдаёт статьи генератором: складывать миллионы
документов в список нельзя (см. bulk_writer.py).
"""
import logging
import re
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from urllib.parse import quote
import httpx
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
KEY_RE = re.compile(r"<Key>(PMC\d+\.\d+)/\1\.txt</Key>")
TOKEN_RE = re.compile(r"<NextContinuationToken>([^<]+)</NextContinuationToken>")
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
MIN_CHARS = 1500 # меньше — обрывок, а не статья
class PMCBulkParser(BaseParser):
"""PMC Open Access из бакета AWS. Отдаёт статьи потоком."""
source_name = "pmc" # тот же источник в корпусе, что и у API-парсера
bulk = True
def __init__(self, workers: int = 12) -> None:
super().__init__()
self.workers = workers
self.client = httpx.Client(
timeout=60,
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
)
# Позиция листинга: бакет отдаётся страницами, и продолжать прогон
# нужно с той же страницы, а не с начала
self.last_token: str | None = None
def fetch( # type: ignore[override]
self,
limit: int = 1000,
resume_token: str | None = None,
progress_cb: ProgressCallback | None = None,
**_ignored: Any,
) -> Iterator[dict[str, Any]]:
"""Статьи из бакета: листинг страницами, скачивание в потоках.
Args:
limit: сколько статей отдать
resume_token: продолжить листинг с сохранённой страницы бакета
progress_cb: см. base.ProgressCallback
"""
token = resume_token
given = 0
while given < limit:
try:
ids, token = self._list_page(token, want=min(200, limit - given))
except Exception as e:
logger.error("PMC bulk: листинг не удался: %s", e)
return
if not ids:
logger.info("PMC bulk: бакет закончился")
return
with ThreadPoolExecutor(max_workers=self.workers) as pool:
for raw in pool.map(self._fetch_article, ids):
if raw is None:
continue
given += 1
raw["resume_token"] = token
yield raw
if given >= limit:
break
self.last_token = token
if progress_cb and not progress_cb(given):
logger.info("PMC bulk: выборка остановлена по запросу (%d)", given)
return
if token is None:
return
def _list_page(self, token: str | None, want: int) -> tuple[list[str], str | None]:
"""Одна страница листинга бакета: id статей и токен следующей страницы."""
url = f"{BUCKET}/?list-type=2&max-keys=1000"
if token:
# В токене бывают + и / — без экранирования S3 отвечает 400
url += f"&continuation-token={quote(token, safe='')}"
resp = self.client.get(url)
resp.raise_for_status()
ids = KEY_RE.findall(resp.text)[:want]
m = TOKEN_RE.search(resp.text)
return ids, (m.group(1) if m else None)
def _fetch_article(self, art_id: str) -> dict[str, Any] | None:
"""Текст статьи и метаданные; None — статья недоступна или пустая."""
try:
txt = self.client.get(f"{BUCKET}/{art_id}/{art_id}.txt")
if txt.status_code != 200 or len(txt.text) < MIN_CHARS:
return None
meta_resp = self.client.get(f"{BUCKET}/{art_id}/{art_id}.json")
meta = meta_resp.json() if meta_resp.status_code == 200 else {}
except Exception:
return None
return {"art_id": art_id, "meta": meta, "text": txt.text}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Привести статью к унифицированному формату документов корпуса."""
meta = raw.get("meta") or {}
art_id = raw.get("art_id", "")
pmcid = meta.get("pmcid") or art_id.split(".")[0]
if not pmcid:
return {}
citation = meta.get("citation") or ""
year_match = YEAR_RE.search(citation)
text = raw.get("text", "")
return {
"source": self.source_name,
"ext_id": f"pmc:{pmcid}",
"title": (meta.get("title") or pmcid)[:1000],
"authors": [],
"doi": meta.get("doi"),
"year": int(year_match.group(0)) if year_match else None,
"lang": "en",
"journal": citation[:300] or None,
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/",
"abstract": text[:2000],
"text": text,
"resume_token": raw.get("resume_token"),
}

View File

@@ -0,0 +1,179 @@
"""Парсер русской Википедии из дампа 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"<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,
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:
"""Разобрать <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_position": raw.get("dump_position"),
}