Files
anti-plagiarism/scripts/parsers/arxiv.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

221 lines
7.2 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.
"""Парсер arXiv API.
arXiv — открытый препринт-сервер в физике, математике, CS и биологии.
API: https://info.arxiv.org/help/api/index.html
Использует Atom XML API.
"""
import logging
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser
logger = logging.getLogger(__name__)
ARXIV_API = "https://export.arxiv.org/api/query"
RATE_LIMIT_DELAY = 3.0 # arXiv требует не более 1 запроса/3сек
# Namespace XML
NS = {
"atom": "http://www.w3.org/2005/Atom",
"arxiv": "http://arxiv.org/schemas/atom",
"dc": "http://purl.org/dc/elements/1.1/",
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
}
class ArxivParser(BaseParser):
"""Парсер arXiv API."""
source_name = "arxiv"
def __init__(self) -> None:
super().__init__()
self.client = httpx.Client(
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
timeout=30.0,
)
def fetch(
self,
query: str = "",
limit: int = 500,
categories: list[str] | None = None,
year_from: int | None = None,
) -> list[dict[str, Any]]:
"""
Получить препринты из arXiv.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
categories: Список категорий arXiv (cs.AI, math.ST и т.д.)
year_from: Год публикации от
Returns:
Список сырых словарей
"""
results = []
start = 0
max_results = min(100, limit)
# Формируем запрос
search_query = ""
if query:
search_query = f"all:{query}"
if categories:
cat_query = " OR ".join(f"cat:{c}" for c in categories)
search_query = f"({search_query}) AND ({cat_query})" if search_query else f"({cat_query})"
if not search_query:
search_query = "all:neural network" # Default query
while len(results) < limit:
try:
params = {
"search_query": search_query,
"start": start,
"max_results": min(max_results, limit - len(results)),
"sortBy": "submittedDate",
"sortOrder": "descending",
}
response = self.client.get(ARXIV_API, params=params)
response.raise_for_status()
entries = self._parse_xml(response.text)
if not entries:
break
results.extend(entries)
start += len(entries)
if len(entries) < max_results:
break
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"arXiv HTTP ошибка: {e.response.status_code}")
break
except Exception as e:
logger.error(f"Ошибка запроса arXiv: {e}")
break
return results[:limit]
def _parse_xml(self, xml_text: str) -> list[dict[str, Any]]:
"""Парсить Atom XML ответ arXiv."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logger.error(f"Ошибка парсинга XML arXiv: {e}")
return []
entries = []
for entry in root.findall("atom:entry", NS):
entries.append(self._parse_entry(entry))
return entries
def _parse_entry(self, entry: ET.Element) -> dict[str, Any]:
"""Парсить один entry из Atom XML."""
def text(path: str) -> str | None:
elem = entry.find(path, NS)
return elem.text.strip() if elem is not None and elem.text else None
arxiv_id = text("atom:id") or ""
# Нормализовать: убрать версию
if "abs/" in arxiv_id:
arxiv_id = arxiv_id.split("abs/")[-1].split("v")[0]
# Авторы
authors = []
for author_elem in entry.findall("atom:author", NS):
name = text("atom:name") if author_elem.find("atom:name", NS) is not None else None
if name:
parts = name.strip().split()
if len(parts) >= 2:
authors.append({
"last_name": parts[-1],
"first_name": " ".join(parts[:-1]),
})
elif parts:
authors.append({"last_name": parts[0], "first_name": ""})
# Год из published
published = text("atom:published") or ""
year = int(published[:4]) if published[:4].isdigit() else None
# DOI
doi = None
for link in entry.findall("atom:link", NS):
if link.get("title") == "doi":
doi = link.get("href", "").replace("http://dx.doi.org/", "")
break
# URL — предпочитаем прямую ссылку на PDF (её качает enrich_full_text),
# иначе HTML-страницу аннотации
url = None
for link in entry.findall("atom:link", NS):
if link.get("title") == "pdf" or link.get("type") == "application/pdf":
url = link.get("href")
break
if not url:
for link in entry.findall("atom:link", NS):
if link.get("type") == "text/html":
url = link.get("href")
break
# Категории
categories = [
cat.get("term", "")
for cat in entry.findall("atom:category", NS)
]
return {
"id": arxiv_id,
"title": text("atom:title") or "",
"abstract": text("atom:summary") or "",
"authors": authors,
"year": year,
"published": published,
"doi": doi,
"url": url,
"categories": categories,
"journal": "arXiv",
}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Преобразовать документ arXiv в унифицированный формат."""
ext_id = raw.get("id", "")
if not ext_id:
return {}
# Нормализовать авторов
authors = self.normalize_authors(raw.get("authors", []))
# Определить язык (arXiv — преимущественно английский)
lang = "en"
return {
"source": self.source_name,
"ext_id": f"arxiv:{ext_id}",
"doi": raw.get("doi") or None,
"title": (raw.get("title") or "").replace("\n", " ").strip() or None,
"authors": authors,
"year": raw.get("year"),
"lang": lang,
"journal": "arXiv",
"volume": None,
"issue": None,
"pages": None,
"abstract": (raw.get("abstract") or "").replace("\n", " ").strip() or None,
"url": raw.get("url"),
"full_text": None,
}