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

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