feat: initial microservices project structure
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>
This commit is contained in:
166
scripts/parsers/base.py
Normal file
166
scripts/parsers/base.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Базовый класс для всех парсеров источников.
|
||||
|
||||
Определяет унифицированный интерфейс и формат документов.
|
||||
"""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user