"""Базовый класс для всех парсеров источников. Определяет унифицированный интерфейс и формат документов. """ 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