From 9d005486df9ff5cfa204fe7bd151f32eaaf8f81d Mon Sep 17 00:00:00 2001 From: jze9 Date: Wed, 12 Aug 2026 21:02:16 +0500 Subject: [PATCH] =?UTF-8?q?fix(parsers):=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20CyberLeninka=20+=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D1=83?= =?UTF-8?q?=D1=81=D1=81=D0=BA=D1=83=D1=8E=20=D0=B7=D0=B0=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=BA=D1=83=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP 405) и ни разу не наполнял базу. - cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка (API отдаёт список, не строку); чистка -подсветки и HTML-сущностей ("). Проверено вживую: 5/5 студенческих тем возвращают реальные русские статьи. - Юнит-тесты парсера (scripts/parsers/tests/, 5 шт.) + обвязка; run_tests.sh обобщён на пути → парсеры теперь в тест-гейте CI. Всего тестов: 87. - scripts/seed_ru_sources.py: сидер parse_sources по 30 студенческим дисциплинам (dry-run по умолчанию, --apply для записи). НЕ запускает заливку — готовит задания. - docs/INGESTION.md: runbook (текущее состояние, шаги запуска, проверка, масштаб). Прод-путь index.run_parser уже поддерживает cyberleninka и openalex(lang=ru). Заливку не запускал — это отдельный go (ресурсоёмко: GPU-эмбеддинги, рост БД). Co-Authored-By: Claude Opus 4.8 --- docs/INGESTION.md | 52 +++++++++ scripts/parsers/conftest.py | 6 + scripts/parsers/cyberleninka.py | 47 ++++++-- scripts/parsers/pytest.ini | 3 + scripts/parsers/requirements-test.txt | 4 + scripts/parsers/tests/test_cyberleninka.py | 53 +++++++++ scripts/run_tests.sh | 27 +++-- scripts/seed_ru_sources.py | 121 +++++++++++++++++++++ 8 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 docs/INGESTION.md create mode 100644 scripts/parsers/conftest.py create mode 100644 scripts/parsers/pytest.ini create mode 100644 scripts/parsers/requirements-test.txt create mode 100644 scripts/parsers/tests/test_cyberleninka.py create mode 100644 scripts/seed_ru_sources.py diff --git a/docs/INGESTION.md b/docs/INGESTION.md new file mode 100644 index 0000000..2b2f607 --- /dev/null +++ b/docs/INGESTION.md @@ -0,0 +1,52 @@ +# Наполнение корпуса — runbook + +## Текущее состояние (на 2026-08-12) + +- ~**42K документов**, из них **99.4% английские, 0 русских** (см. `documents`). +- Заливка **встала 2026-08-07**. Корпус — seed из ~30 английских тем OpenAlex/arXiv. +- Для сервиса под русских студентов это главный дефект: русские работы проверять + не с чем. + +## Что подготовлено + +- **Парсер CyberLeninka починен** (`scripts/parsers/cyberleninka.py`): раньше слал GET + на `/api/search` → HTTP 405; теперь POST с JSON-телом (`mode=articles`), authors из + списка, чистка ``/HTML-сущностей. Проверено вживую (5/5 тем) + юнит-тесты + (`scripts/parsers/tests/`), в гейте CI. +- **Прод-путь готов**: `index.run_parser(source_id)` уже умеет `cyberleninka` и + `openalex` c `lang=ru`. +- **Сидер источников**: `scripts/seed_ru_sources.py` — 30 студенческих дисциплин. + +## Запуск русской заливки + +```bash +# 1. (на app-хосте / в контейнере worker-indexer, где есть psycopg2 и прод-.env) +# Посмотреть план: +python scripts/seed_ru_sources.py +# Создать источники в parse_sources (лимит на дисциплину): +python scripts/seed_ru_sources.py --apply --limit 500 + +# 2. Проверить пару источников на темпе/качестве, затем запустить заливку: +# • Админ-панель → «Источники» → «Запустить», ЛИБО +# • Celery: index.run_parser.delay(source_id) по каждому id +``` + +Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`, +fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем × +500 ≈ 15K русских документов на первый заход. + +## Проверка результата + +```sql +SELECT lang, count(*) FROM documents GROUP BY lang ORDER BY 2 DESC; -- должен появиться ru +SELECT source, count(*) FROM documents WHERE source='cyberleninka'; -- > 0 +``` + +## Масштаб (следующий уровень) + +- Больше тем + выше `--limit`; добавить OpenAlex `lang=ru` (качество ниже — англ. + заголовки с меткой ru). +- Для миллионов — **bulk** (снапшот OpenAlex на S3), а не постраничный API. +- На масштабе обязателен `VECTOR_BACKEND=qdrant` (FAISS flat не тянет), а таблица + `fingerprints` (уже ~29M строк на 42K доков) потребует партиционирования. См. + [ARCHITECTURE.md](ARCHITECTURE.md) и [DR-HA.md](DR-HA.md). diff --git a/scripts/parsers/conftest.py b/scripts/parsers/conftest.py new file mode 100644 index 0000000..ec7ed40 --- /dev/null +++ b/scripts/parsers/conftest.py @@ -0,0 +1,6 @@ +"""Добавляет каталог парсеров в sys.path (импорты вида `from base import ...`).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/scripts/parsers/cyberleninka.py b/scripts/parsers/cyberleninka.py index f1b2377..afb36c8 100644 --- a/scripts/parsers/cyberleninka.py +++ b/scripts/parsers/cyberleninka.py @@ -3,11 +3,13 @@ КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей. Сайт: https://cyberleninka.ru -Используем парсинг HTML страниц со строгим rate limiting (1 req/sec), -а не недокументированное API. +Поиск — через JSON-API `POST /api/search` (`{"mode":"articles", "q", "size", "from"}`) +со строгим rate limiting (1 req/sec). Ответ подсвечивает совпадения тегами … +в name/annotation — чистим при трансформации. """ import contextlib +import html import logging import re import time @@ -63,13 +65,16 @@ class CyberLeninkaParser(BaseParser): while len(results) < limit: try: - params: dict[str, Any] = { + # /api/search КиберЛенинки — это POST с JSON-телом (GET даёт 405). + # mode=articles обязателен, иначе поиск не по статьям. + payload: dict[str, Any] = { + "mode": "articles", "q": query, "size": min(10, limit - len(results)), "from": page * 10, } - response = self.client.get(SEARCH_URL, params=params) + response = self.client.post(SEARCH_URL, json=payload) response.raise_for_status() data = response.json() @@ -114,9 +119,13 @@ class CyberLeninkaParser(BaseParser): if not ext_id: return {} - # Авторы (строка вида "Иванов И.И., Петров П.П.") - authors_str = raw.get("authors", "") or "" - authors = _parse_cyberleninka_authors(authors_str) + # Авторы: API отдаёт список строк "Фамилия И. О." (иногда — одну строку) + raw_authors = raw.get("authors") or [] + authors = ( + _parse_cyberleninka_authors(raw_authors) + if isinstance(raw_authors, str) + else _authors_from_list(raw_authors) + ) # Год year_raw = raw.get("year") @@ -133,7 +142,7 @@ class CyberLeninkaParser(BaseParser): "source": self.source_name, "ext_id": ext_id, "doi": raw.get("doi") or None, - "title": (raw.get("name") or "").strip() or None, + "title": _clean(raw.get("name")) or None, "authors": authors, "year": year, "lang": "ru", # КиберЛенинка — только русскоязычные @@ -141,7 +150,7 @@ class CyberLeninkaParser(BaseParser): "volume": raw.get("volume") or None, "issue": raw.get("number") or None, "pages": raw.get("pages") or None, - "abstract": (raw.get("annotation") or "").strip() or None, + "abstract": _clean(raw.get("annotation")) or None, "url": url, "full_text": None, } @@ -189,6 +198,26 @@ class CyberLeninkaParser(BaseParser): return {} +def _clean(text: str | None) -> str: + """Убрать HTML-теги подсветки (…), декодировать сущности ("), обрезать.""" + if not text: + return "" + return html.unescape(re.sub(r"<[^>]+>", "", str(text))).strip() + + +def _authors_from_list(items: list) -> list[dict[str, str]]: + """Авторы из списка строк 'Фамилия И. О.' → [{last_name, initials}, ...].""" + authors: list[dict[str, str]] = [] + for item in items: + name = _clean(str(item)) + words = name.split() + if not words: + continue + initials = " ".join(words[1:]) if len(words) >= 2 else "" + authors.append({"last_name": words[0], "initials": initials}) + return authors + + def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]: """ Разбить строку авторов КиберЛенинки на список. diff --git a/scripts/parsers/pytest.ini b/scripts/parsers/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/scripts/parsers/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/scripts/parsers/requirements-test.txt b/scripts/parsers/requirements-test.txt new file mode 100644 index 0000000..16c586c --- /dev/null +++ b/scripts/parsers/requirements-test.txt @@ -0,0 +1,4 @@ +# Зависимости для юнит-тестов парсеров (чистый transform, без сети). +pytest==8.2.0 +httpx==0.27.0 +beautifulsoup4==4.12.3 diff --git a/scripts/parsers/tests/test_cyberleninka.py b/scripts/parsers/tests/test_cyberleninka.py new file mode 100644 index 0000000..ade586a --- /dev/null +++ b/scripts/parsers/tests/test_cyberleninka.py @@ -0,0 +1,53 @@ +"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети). + +Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/ +annotation — с HTML-подсветкой () и сущностями ("). +""" + +from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean + +# Форма ответа POST /api/search КиберЛенинки +SAMPLE = { + "name": "СОДЕРЖАНИЕ ЭКОНОМИЧЕСКОГО АНАЛИЗА "X"", + "annotation": "В статье рассматривается вопрос", + "authors": ["Вишникина А. Д.", "Клопова А. А."], + "journal": "Экономика и социум", + "year": 2024, + "link": "/article/n/soderzhanie", +} + + +def test_clean_strips_tags_and_entities(): + assert _clean('Тест "X"') == 'Тест "X"' + assert _clean(None) == "" + assert _clean(" чисто ") == "чисто" + + +def test_authors_from_list(): + a = _authors_from_list(["Вишникина А. Д.", "Иванов И."]) + assert a[0] == {"last_name": "Вишникина", "initials": "А. Д."} + assert a[1] == {"last_name": "Иванов", "initials": "И."} + assert _authors_from_list([]) == [] + + +def test_transform_cleans_and_maps_fields(): + t = CyberLeninkaParser().transform(SAMPLE) + assert "" not in t["title"] and """ not in t["title"] + assert t["title"].startswith("СОДЕРЖАНИЕ ЭКОНОМИЧЕСКОГО АНАЛИЗА") + assert t["lang"] == "ru" + assert t["year"] == 2024 + assert t["journal"] == "Экономика и социум" + assert t["url"] == "https://cyberleninka.ru/article/n/soderzhanie" + assert t["source"] == "cyberleninka" + assert t["authors"][0]["last_name"] == "Вишникина" + assert "" not in t["abstract"] + + +def test_transform_handles_string_authors(): + raw = dict(SAMPLE, authors="Иванов И.И., Петров П.П.") + t = CyberLeninkaParser().transform(raw) + assert len(t["authors"]) == 2 + + +def test_transform_empty_without_id_or_link(): + assert CyberLeninkaParser().transform({"name": "x"}) == {} diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index e31eae5..b9e88db 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -17,22 +17,24 @@ MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IMAGE="python:3.11-slim" -# service:apt-пакеты (нужны faiss-cpu → libgomp1) +# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1) SERVICES=( - "worker-indexer:" - "worker-gost:" - "worker-gpu:libgomp1" + "services/worker-indexer:" + "services/worker-gost:" + "services/worker-gpu:libgomp1" + "scripts/parsers:" ) run_service() { - local svc="$1" apt_pkgs="$2" - local dir="$ROOT/services/$svc" + local dir_rel="$1" apt_pkgs="$2" + local dir="$ROOT/$dir_rel" + local name="${dir_rel##*/}" if [[ ! -f "$dir/requirements-test.txt" ]]; then - echo "⚠ $svc: нет requirements-test.txt — пропуск" + echo "⚠ $name: нет requirements-test.txt — пропуск" return 0 fi echo "──────────────────────────────────────────────" - echo "▶ Тесты: $svc" + echo "▶ Тесты: $name" echo "──────────────────────────────────────────────" docker run --rm \ -v "$dir":/app -w /app \ @@ -49,12 +51,13 @@ run_service() { FILTER="${1:-}" failed=0 for entry in "${SERVICES[@]}"; do - svc="${entry%%:*}" + dir_rel="${entry%%:*}" apt="${entry#*:}" - [[ -n "$FILTER" && "$FILTER" != "$svc" ]] && continue - if ! run_service "$svc" "$apt"; then + name="${dir_rel##*/}" + [[ -n "$FILTER" && "$FILTER" != "$name" ]] && continue + if ! run_service "$dir_rel" "$apt"; then failed=1 - echo "✗ $svc: тесты упали" + echo "✗ $name: тесты упали" fi done diff --git a/scripts/seed_ru_sources.py b/scripts/seed_ru_sources.py new file mode 100644 index 0000000..18b2e06 --- /dev/null +++ b/scripts/seed_ru_sources.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Подготовка русскоязычной заливки корпуса — сидинг parse_sources (CyberLeninka). + +Зачем: корпус на ~99% английский (0 русских источников), хотя сервис — для русских +студентов. Этот скрипт создаёт в таблице `parse_sources` набор заданий CyberLeninka +по основным студенческим дисциплинам. Он НЕ запускает заливку — только готовит +задания; их затем триггерят (кнопка «Запустить» в админке или Celery-задача +`index.run_parser(source_id)`). + +Идемпотентно: источник с таким `name` повторно не создаётся. + +Запуск: + # dry-run — показать план, ничего не писать: + python scripts/seed_ru_sources.py + # создать источники в БД (лимит на каждую дисциплину): + python scripts/seed_ru_sources.py --apply --limit 500 + +Креды PG берутся из .env (POSTGRES_HOST/PORT/DB/USER/PASSWORD) или окружения. +ВНИМАНИЕ: локальный .env может указывать на старый PG — для прода используйте +POSTGRES_HOST=192.168.1.38 (см. вывод по наполненности базы). +""" + +import argparse +import os +import sys +from pathlib import Path + +# Студенческие дисциплины → поисковый запрос в CyberLeninka (русскоязычный). +DISCIPLINES = [ + "экономический анализ", "менеджмент организации", "бухгалтерский учёт", + "финансы и кредит", "маркетинг", "гражданское право", "уголовное право", + "конституционное право", "трудовое право", "педагогика", "психология личности", + "социология", "философия науки", "история России", "политология", + "информационные технологии", "программирование", "базы данных", + "математический анализ", "физика", "химия", "биология", "медицина", + "экология", "лингвистика", "литературоведение", "государственное управление", + "международные отношения", "журналистика", "культурология", +] + + +def _load_env() -> None: + """Подтянуть переменные из .env репозитория, если файл есть.""" + env = Path(__file__).resolve().parent.parent / ".env" + if not env.exists(): + return + for line in env.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + os.environ.setdefault(key.strip(), val.strip()) + + +def _pg_dsn() -> dict: + return { + "host": os.environ.get("POSTGRES_HOST", "localhost"), + "port": int(os.environ.get("POSTGRES_PORT", "5432")), + "dbname": os.environ.get("POSTGRES_DB", "antiplagiator"), + "user": os.environ.get("POSTGRES_USER", "antiplagiator"), + "password": os.environ.get("POSTGRES_PASSWORD", ""), + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--apply", action="store_true", help="реально записать в БД (иначе dry-run)") + ap.add_argument("--limit", type=int, default=500, help="лимит документов на дисциплину") + ap.add_argument("--source-type", default="cyberleninka", help="тип парсера (по умолчанию cyberleninka)") + args = ap.parse_args() + + _load_env() + rows = [(f"ru:{d}", args.source_type, d) for d in DISCIPLINES] + + print(f"Дисциплин: {len(rows)} | тип: {args.source_type} | лимит/шт: {args.limit}") + print(f"Потенциальный объём: ~{len(rows) * args.limit} документов") + if not args.apply: + print("\n[dry-run] будут созданы источники (name → query):") + for name, _, q in rows: + print(f" {name:38} → {q!r}") + print("\nЗапустите с --apply, чтобы записать в parse_sources.") + return + + try: + import psycopg2 + except ImportError: + sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте на app-хосте/в контейнере.") + + dsn = _pg_dsn() + print(f"\nПодключение к PG {dsn['host']}:{dsn['port']}/{dsn['dbname']} ...") + conn = psycopg2.connect(**dsn) + conn.autocommit = True + created, skipped, ids = 0, 0, [] + with conn.cursor() as cur: + for name, stype, query in rows: + cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,)) + existing = cur.fetchone() + if existing: + skipped += 1 + continue + cur.execute( + 'INSERT INTO parse_sources ' + '(source_type, name, query, lang, "limit", enabled, last_status, docs_added) ' + "VALUES (%s, %s, %s, 'ru', %s, TRUE, 'idle', 0) RETURNING id", + (stype, name, query, args.limit), + ) + ids.append(cur.fetchone()[0]) + created += 1 + conn.close() + + print(f"Создано: {created}, пропущено (уже были): {skipped}") + if ids: + print(f"ID новых источников: {ids}") + print("\nЗапуск заливки (после проверки источников):") + print(" • Админка → раздел «Источники» → «Запустить» у каждого, ЛИБО") + print(" • Celery: for i in ids: index.run_parser.delay(i)") + print("Рекомендация: сначала запустить 1-2 для проверки темпа/качества, потом остальные.") + + +if __name__ == "__main__": + main()