Files
anti-plagiarism/services/worker-indexer/app/fulltext.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

77 lines
3.1 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.
"""Скачивание и извлечение полного текста статьи по URL источника.
OpenAlex/arXiv отдают в метаданных ссылку (`url` = oa_url / pdf), которая часто
ведёт на PDF открытого доступа. Здесь мы best-effort скачиваем файл и извлекаем
из него текст. HTML-страницы (лендинги журналов) пропускаем — надёжно доставать
текст статьи из произвольного HTML нельзя.
Всё завёрнуто в широкий except: недоступный/битый источник не должен ронять
заливку корпуса, просто у документа не будет полного текста.
"""
import logging
import httpx
from app.config import settings
from app.extractors.pdf import extract_text_from_pdf
logger = logging.getLogger(__name__)
_HEADERS = {
"User-Agent": "AcademicHelper/1.0 (+https://academic.jze9.ru; mailto:noreply@jze9mail.ru)",
"Accept": "application/pdf,*/*",
}
def fetch_full_text(url: str) -> str | None:
"""Скачать документ по URL и вернуть извлечённый текст, либо None.
Возвращает None, если: url пустой, файл не PDF, скачивание не удалось,
или извлечённого текста слишком мало (< FULL_TEXT_MIN_CHARS).
"""
if not url:
return None
try:
with httpx.Client(
follow_redirects=True,
timeout=settings.FULL_TEXT_TIMEOUT,
headers=_HEADERS,
) as client:
with client.stream("GET", url) as resp:
resp.raise_for_status()
ctype = resp.headers.get("content-type", "").lower()
# Скачиваем с ограничением размера
buf = bytearray()
for chunk in resp.iter_bytes():
buf += chunk
if len(buf) > settings.FULL_TEXT_MAX_BYTES:
logger.info(
f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, "
f"обрезаю: {url}"
)
break
data = bytes(buf)
except Exception as e:
logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None
# Определяем PDF по content-type или magic-байтам
is_pdf = "pdf" in ctype or data[:5] == b"%PDF-"
if not is_pdf:
logger.debug(f"full-text: не PDF (content-type={ctype!r}), пропускаю: {url}")
return None
try:
text = (extract_text_from_pdf(data) or "").strip()
except Exception as e:
logger.info(f"full-text: извлечение PDF не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None
if len(text) < settings.FULL_TEXT_MIN_CHARS:
return None
return text