"""Скачивание и извлечение полного текста статьи по 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