Files
anti-plagiarism/scripts/parsers/openalex.py
jze9 7758315632 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>
2026-05-24 19:42:39 +05:00

246 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Парсер OpenAlex API.
OpenAlex — открытая академическая база данных с >250 млн работ.
API: https://docs.openalex.org/
Особенности:
- Cursor-based пагинация (не offset, чтобы не было дублей)
- Rate limit: 100,000 запросов/день без ключа
- Идемпотентность: проверка по ext_id перед добавлением
"""
import time
import logging
from typing import Any, Generator
import httpx
from base import BaseParser
logger = logging.getLogger(__name__)
OPENALEX_API = "https://api.openalex.org"
DEFAULT_EMAIL = "noreply@jze9.ru" # Для вежливого агента
class OpenAlexParser(BaseParser):
"""Парсер OpenAlex API с cursor-based пагинацией."""
source_name = "openalex"
def __init__(self, email: str = DEFAULT_EMAIL) -> None:
super().__init__()
self.email = email
self.client = httpx.Client(
headers={"User-Agent": f"AcademicHelper/1.0 ({email})"},
timeout=30.0,
)
def fetch(
self,
query: str = "",
limit: int = 1000,
lang: str | None = None,
year_from: int | None = None,
year_to: int | None = None,
type_filter: str = "journal-article",
) -> list[dict[str, Any]]:
"""
Получить документы из OpenAlex.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
lang: Язык ('ru', 'en', None = все)
year_from: Год публикации от
year_to: Год публикации до
type_filter: Тип документа (journal-article, book, и т.д.)
Returns:
Список сырых словарей из OpenAlex API
"""
results = []
for page in self._paginate(
query=query,
limit=limit,
lang=lang,
year_from=year_from,
year_to=year_to,
type_filter=type_filter,
):
results.extend(page)
if len(results) >= limit:
break
return results[:limit]
def _paginate(
self,
query: str,
limit: int,
lang: str | None,
year_from: int | None,
year_to: int | None,
type_filter: str,
) -> Generator[list[dict], None, None]:
"""Cursor-based пагинация OpenAlex."""
cursor = "*"
per_page = min(200, limit)
total_fetched = 0
while total_fetched < limit:
params: dict[str, Any] = {
"per-page": per_page,
"cursor": cursor,
"mailto": self.email,
}
# Фильтры
filters = [f"type:{type_filter}", "is_oa:true"]
if query:
params["search"] = query
if lang:
filters.append(f"language:{lang}")
if year_from and year_to:
filters.append(f"publication_year:{year_from}-{year_to}")
elif year_from:
filters.append(f"publication_year:>{year_from}")
elif year_to:
filters.append(f"publication_year:<{year_to}")
params["filter"] = ",".join(filters)
try:
response = self.client.get(f"{OPENALEX_API}/works", params=params)
response.raise_for_status()
data = response.json()
works = data.get("results", [])
if not works:
break
yield works
total_fetched += len(works)
# Следующий курсор
cursor = data.get("meta", {}).get("next_cursor")
if not cursor:
break
# Rate limiting: 10 запросов/сек без ключа
time.sleep(0.1)
except httpx.HTTPStatusError as e:
logger.error(f"OpenAlex HTTP ошибка: {e.response.status_code}")
if e.response.status_code == 429:
logger.warning("Rate limit! Ожидаем 60 секунд...")
time.sleep(60)
continue
break
except Exception as e:
logger.error(f"Ошибка запроса OpenAlex: {e}")
break
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""
Преобразовать документ OpenAlex в унифицированный формат.
OpenAlex структура:
- id: строка вида "https://openalex.org/W..."
- doi: DOI ссылка
- title: название
- authorships: список авторов
- publication_year: год
- primary_location.source.display_name: журнал
- open_access.oa_url: ссылка на PDF
- abstract_inverted_index: инвертированный индекс аннотации
"""
# Нормализовать ext_id (убрать URL-часть)
raw_id = raw.get("id", "")
ext_id = raw_id.replace("https://openalex.org/", "") if raw_id else ""
if not ext_id:
return {}
# Авторы
authorships = raw.get("authorships", [])
authors_raw = []
for a in authorships[:10]: # Максимум 10 авторов
author = a.get("author", {})
display_name = author.get("display_name", "")
if display_name:
# OpenAlex даёт "Иван Иванов" → разбиваем
parts = display_name.strip().split()
if len(parts) >= 2:
authors_raw.append({
"last_name": parts[-1],
"first_name": " ".join(parts[:-1]),
})
elif parts:
authors_raw.append({"last_name": parts[0], "first_name": ""})
authors = self.normalize_authors(authors_raw)
# Журнал
primary_location = raw.get("primary_location") or {}
source = primary_location.get("source") or {}
journal = source.get("display_name")
# URL на полный текст
oa = raw.get("open_access") or {}
url = oa.get("oa_url") or primary_location.get("landing_page_url")
# Аннотация (восстановить из инвертированного индекса)
abstract = None
inv_index = raw.get("abstract_inverted_index")
if inv_index:
abstract = _reconstruct_abstract(inv_index)
# Бибинформация
biblio = raw.get("biblio") or {}
# DOI
doi = raw.get("doi", "")
if doi and doi.startswith("https://doi.org/"):
doi = doi.replace("https://doi.org/", "")
# Язык
lang = raw.get("language")
return {
"source": self.source_name,
"ext_id": ext_id,
"doi": doi or None,
"title": (raw.get("title") or "").strip() or None,
"authors": authors,
"year": raw.get("publication_year"),
"lang": lang,
"journal": journal,
"volume": biblio.get("volume"),
"issue": biblio.get("issue"),
"pages": f"{biblio.get('first_page', '')}-{biblio.get('last_page', '')}".strip("-") or None,
"abstract": abstract,
"url": url,
"full_text": None, # Полный текст скачивается отдельно
}
def _reconstruct_abstract(inverted_index: dict) -> str:
"""
Восстановить аннотацию из инвертированного индекса OpenAlex.
Инвертированный индекс: {слово: [позиция1, позиция2, ...], ...}
"""
try:
positions: dict[int, str] = {}
for word, pos_list in inverted_index.items():
for pos in pos_list:
positions[pos] = word
return " ".join(positions[k] for k in sorted(positions.keys()))
except Exception:
return ""