Services: - api: FastAPI gateway with JWT auth, async endpoints, WebSocket - worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM - worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction - worker-notifier: SMTP email notifications - worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting Infrastructure: - docker-compose.yml (production) + docker-compose.dev.yml (hot reload) - Nginx reverse proxy + WebSocket support - PostgreSQL 16 with Alembic migrations - Elasticsearch 8 with Russian/English analyzers - MinIO, RabbitMQ, Redis, Ollama Frontend: - React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5 - 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register Scripts: - Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
"""Базовый класс для всех парсеров источников.
|
||
|
||
Определяет унифицированный интерфейс и формат документов.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from abc import ABC, abstractmethod
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# Унифицированный формат документа
|
||
UNIFIED_SCHEMA = {
|
||
"source": str, # openalex, cyberleninka, arxiv, wikipedia_ru, wikipedia_en
|
||
"ext_id": str, # Внешний ID (уникален в рамках источника)
|
||
"doi": "str|None",
|
||
"title": str,
|
||
"authors": list, # [{"last_name": str, "first_name": str, "initials": str}]
|
||
"year": "int|None",
|
||
"lang": "str|None", # ru, en
|
||
"journal": "str|None",
|
||
"volume": "str|None",
|
||
"issue": "str|None",
|
||
"pages": "str|None",
|
||
"abstract": "str|None",
|
||
"url": "str|None",
|
||
"full_text": "str|None", # Для fingerprinting (не хранится в PostgreSQL)
|
||
}
|
||
|
||
|
||
class BaseParser(ABC):
|
||
"""Базовый класс парсера источника."""
|
||
|
||
source_name: str = "unknown"
|
||
|
||
def __init__(self) -> None:
|
||
self.logger = logging.getLogger(f"parser.{self.source_name}")
|
||
|
||
@abstractmethod
|
||
def fetch(self, **kwargs) -> list[dict[str, Any]]:
|
||
"""
|
||
Получить сырые документы из источника.
|
||
|
||
Args:
|
||
**kwargs: Специфичные для источника параметры (query, limit и т.д.)
|
||
|
||
Returns:
|
||
Список сырых словарей из API источника
|
||
"""
|
||
...
|
||
|
||
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
Привести сырой документ к унифицированному формату.
|
||
|
||
Args:
|
||
raw: Сырой словарь из API источника
|
||
|
||
Returns:
|
||
Документ в унифицированном формате
|
||
"""
|
||
raise NotImplementedError(
|
||
f"Парсер {self.source_name!r} должен реализовать метод transform()"
|
||
)
|
||
|
||
def save_jsonl(self, docs: list[dict[str, Any]], output_path: Path) -> None:
|
||
"""
|
||
Сохранить документы в JSONL формате (один JSON объект на строку).
|
||
|
||
Args:
|
||
docs: Список документов
|
||
output_path: Путь к выходному файлу (дополняется, не перезаписывается)
|
||
"""
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with output_path.open("a", encoding="utf-8") as f:
|
||
for doc in docs:
|
||
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
|
||
|
||
def run(self, output_dir: Path, **kwargs) -> list[dict[str, Any]]:
|
||
"""
|
||
Запустить парсер: fetch → transform → save.
|
||
|
||
Args:
|
||
output_dir: Директория для сохранения результатов
|
||
**kwargs: Параметры для метода fetch()
|
||
|
||
Returns:
|
||
Список трансформированных документов
|
||
"""
|
||
self.logger.info(f"[{self.source_name}] Начало парсинга...")
|
||
|
||
raw_docs = self.fetch(**kwargs)
|
||
self.logger.info(f"[{self.source_name}] Получено {len(raw_docs)} документов")
|
||
|
||
transformed = []
|
||
errors = 0
|
||
for raw in raw_docs:
|
||
try:
|
||
doc = self.transform(raw)
|
||
if doc and doc.get("title") and doc.get("ext_id"):
|
||
transformed.append(doc)
|
||
except Exception as e:
|
||
errors += 1
|
||
self.logger.warning(f"Ошибка трансформации: {e}")
|
||
|
||
if errors:
|
||
self.logger.warning(f"[{self.source_name}] Ошибок трансформации: {errors}")
|
||
|
||
out_file = output_dir / f"{self.source_name}.jsonl"
|
||
self.save_jsonl(transformed, out_file)
|
||
|
||
self.logger.info(
|
||
f"[{self.source_name}] Сохранено {len(transformed)} документов → {out_file}"
|
||
)
|
||
|
||
return transformed
|
||
|
||
@staticmethod
|
||
def normalize_authors(raw_authors: list) -> list[dict[str, str]]:
|
||
"""
|
||
Нормализовать список авторов к единому формату.
|
||
|
||
Args:
|
||
raw_authors: Авторы из API (разный формат у каждого источника)
|
||
|
||
Returns:
|
||
Список {"last_name": str, "first_name": str, "initials": str}
|
||
"""
|
||
result = []
|
||
for author in raw_authors:
|
||
if isinstance(author, str):
|
||
parts = author.strip().split()
|
||
last_name = parts[0] if parts else ""
|
||
first_name = " ".join(parts[1:]) if len(parts) > 1 else ""
|
||
elif isinstance(author, dict):
|
||
last_name = (
|
||
author.get("last_name") or
|
||
author.get("family") or
|
||
author.get("surname") or ""
|
||
).strip()
|
||
first_name = (
|
||
author.get("first_name") or
|
||
author.get("given") or ""
|
||
).strip()
|
||
else:
|
||
continue
|
||
|
||
if not last_name:
|
||
continue
|
||
|
||
# Инициалы
|
||
initials = ""
|
||
if first_name:
|
||
parts = first_name.split()
|
||
initials = "".join(p[0].upper() + "." for p in parts if p)
|
||
|
||
result.append({
|
||
"last_name": last_name,
|
||
"first_name": first_name,
|
||
"initials": initials,
|
||
})
|
||
|
||
return result
|