Files
anti-plagiarism/scripts/parsers/openalex.py
jze9 ef2723e1d3 feat(indexer): скачивание полного текста статей + батчинг эмбеддингов
Раньше корпус состоял только из метаданных: парсеры отдают full_text=None,
fingerprints/эмбеддинги считались из аннотаций, MinIO статьями не наполнялся
вовсе. Проверка плагиата шла против абстрактов, а не тел статей.

Добавлено:
- app/fulltext.py: best-effort скачивание PDF по url источника (стрим с
  лимитом размера, детект PDF по content-type/magic), извлечение текста
  через PyMuPDF.
- index.enrich_full_text: новая задача — качает полный текст, кладёт в MinIO
  (documents/corpus/{id}.txt), пересчитывает fingerprints по полному тексту,
  обновляет MinHash. Диспатчится из add_document при FETCH_FULL_TEXT=true.
- openalex: предпочитаем прямую ссылку на PDF (best_oa_location.pdf_url)
  вместо лендинга — покрытие full-text выросло с 17% до 33% на выборке.
- run_parser батчит эмбеддинги (EMBED_BATCH_SIZE) вместо диспатча по одному
  документу: worker-gpu кодирует пачку разом и реже переписывает FAISS-индекс.

Покрытие ~33% (прямые OA-PDF: arxiv/usenix/springer/techscience и т.п.);
для остального остаётся фолбэк на аннотацию. Управляется FETCH_FULL_TEXT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:42:02 +05:00

256 lines
8.9 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 на полный текст: предпочитаем прямую ссылку на 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 ""