fix(parsers): починить CyberLeninka + подготовить русскую заливку корпуса
Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP 405) и ни разу не наполнял базу. - cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка (API отдаёт список, не строку); чистка <b>-подсветки и 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 <noreply@anthropic.com>
This commit is contained in:
52
docs/INGESTION.md
Normal file
52
docs/INGESTION.md
Normal 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).
|
||||
6
scripts/parsers/conftest.py
Normal file
6
scripts/parsers/conftest.py
Normal 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__)))
|
||||
@@ -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). Ответ подсвечивает совпадения тегами <b>…</b>
|
||||
в 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-теги подсветки (<b>…</b>), декодировать сущности ("), обрезать."""
|
||||
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]]:
|
||||
"""
|
||||
Разбить строку авторов КиберЛенинки на список.
|
||||
|
||||
3
scripts/parsers/pytest.ini
Normal file
3
scripts/parsers/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
4
scripts/parsers/requirements-test.txt
Normal file
4
scripts/parsers/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
|
||||
pytest==8.2.0
|
||||
httpx==0.27.0
|
||||
beautifulsoup4==4.12.3
|
||||
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
|
||||
|
||||
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
|
||||
annotation — с HTML-подсветкой (<b>) и сущностями (").
|
||||
"""
|
||||
|
||||
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
|
||||
|
||||
# Форма ответа POST /api/search КиберЛенинки
|
||||
SAMPLE = {
|
||||
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> "X"",
|
||||
"annotation": "В статье <b>рассматривается</b> вопрос",
|
||||
"authors": ["Вишникина А. Д.", "Клопова А. А."],
|
||||
"journal": "Экономика и социум",
|
||||
"year": 2024,
|
||||
"link": "/article/n/soderzhanie",
|
||||
}
|
||||
|
||||
|
||||
def test_clean_strips_tags_and_entities():
|
||||
assert _clean('<b>Тест</b> "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 "<b>" 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 "<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"}) == {}
|
||||
@@ -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
|
||||
|
||||
|
||||
121
scripts/seed_ru_sources.py
Normal file
121
scripts/seed_ru_sources.py
Normal 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()
|
||||
Reference in New Issue
Block a user