Второй 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>
76 lines
3.1 KiB
Python
76 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, 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
|