"""curl_cffi 403/firewall reconnect-retry под backconnect-прокси. Под backconnect rotating-прокси (mproxy.site, нет changeip-URL) одиночный HTTP 403 или firewall-интерстициал ронял весь avito_full_load sweep через AvitoBlockedError → mark_banned. Каждое новое соединение через backconnect даёт свежий exit-IP, поэтому ретраим пересозданием curl_cffi-сессии до _AVITO_403_MAX_RETRIES раз. curl_cffi-сессия всегда egress через scraper_proxy_url (backconnect mproxy), поэтому reconnect-retry гейтим ТОЛЬКО на наличие proxy — независимо от того, задан ли changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Включаем через scraper_proxy_url_env (scraper_proxy_url — read-only property, его не monkeypatch'ишь). """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.scrapers import avito as avito_mod from app.services.scrapers.avito import AvitoScraper from app.services.scrapers.avito_exceptions import AvitoBlockedError # Валидная карточка SERP (одна) — successful page после reconnect. _CARD_HTML = ( "" '
' '' "2-к. квартира, 54,3 м², 5/9 эт." "" '' "
" "" ) # Firewall-интерстициал (HTTP 200, не 403). _FIREWALL_HTML = ( "Доступ ограничен: проблема с IP" '
...
' ) def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None: """scraper_proxy_url задан (через env-поле), changeip-URL — нет → backconnect.""" monkeypatch.setattr(avito_mod.settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619") monkeypatch.setattr(avito_mod.settings, "avito_proxy_url", None) monkeypatch.setattr(avito_mod.settings, "avito_proxy_rotate_url", None) @pytest.mark.asyncio async def test_backconnect_403_403_200_reconnect_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """backconnect: 403 → 403 → 200 → reconnect-retry возвращает HTML, _reset_cffi дёрнут 2×.""" _enable_backconnect(monkeypatch) scraper = AvitoScraper() r403 = MagicMock() r403.status_code = 403 r403.text = "" ok = MagicMock() ok.status_code = 200 ok.text = _CARD_HTML mock_session = AsyncMock() mock_session.get = AsyncMock(side_effect=[r403, r403, ok]) scraper._cffi = mock_session # _reset_cffi не должен реально пересоздавать сессию (иначе .get-side_effect # потеряется) — патчим на no-op счётчик, проверяем что вызвался. reset = AsyncMock() with ( patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()), patch.object(AvitoScraper, "_reset_cffi", reset), patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot, ): html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) assert html == _CARD_HTML assert reset.await_count == 2 # 403, 403 → два reconnect перед 200 rot.assert_not_awaited() # backconnect не дёргает changeip-ротацию assert mock_session.get.await_count == 3 @pytest.mark.asyncio async def test_backconnect_firewall_reconnect_succeeds( monkeypatch: pytest.MonkeyPatch, ) -> None: """backconnect: firewall-200 → 200 → reconnect-retry (firewall тоже триггерит).""" _enable_backconnect(monkeypatch) scraper = AvitoScraper() fw = MagicMock() fw.status_code = 200 fw.text = _FIREWALL_HTML ok = MagicMock() ok.status_code = 200 ok.text = _CARD_HTML mock_session = AsyncMock() mock_session.get = AsyncMock(side_effect=[fw, ok]) scraper._cffi = mock_session reset = AsyncMock() with ( patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()), patch.object(AvitoScraper, "_reset_cffi", reset), ): html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) assert html == _CARD_HTML assert reset.await_count == 1 @pytest.mark.asyncio async def test_backconnect_403_exhausted_raises_blocked( monkeypatch: pytest.MonkeyPatch, ) -> None: """backconnect: 403 _AVITO_403_MAX_RETRIES+1 раз → всё равно AvitoBlockedError.""" _enable_backconnect(monkeypatch) scraper = AvitoScraper() r403 = MagicMock() r403.status_code = 403 r403.text = "" mock_session = AsyncMock() mock_session.get = AsyncMock(return_value=r403) scraper._cffi = mock_session reset = AsyncMock() with ( patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()), patch.object(AvitoScraper, "_reset_cffi", reset), ): with pytest.raises(AvitoBlockedError, match="IP banned"): await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) # ровно _AVITO_403_MAX_RETRIES reconnect, потом raise assert reset.await_count == avito_mod._AVITO_403_MAX_RETRIES # get: 1 первичный + _AVITO_403_MAX_RETRIES повторов assert mock_session.get.await_count == avito_mod._AVITO_403_MAX_RETRIES + 1 @pytest.mark.asyncio async def test_reconnect_fires_even_when_rotate_url_set( monkeypatch: pytest.MonkeyPatch, ) -> None: """Прод-конфиг run-212 регрессия: scraper_proxy_url + leftover avito_proxy_rotate_url + max_rotations=0 → reconnect-retry ВСЁ РАВНО срабатывает (gate только на наличие proxy). changeip-URL относится к ДРУГОМУ (auv/browser) прокси; curl_cffi egress через scraper_proxy_url, поэтому пересоздание сессии меняет exit-IP независимо от changeip. С max_rotations=0 changeip-путь — no-op, fallback-reconnect снимает 403. """ monkeypatch.setattr( avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x" ) monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 0) monkeypatch.setattr(avito_mod.settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619") scraper = AvitoScraper() r403 = MagicMock() r403.status_code = 403 r403.text = "" ok = MagicMock() ok.status_code = 200 ok.text = _CARD_HTML mock_session = AsyncMock() mock_session.get = AsyncMock(side_effect=[r403, r403, ok]) scraper._cffi = mock_session reset = AsyncMock() with ( patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()), patch.object(AvitoScraper, "_reset_cffi", reset), patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot, ): html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) assert html == _CARD_HTML # 403→403→200 снят reconnect'ом, нет raise assert reset.await_count == 2 # reconnect сработал несмотря на rotate_url rot.assert_not_awaited() # max_rotations=0 → changeip-путь не дёргается assert mock_session.get.await_count == 3 @pytest.mark.asyncio async def test_changeip_path_tried_first_when_rotations_available( monkeypatch: pytest.MonkeyPatch, ) -> None: """changeip precedence: scraper_proxy_url + rotate_url + max_rotations=1 → на 403 changeip-ротация пробуется ПЕРЕД reconnect-fallback. Гарантирует, что новый gate не сломал приоритет legacy changeip-пути. """ monkeypatch.setattr( avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x" ) monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1) monkeypatch.setattr(avito_mod.settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619") scraper = AvitoScraper() r403 = MagicMock() r403.status_code = 403 r403.text = "" ok = MagicMock() ok.status_code = 200 ok.text = _CARD_HTML mock_session = AsyncMock() mock_session.get = AsyncMock(side_effect=[r403, ok]) scraper._cffi = mock_session reset = AsyncMock() rot = AsyncMock(return_value=True) with ( patch("app.services.scrapers.avito.asyncio.sleep", AsyncMock()), patch.object(AvitoScraper, "_reset_cffi", reset), patch.object(AvitoScraper, "_rotate_ip", rot), ): html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) assert html == _CARD_HTML rot.assert_awaited_once() # changeip-путь использован первым на 403 reset.assert_not_awaited() # до reconnect-fallback не дошло assert mock_session.get.await_count == 2