Раньше корпус состоял только из метаданных: парсеры отдают 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>
77 lines
3.1 KiB
Python
77 lines
3.1 KiB
Python
"""Скачивание и извлечение полного текста статьи по 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
|