fix(parsers): починить CyberLeninka + подготовить русскую заливку корпуса

Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских
студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP
405) и ни разу не наполнял базу.

- cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка
  (API отдаёт список, не строку); чистка <b>-подсветки и HTML-сущностей (&quot;).
  Проверено вживую: 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 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-12 21:02:16 +05:00
parent e6c44f30dd
commit 9d005486df
8 changed files with 292 additions and 21 deletions

52
docs/INGESTION.md Normal file
View File

@@ -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 из
списка, чистка `<b>`/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).

View File

@@ -0,0 +1,6 @@
"""Добавляет каталог парсеров в sys.path (импорты вида `from base import ...`)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -3,11 +3,13 @@
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей. КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
Сайт: https://cyberleninka.ru Сайт: https://cyberleninka.ru
Используем парсинг HTML страниц со строгим rate limiting (1 req/sec), Поиск — через JSON-API `POST /api/search` (`{"mode":"articles", "q", "size", "from"}`)
а не недокументированное API. со строгим rate limiting (1 req/sec). Ответ подсвечивает совпадения тегами <b>…</b>
в name/annotation — чистим при трансформации.
""" """
import contextlib import contextlib
import html
import logging import logging
import re import re
import time import time
@@ -63,13 +65,16 @@ class CyberLeninkaParser(BaseParser):
while len(results) < limit: while len(results) < limit:
try: try:
params: dict[str, Any] = { # /api/search КиберЛенинки — это POST с JSON-телом (GET даёт 405).
# mode=articles обязателен, иначе поиск не по статьям.
payload: dict[str, Any] = {
"mode": "articles",
"q": query, "q": query,
"size": min(10, limit - len(results)), "size": min(10, limit - len(results)),
"from": page * 10, "from": page * 10,
} }
response = self.client.get(SEARCH_URL, params=params) response = self.client.post(SEARCH_URL, json=payload)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -114,9 +119,13 @@ class CyberLeninkaParser(BaseParser):
if not ext_id: if not ext_id:
return {} return {}
# Авторы (строка вида "Иванов И.И., Петров П.П.") # Авторы: API отдаёт список строк "Фамилия И. О." (иногда — одну строку)
authors_str = raw.get("authors", "") or "" raw_authors = raw.get("authors") or []
authors = _parse_cyberleninka_authors(authors_str) authors = (
_parse_cyberleninka_authors(raw_authors)
if isinstance(raw_authors, str)
else _authors_from_list(raw_authors)
)
# Год # Год
year_raw = raw.get("year") year_raw = raw.get("year")
@@ -133,7 +142,7 @@ class CyberLeninkaParser(BaseParser):
"source": self.source_name, "source": self.source_name,
"ext_id": ext_id, "ext_id": ext_id,
"doi": raw.get("doi") or None, "doi": raw.get("doi") or None,
"title": (raw.get("name") or "").strip() or None, "title": _clean(raw.get("name")) or None,
"authors": authors, "authors": authors,
"year": year, "year": year,
"lang": "ru", # КиберЛенинка — только русскоязычные "lang": "ru", # КиберЛенинка — только русскоязычные
@@ -141,7 +150,7 @@ class CyberLeninkaParser(BaseParser):
"volume": raw.get("volume") or None, "volume": raw.get("volume") or None,
"issue": raw.get("number") or None, "issue": raw.get("number") or None,
"pages": raw.get("pages") 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, "url": url,
"full_text": None, "full_text": None,
} }
@@ -189,6 +198,26 @@ class CyberLeninkaParser(BaseParser):
return {} return {}
def _clean(text: str | None) -> str:
"""Убрать HTML-теги подсветки (<b>…</b>), декодировать сущности (&quot;), обрезать."""
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]]: def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
""" """
Разбить строку авторов КиберЛенинки на список. Разбить строку авторов КиберЛенинки на список.

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,4 @@
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
pytest==8.2.0
httpx==0.27.0
beautifulsoup4==4.12.3

View File

@@ -0,0 +1,53 @@
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
annotation — с HTML-подсветкой (<b>) и сущностями (&quot;).
"""
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
# Форма ответа POST /api/search КиберЛенинки
SAMPLE = {
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> &quot;X&quot;",
"annotation": "В статье <b>рассматривается</b> вопрос",
"authors": ["Вишникина А. Д.", "Клопова А. А."],
"journal": "Экономика и социум",
"year": 2024,
"link": "/article/n/soderzhanie",
}
def test_clean_strips_tags_and_entities():
assert _clean('<b>Тест</b> &quot;X&quot;') == 'Тест "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 "<b>" not in t["title"] and "&quot;" 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 "<b>" 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"}) == {}

View File

@@ -17,22 +17,24 @@ MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="python:3.11-slim" IMAGE="python:3.11-slim"
# service:apt-пакеты (нужны faiss-cpu → libgomp1) # относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
SERVICES=( SERVICES=(
"worker-indexer:" "services/worker-indexer:"
"worker-gost:" "services/worker-gost:"
"worker-gpu:libgomp1" "services/worker-gpu:libgomp1"
"scripts/parsers:"
) )
run_service() { run_service() {
local svc="$1" apt_pkgs="$2" local dir_rel="$1" apt_pkgs="$2"
local dir="$ROOT/services/$svc" local dir="$ROOT/$dir_rel"
local name="${dir_rel##*/}"
if [[ ! -f "$dir/requirements-test.txt" ]]; then if [[ ! -f "$dir/requirements-test.txt" ]]; then
echo "⚠ $svc: нет requirements-test.txt — пропуск" echo "⚠ $name: нет requirements-test.txt — пропуск"
return 0 return 0
fi fi
echo "──────────────────────────────────────────────" echo "──────────────────────────────────────────────"
echo "▶ Тесты: $svc" echo "▶ Тесты: $name"
echo "──────────────────────────────────────────────" echo "──────────────────────────────────────────────"
docker run --rm \ docker run --rm \
-v "$dir":/app -w /app \ -v "$dir":/app -w /app \
@@ -49,12 +51,13 @@ run_service() {
FILTER="${1:-}" FILTER="${1:-}"
failed=0 failed=0
for entry in "${SERVICES[@]}"; do for entry in "${SERVICES[@]}"; do
svc="${entry%%:*}" dir_rel="${entry%%:*}"
apt="${entry#*:}" apt="${entry#*:}"
[[ -n "$FILTER" && "$FILTER" != "$svc" ]] && continue name="${dir_rel##*/}"
if ! run_service "$svc" "$apt"; then [[ -n "$FILTER" && "$FILTER" != "$name" ]] && continue
if ! run_service "$dir_rel" "$apt"; then
failed=1 failed=1
echo "✗ $svc: тесты упали" echo "✗ $name: тесты упали"
fi fi
done done

121
scripts/seed_ru_sources.py Normal file
View File

@@ -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()