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:
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"}) == {}
|
||||
Reference in New Issue
Block a user