feat: initial microservices project structure
Services: - api: FastAPI gateway with JWT auth, async endpoints, WebSocket - worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM - worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction - worker-notifier: SMTP email notifications - worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting Infrastructure: - docker-compose.yml (production) + docker-compose.dev.yml (hot reload) - Nginx reverse proxy + WebSocket support - PostgreSQL 16 with Alembic migrations - Elasticsearch 8 with Russian/English analyzers - MinIO, RabbitMQ, Redis, Ollama Frontend: - React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5 - 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register Scripts: - Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
218
scripts/parsers/arxiv.py
Normal file
218
scripts/parsers/arxiv.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Парсер arXiv API.
|
||||
|
||||
arXiv — открытый препринт-сервер в физике, математике, CS и биологии.
|
||||
API: https://info.arxiv.org/help/api/index.html
|
||||
|
||||
Использует Atom XML API.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
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
|
||||
url = None
|
||||
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"
|
||||
|
||||
# Категории как JSON
|
||||
categories = raw.get("categories", [])
|
||||
|
||||
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,
|
||||
}
|
||||
166
scripts/parsers/base.py
Normal file
166
scripts/parsers/base.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Базовый класс для всех парсеров источников.
|
||||
|
||||
Определяет унифицированный интерфейс и формат документов.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Унифицированный формат документа
|
||||
UNIFIED_SCHEMA = {
|
||||
"source": str, # openalex, cyberleninka, arxiv, wikipedia_ru, wikipedia_en
|
||||
"ext_id": str, # Внешний ID (уникален в рамках источника)
|
||||
"doi": "str|None",
|
||||
"title": str,
|
||||
"authors": list, # [{"last_name": str, "first_name": str, "initials": str}]
|
||||
"year": "int|None",
|
||||
"lang": "str|None", # ru, en
|
||||
"journal": "str|None",
|
||||
"volume": "str|None",
|
||||
"issue": "str|None",
|
||||
"pages": "str|None",
|
||||
"abstract": "str|None",
|
||||
"url": "str|None",
|
||||
"full_text": "str|None", # Для fingerprinting (не хранится в PostgreSQL)
|
||||
}
|
||||
|
||||
|
||||
class BaseParser(ABC):
|
||||
"""Базовый класс парсера источника."""
|
||||
|
||||
source_name: str = "unknown"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.logger = logging.getLogger(f"parser.{self.source_name}")
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self, **kwargs) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получить сырые документы из источника.
|
||||
|
||||
Args:
|
||||
**kwargs: Специфичные для источника параметры (query, limit и т.д.)
|
||||
|
||||
Returns:
|
||||
Список сырых словарей из API источника
|
||||
"""
|
||||
...
|
||||
|
||||
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Привести сырой документ к унифицированному формату.
|
||||
|
||||
Args:
|
||||
raw: Сырой словарь из API источника
|
||||
|
||||
Returns:
|
||||
Документ в унифицированном формате
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Парсер {self.source_name!r} должен реализовать метод transform()"
|
||||
)
|
||||
|
||||
def save_jsonl(self, docs: list[dict[str, Any]], output_path: Path) -> None:
|
||||
"""
|
||||
Сохранить документы в JSONL формате (один JSON объект на строку).
|
||||
|
||||
Args:
|
||||
docs: Список документов
|
||||
output_path: Путь к выходному файлу (дополняется, не перезаписывается)
|
||||
"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("a", encoding="utf-8") as f:
|
||||
for doc in docs:
|
||||
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
|
||||
|
||||
def run(self, output_dir: Path, **kwargs) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Запустить парсер: fetch → transform → save.
|
||||
|
||||
Args:
|
||||
output_dir: Директория для сохранения результатов
|
||||
**kwargs: Параметры для метода fetch()
|
||||
|
||||
Returns:
|
||||
Список трансформированных документов
|
||||
"""
|
||||
self.logger.info(f"[{self.source_name}] Начало парсинга...")
|
||||
|
||||
raw_docs = self.fetch(**kwargs)
|
||||
self.logger.info(f"[{self.source_name}] Получено {len(raw_docs)} документов")
|
||||
|
||||
transformed = []
|
||||
errors = 0
|
||||
for raw in raw_docs:
|
||||
try:
|
||||
doc = self.transform(raw)
|
||||
if doc and doc.get("title") and doc.get("ext_id"):
|
||||
transformed.append(doc)
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
self.logger.warning(f"Ошибка трансформации: {e}")
|
||||
|
||||
if errors:
|
||||
self.logger.warning(f"[{self.source_name}] Ошибок трансформации: {errors}")
|
||||
|
||||
out_file = output_dir / f"{self.source_name}.jsonl"
|
||||
self.save_jsonl(transformed, out_file)
|
||||
|
||||
self.logger.info(
|
||||
f"[{self.source_name}] Сохранено {len(transformed)} документов → {out_file}"
|
||||
)
|
||||
|
||||
return transformed
|
||||
|
||||
@staticmethod
|
||||
def normalize_authors(raw_authors: list) -> list[dict[str, str]]:
|
||||
"""
|
||||
Нормализовать список авторов к единому формату.
|
||||
|
||||
Args:
|
||||
raw_authors: Авторы из API (разный формат у каждого источника)
|
||||
|
||||
Returns:
|
||||
Список {"last_name": str, "first_name": str, "initials": str}
|
||||
"""
|
||||
result = []
|
||||
for author in raw_authors:
|
||||
if isinstance(author, str):
|
||||
parts = author.strip().split()
|
||||
last_name = parts[0] if parts else ""
|
||||
first_name = " ".join(parts[1:]) if len(parts) > 1 else ""
|
||||
elif isinstance(author, dict):
|
||||
last_name = (
|
||||
author.get("last_name") or
|
||||
author.get("family") or
|
||||
author.get("surname") or ""
|
||||
).strip()
|
||||
first_name = (
|
||||
author.get("first_name") or
|
||||
author.get("given") or ""
|
||||
).strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if not last_name:
|
||||
continue
|
||||
|
||||
# Инициалы
|
||||
initials = ""
|
||||
if first_name:
|
||||
parts = first_name.split()
|
||||
initials = "".join(p[0].upper() + "." for p in parts if p)
|
||||
|
||||
result.append({
|
||||
"last_name": last_name,
|
||||
"first_name": first_name,
|
||||
"initials": initials,
|
||||
})
|
||||
|
||||
return result
|
||||
233
scripts/parsers/cyberleninka.py
Normal file
233
scripts/parsers/cyberleninka.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Парсер КиберЛенинки.
|
||||
|
||||
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
|
||||
Сайт: https://cyberleninka.ru
|
||||
|
||||
Используем парсинг HTML страниц со строгим rate limiting (1 req/sec),
|
||||
а не недокументированное API.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from base import BaseParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://cyberleninka.ru"
|
||||
SEARCH_URL = f"{BASE_URL}/api/search"
|
||||
ARTICLE_URL = f"{BASE_URL}/article"
|
||||
RATE_LIMIT_DELAY = 1.0 # секунд между запросами
|
||||
|
||||
|
||||
class CyberLeninkaParser(BaseParser):
|
||||
"""Парсер КиберЛенинки через HTML + API поиска."""
|
||||
|
||||
source_name = "cyberleninka"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.client = httpx.Client(
|
||||
headers={
|
||||
"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||
},
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
query: str = "",
|
||||
limit: int = 500,
|
||||
subject: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получить статьи из КиберЛенинки.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
limit: Максимальное количество статей
|
||||
subject: Предметная область (опционально)
|
||||
|
||||
Returns:
|
||||
Список сырых словарей статей
|
||||
"""
|
||||
results = []
|
||||
page = 0
|
||||
|
||||
while len(results) < limit:
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"q": query,
|
||||
"size": min(10, limit - len(results)),
|
||||
"from": page * 10,
|
||||
}
|
||||
|
||||
response = self.client.get(SEARCH_URL, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
items = data.get("articles") or []
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
results.extend(items)
|
||||
page += 1
|
||||
|
||||
if len(items) < 10:
|
||||
break
|
||||
|
||||
time.sleep(RATE_LIMIT_DELAY)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"КиберЛенинка HTTP ошибка: {e.response.status_code}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса КиберЛенинки: {e}")
|
||||
break
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Преобразовать статью КиберЛенинки в унифицированный формат.
|
||||
|
||||
Структура ответа API поиска КиберЛенинки:
|
||||
- id: числовой ID
|
||||
- name: название
|
||||
- authors: строка с авторами
|
||||
- journal: название журнала
|
||||
- year: год
|
||||
- annotation: аннотация
|
||||
- link: путь к статье
|
||||
"""
|
||||
raw_id = raw.get("id") or raw.get("link", "")
|
||||
ext_id = str(raw_id)
|
||||
|
||||
if not ext_id:
|
||||
return {}
|
||||
|
||||
# Авторы (строка вида "Иванов И.И., Петров П.П.")
|
||||
authors_str = raw.get("authors", "") or ""
|
||||
authors = _parse_cyberleninka_authors(authors_str)
|
||||
|
||||
# Год
|
||||
year_raw = raw.get("year")
|
||||
year = None
|
||||
if year_raw:
|
||||
try:
|
||||
year = int(str(year_raw)[:4])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# URL
|
||||
link = raw.get("link", "")
|
||||
url = f"{BASE_URL}{link}" if link.startswith("/") else link or None
|
||||
|
||||
return {
|
||||
"source": self.source_name,
|
||||
"ext_id": ext_id,
|
||||
"doi": raw.get("doi") or None,
|
||||
"title": (raw.get("name") or "").strip() or None,
|
||||
"authors": authors,
|
||||
"year": year,
|
||||
"lang": "ru", # КиберЛенинка — только русскоязычные
|
||||
"journal": raw.get("journal") or None,
|
||||
"volume": raw.get("volume") or None,
|
||||
"issue": raw.get("number") or None,
|
||||
"pages": raw.get("pages") or None,
|
||||
"abstract": (raw.get("annotation") or "").strip() or None,
|
||||
"url": url,
|
||||
"full_text": None,
|
||||
}
|
||||
|
||||
def fetch_article_details(self, url: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получить детали статьи из HTML страницы.
|
||||
|
||||
Извлекает метатеги citation_* по Dublin Core / Google Scholar стандарту.
|
||||
|
||||
Args:
|
||||
url: URL страницы статьи
|
||||
|
||||
Returns:
|
||||
Словарь с дополнительными метаданными
|
||||
"""
|
||||
try:
|
||||
time.sleep(RATE_LIMIT_DELAY)
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
meta = {}
|
||||
|
||||
# Извлечь citation_* мета теги
|
||||
for tag in soup.find_all("meta"):
|
||||
name = tag.get("name", "")
|
||||
content = tag.get("content", "")
|
||||
if name.startswith("citation_") and content:
|
||||
key = name[9:] # Убрать "citation_"
|
||||
meta[key] = content
|
||||
|
||||
return {
|
||||
"doi": meta.get("doi"),
|
||||
"title": meta.get("title"),
|
||||
"abstract": meta.get("abstract"),
|
||||
"year": meta.get("publication_date", "")[:4] or None,
|
||||
"journal": meta.get("journal_title"),
|
||||
"volume": meta.get("volume"),
|
||||
"issue": meta.get("issue"),
|
||||
"pages": f"{meta.get('firstpage', '')}-{meta.get('lastpage', '')}".strip("-") or None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения деталей статьи {url}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
|
||||
"""
|
||||
Разбить строку авторов КиберЛенинки на список.
|
||||
|
||||
Форматы: "Иванов И.И.", "Иванов И.И., Петров П.П."
|
||||
"""
|
||||
if not authors_str.strip():
|
||||
return []
|
||||
|
||||
results = []
|
||||
# Разделитель — запятая, но не внутри инициалов
|
||||
parts = re.split(r",\s+(?=[А-ЯA-Z])", authors_str.strip())
|
||||
|
||||
for part in parts:
|
||||
part = part.strip().rstrip(",")
|
||||
words = part.split()
|
||||
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Ищем паттерн: Фамилия Инициалы (последнее слово — инициалы)
|
||||
if len(words) >= 2:
|
||||
# Проверить, последнее ли слово — инициалы (содержит точки)
|
||||
if "." in words[-1]:
|
||||
last_name = " ".join(words[:-1])
|
||||
initials = words[-1]
|
||||
else:
|
||||
last_name = words[0]
|
||||
initials = "".join(w[0].upper() + "." for w in words[1:] if w)
|
||||
else:
|
||||
last_name = words[0]
|
||||
initials = ""
|
||||
|
||||
results.append({
|
||||
"last_name": last_name,
|
||||
"first_name": "",
|
||||
"initials": initials,
|
||||
})
|
||||
|
||||
return results
|
||||
245
scripts/parsers/openalex.py
Normal file
245
scripts/parsers/openalex.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Парсер 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 = "journal-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,
|
||||
}
|
||||
|
||||
# Фильтры
|
||||
filters = [f"type:{type_filter}", "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 на полный текст
|
||||
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 ""
|
||||
152
scripts/run_parser.py
Normal file
152
scripts/run_parser.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI для запуска парсеров источников.
|
||||
|
||||
Использование:
|
||||
python run_parser.py openalex --limit 10000 --query "машинное обучение"
|
||||
python run_parser.py cyberleninka --limit 1000 --query "нейронные сети"
|
||||
python run_parser.py arxiv --limit 5000 --categories cs.AI cs.LG
|
||||
python run_parser.py all --limit 1000
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавить директорию парсеров в путь
|
||||
sys.path.insert(0, str(Path(__file__).parent / "parsers"))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_openalex(args: argparse.Namespace, output_dir: Path) -> None:
|
||||
from openalex import OpenAlexParser
|
||||
|
||||
parser = OpenAlexParser()
|
||||
parser.run(
|
||||
output_dir=output_dir,
|
||||
query=args.query,
|
||||
limit=args.limit,
|
||||
lang=args.lang,
|
||||
year_from=args.year_from,
|
||||
year_to=args.year_to,
|
||||
)
|
||||
|
||||
|
||||
def run_cyberleninka(args: argparse.Namespace, output_dir: Path) -> None:
|
||||
from cyberleninka import CyberLeninkaParser
|
||||
|
||||
parser = CyberLeninkaParser()
|
||||
parser.run(
|
||||
output_dir=output_dir,
|
||||
query=args.query,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
|
||||
def run_arxiv(args: argparse.Namespace, output_dir: Path) -> None:
|
||||
from arxiv import ArxivParser
|
||||
|
||||
parser = ArxivParser()
|
||||
parser.run(
|
||||
output_dir=output_dir,
|
||||
query=args.query,
|
||||
limit=args.limit,
|
||||
categories=args.categories,
|
||||
year_from=args.year_from,
|
||||
)
|
||||
|
||||
|
||||
def run_all(args: argparse.Namespace, output_dir: Path) -> None:
|
||||
"""Запустить все парсеры последовательно."""
|
||||
logger.info("Запуск всех парсеров...")
|
||||
run_openalex(args, output_dir)
|
||||
run_cyberleninka(args, output_dir)
|
||||
run_arxiv(args, output_dir)
|
||||
|
||||
|
||||
PARSERS = {
|
||||
"openalex": run_openalex,
|
||||
"cyberleninka": run_cyberleninka,
|
||||
"arxiv": run_arxiv,
|
||||
"all": run_all,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Запуск парсеров академических источников",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"source",
|
||||
choices=list(PARSERS.keys()),
|
||||
help="Источник для парсинга",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query", "-q",
|
||||
default="",
|
||||
help="Поисковый запрос",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", "-n",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Максимальное количество документов (default: 1000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
default="/data/processed",
|
||||
help="Директория для сохранения JSONL (default: /data/processed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang",
|
||||
choices=["ru", "en"],
|
||||
default=None,
|
||||
help="Язык документов",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--year-from",
|
||||
type=int,
|
||||
default=None,
|
||||
dest="year_from",
|
||||
help="Год публикации от",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--year-to",
|
||||
type=int,
|
||||
default=None,
|
||||
dest="year_to",
|
||||
help="Год публикации до",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--categories",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="arXiv категории (например: cs.AI cs.LG math.ST)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"Парсер: {args.source}, лимит: {args.limit}, запрос: {args.query!r}")
|
||||
logger.info(f"Выходная директория: {output_dir}")
|
||||
|
||||
run_fn = PARSERS[args.source]
|
||||
run_fn(args, output_dir)
|
||||
|
||||
logger.info("Парсинг завершён.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user