Files
anti-plagiarism/scripts/parsers/openalex.py
jze9 f1a8d07cb3
All checks were successful
Deploy / test (push) Successful in 4m3s
Deploy / deploy (push) Successful in 5s
fix(openalex): пауза между страницами и живой backoff — заливка тонула в 429
Массовый запуск показал: лимит вежливого пула OpenAlex (10 req/s) общий на
mailto, а не на процесс. Четыре воркера с паузой 0.1с получали сплошные 429,
и каждый прогон уходил в 900с бесполезного backoff, не забрав ничего.

- RATE_LIMIT_DELAY 0.1 → 1.0с (≈4 req/s на четырёх воркерах);
- backoff спит кусками по 5с и отчитывается через progress_cb: прогон больше
  не выглядит зависшим в админке и отменяется во время ожидания, а не после.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:54:42 +05:00

321 lines
13 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, ProgressCallback
logger = logging.getLogger(__name__)
OPENALEX_API = "https://api.openalex.org"
DEFAULT_EMAIL = "noreply@jze9.ru" # Для вежливого агента
# Пауза между страницами. Лимит вежливого пула — 10 запросов/сек НА ВЕСЬ ключ
# (mailto), а не на процесс: четыре воркера, качающие разные источники, делят
# его между собой. С прежними 0.1с массовая заливка утыкалась в сплошные 429 и
# каждый прогон уходил в 900с бесполезного backoff. 1с × 4 воркера ≈ 4 req/s.
RATE_LIMIT_DELAY = 1.0
# Backoff режем на куски: во время сна парсер обязан отчитываться о жизни,
# иначе прогон выглядит зависшим и его нельзя отменить из админки.
BACKOFF_TICK_S = 5.0
def _sleep_alive(
seconds: float, progress_cb: ProgressCallback | None, fetched: int
) -> bool:
"""Поспать, отчитываясь о жизни; False — попросили остановиться.
Минуты сна в backoff нельзя проводить молча: для админки такой прогон
неотличим от зависшего, а отмена не сработает до конца ожидания.
"""
if progress_cb is None:
time.sleep(seconds)
return True
left = seconds
while left > 0:
time.sleep(min(BACKOFF_TICK_S, left))
left -= BACKOFF_TICK_S
if not progress_cb(fetched):
return False
return True
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,
progress_cb: ProgressCallback | None = None,
) -> 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 (для наполнения корпуса)
progress_cb: см. base.ProgressCallback
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,
progress_cb=progress_cb,
):
results.extend(page)
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info("OpenAlex: выборка остановлена по запросу (получено %d)", len(results))
break
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,
progress_cb: ProgressCallback | None = None,
) -> Generator[list[dict], None, None]:
"""Cursor-based пагинация OpenAlex."""
cursor = "*"
per_page = min(200, limit)
total_fetched = 0
rate_limit_retries = 0
# 5 попыток (60+120+240+480+960=1860с) превышали дефолтный
# consumer_timeout RabbitMQ (1800с) — брокер рвал канал до того,
# как таск успевал сдаться и заacke-иться, воркер падал и Docker
# перезапускал его, задача редоставлялась и весь цикл начинался
# заново с попытки 1 — бесконечный краш-луп. 4 попытки = 900с,
# запас с большим запасом.
MAX_RATE_LIMIT_RETRIES = 4
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)
rate_limit_retries = 0 # успешный запрос — сбросить счётчик
# Следующий курсор
cursor = data.get("meta", {}).get("next_cursor")
if not cursor:
break
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"OpenAlex HTTP ошибка: {e.response.status_code}")
if e.response.status_code == 429:
rate_limit_retries += 1
if rate_limit_retries > MAX_RATE_LIMIT_RETRIES:
logger.error(
f"OpenAlex: {MAX_RATE_LIMIT_RETRIES} подряд 429 — сдаюсь, "
f"забрано {total_fetched}/{limit}"
)
break
# Экспоненциальный backoff: 60/120/240/480/960с — при нескольких
# параллельных воркерах плоское ожидание 60с не давало общему
# лимиту освободиться, каждый воркер продлевал блокировку сам.
wait = 60 * (2 ** (rate_limit_retries - 1))
logger.warning(f"Rate limit! Попытка {rate_limit_retries}/{MAX_RATE_LIMIT_RETRIES}, ждём {wait}с...")
if not _sleep_alive(wait, progress_cb, total_fetched):
logger.info("OpenAlex: ожидание прервано по запросу")
break
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 ""