gendesign/tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py
lekss361 ca4637e5c8
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
fix(tradein/avito): detect HTTP-200 soft-block → rotate + coords-priority backfill ordering (#1950, #1967)
Soft-block (HTTP 200 generic витрина без item-view) теперь детектится как блок → существующая 403/firewall reconnect-машинерия → ротация exit-IP вместо silent failed (blocked=0, enriched→0 каскад). + snapshot ORDER BY (lat IS NULL) DESC для приоритизации coord-holes (#1967, in-scope 321). Tests: test_avito_detail_soft_block.py. Full tradein suite 2591 passed.
2026-06-28 08:06:12 +00:00

101 lines
4.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.

"""Soft-block (HTTP 200 generic-витрина) detection на detail-странице (#1967 / #1950).
Под нагрузкой Avito иногда отдаёт HTTP 200 с ОБЩЕЙ витриной
(<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного объявления —
мягкий анти-бот deflect. У такой страницы нет ни firewall-маркеров, ни item-view →
parse_detail_html ронял ValueError → generic failed, IP НЕ ротировался, enriched
каскадно падал к 0. _is_detail_soft_block детектит её как блок → существующая
403/firewall reconnect-машинерия пересоздаёт эфемерную сессию (свежий exit-IP).
Зеркало fixture-стиля test_avito_detail_403_reconnect.py.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.core.config import settings
from app.services.scrapers import avito_detail as detail_mod
from app.services.scrapers.avito_detail import fetch_detail
from app.services.scrapers.avito_exceptions import AvitoBlockedError
_SENTINEL = object()
_SOFT_BLOCK_HTML = (
'<!DOCTYPE html><html lang="ru"><head>'
"<title>Авито — Объявления на сайте Авито</title>"
"</head><body><div>...</div></body></html>"
)
def _resp(status_code: int, text: str = "") -> MagicMock:
r = MagicMock()
r.status_code = status_code
r.text = text
return r
def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")
@pytest.fixture(autouse=True)
def _patch_common() -> object:
"""asyncio.sleep → no-op; parse_detail_html → sentinel (нет нужды в реальном HTML)."""
with (
patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
patch(
"app.services.scrapers.avito_detail.parse_detail_html", return_value=_SENTINEL
) as parse,
):
yield parse
@pytest.mark.asyncio
async def test_soft_block_200_reconnect_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
"""soft-block(HTTP 200) → 200: generic-витрина детектится как блок → reconnect."""
_enable_backconnect(monkeypatch)
caller = AsyncMock()
caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
retry1 = AsyncMock()
retry1.get = AsyncMock(return_value=_resp(200, "<html/>"))
build = MagicMock(side_effect=[retry1])
with patch.object(detail_mod, "_build_detail_session", build):
result = await fetch_detail("/x", cffi_session=caller)
assert result is _SENTINEL
assert build.call_count == 1
caller.close.assert_not_called()
@pytest.mark.asyncio
async def test_soft_block_exhausted_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
"""soft-block на каждом attempt _AVITO_DETAIL_403_MAX_RETRIES+1 раз → AvitoBlockedError."""
_enable_backconnect(monkeypatch)
caller = AsyncMock()
caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
def _make_retry() -> AsyncMock:
s = AsyncMock()
s.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML))
return s
build = MagicMock(side_effect=lambda: _make_retry())
with patch.object(detail_mod, "_build_detail_session", build):
with pytest.raises(AvitoBlockedError):
await fetch_detail("/x", cffi_session=caller)
assert build.call_count == detail_mod._AVITO_DETAIL_403_MAX_RETRIES
caller.close.assert_not_called()
def test_is_detail_soft_block_unit() -> None:
"""Юнит _is_detail_soft_block: generic-витрина → True; item-view / пусто → False."""
assert detail_mod._is_detail_soft_block(_SOFT_BLOCK_HTML) is True
item_view = "<html><body><span data-marker='item-view/item-id'>№ 123</span></body></html>"
assert detail_mod._is_detail_soft_block(item_view) is False
assert detail_mod._is_detail_soft_block("<html/>") is False
assert detail_mod._is_detail_soft_block("") is False