Files
anti-plagiarism/scripts/parsers/openalex.py
jze9 9894fa9320 feat: админ-панель, лицензия, тестовый стек на распределённой инфраструктуре
Админ-панель (доступ: is_admin + секретный код сессии в /admin/<code>):
- разделы: дашборд (здоровье сервисов, статистика), клиенты, работы,
  база документов, хранилище MinIO, источники парсинга, отстойник работ
- backend: модели ParseSource/StagedWork/AdminSession, миграция 003,
  core/admin (авторизация), роутер api/admin
- frontend: AdminLayout + 7 вкладок, adminApi, вход из кабинета
- Celery index.run_parser (фоновый парсинг), автосбор проверенных работ
  в отстойник с ручным одобрением → индексация в базу

Исправления:
- openalex parser: тип article (не journal-article), убран is_oa-фильтр
- winnowing: приведение xxh64 к signed int64 (фикс bigint out of range)
- bcrypt пин 4.0.1 (совместимость с passlib 1.7.4)
- alembic: prepend_sys_path + asyncpg-драйвер
- frontend: контракт Task (public_id вместо id), react-dropzone

Инфраструктура:
- docker-compose.test.yml: api+frontend+воркеры локально, БД/кэш/хранилище/
  брокер/LLM — на удалённых серверах через .env
- .gitignore: исправлено ошибочное игнорирование кода services/*/app/models/
- LICENSE: проприетарная

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:45:53 +05:00

248 lines
8.5 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 = "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,
}
# Фильтры
# OpenAlex переименовал journal-article → article; is_oa:true сильно
# сужает выдачу, поэтому не навязываем открытый доступ по умолчанию.
filters = [f"type:{type_filter}"]
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 ""