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

View 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>), декодировать сущности (&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]]:
"""
Разбить строку авторов КиберЛенинки на список.