Files
anti-plagiarism/scripts/parsers/openalex.py
jze9 2daaa8c8a4 chore(lint): ruff-гейт в CI + фиксы (0 находок) — блокирует кривой деплой
Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода
(services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе.

Конфиг ruff.toml: правила E/F/W/I/UP/B/SIM/C4, line-length 100. Осознанно
выключены E501 (длину держит форматтер; длинные RU-комментарии — норма),
B008 (Depends()/Query() в дефолтах — идиома FastAPI, не баг) и UP042
((str, Enum)→StrEnum меняет __str__/сериализацию — не трогаем).

Починено под ноль находок:
- B904 (11): raise ... from exc / from None — читаемые цепочки исключений в
  Celery-ретраях и HTTPException, ошибки обработки не маскируют исходные.
- SIM105 (5): try/except/pass → contextlib.suppress (faiss remove_ids, lsh.remove,
  сброс кэша, ws-disconnect, парс года).
- C416/SIM108/B905/F841/UP035/UP017/F401/I001: dict(rows), тернарник, zip strict,
  мёртвая переменная, устаревшие импорты, timezone.utc→UTC, чистка/сортировка.

Обвязка: scripts/run_lint.sh (ruff в изолированном python:3.11-slim), шаг «Линт»
в job test перед юнит-тестами (падаем раньше). make lint / make lint-fix.
Все 41 юнит-тест по-прежнему зелёные, изменённые файлы компилируются.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 17:20:24 +05:00

264 lines
9.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 logging
import time
from collections.abc import Generator
from typing import Any
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",
open_access_only: bool = False,
) -> list[dict[str, Any]]:
"""
Получить документы из OpenAlex.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
lang: Язык ('ru', 'en', None = все)
year_from: Год публикации от
year_to: Год публикации до
type_filter: Тип документа (journal-article, book, и т.д.)
open_access_only: только open-access работы (is_oa:true) — резко
повышает долю статей с доступным PDF (для наполнения корпуса)
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,
open_access_only=open_access_only,
):
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,
open_access_only: bool = False,
) -> 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 open_access_only:
filters.append("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 на полный текст: предпочитаем прямую ссылку на PDF (её реально можно
# скачать и извлечь текст), иначе oa_url / лендинг журнала.
best_oa = raw.get("best_oa_location") or {}
oa = raw.get("open_access") or {}
url = (
best_oa.get("pdf_url")
or primary_location.get("pdf_url")
or oa.get("oa_url")
or best_oa.get("landing_page_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 ""