"""Soft-block (HTTP 200 generic-витрина) detection на detail-странице (#1967 / #1950). Под нагрузкой Avito иногда отдаёт HTTP 200 с ОБЩЕЙ витриной (Авито — Объявления на сайте Авито) вместо запрошенного объявления — мягкий анти-бот 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 = ( '' "Авито — Объявления на сайте Авито" "
...
" ) 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, "")) 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 = "№ 123" assert detail_mod._is_detail_soft_block(item_view) is False assert detail_mod._is_detail_soft_block("") is False assert detail_mod._is_detail_soft_block("") is False