From 44e316c51448e56ff49c1a2445a27f9c89d2f876 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 18 Jun 2026 21:31:20 +0300 Subject: [PATCH] fix(avito): gate 403 reconnect-retry on proxy presence only, not rotate_url absence PR #1764's reconnect-retry was dead code in prod: the gate excluded configs with avito_proxy_rotate_url set, but prod has BOTH the backconnect SCRAPER_PROXY_URL (what curl_cffi egresses through) AND a leftover changeip URL for the separate auv/browser proxy. With max_rotations=0 neither path fired -> a single SERP 403 still aborted the sweep (run 212). The curl session always uses scraper_proxy_url, so session-rebuild rotates the exit IP regardless of changeip config. --- .../backend/app/services/scrapers/avito.py | 8 +- .../test_avito_403_backconnect_reconnect.py | 73 ++++++++++++++++--- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 52f65c22..2f83aacd 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -437,9 +437,11 @@ class AvitoScraper(BaseScraper): return html assert self._cffi is not None max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0 - # backconnect-режим: прокси задан, но changeip-URL нет → ротация exit-IP - # делается пересозданием соединения, а не changeip-вызовом. - backconnect = bool(settings.scraper_proxy_url) and not settings.avito_proxy_rotate_url + # curl_cffi-сессия всегда egress через settings.scraper_proxy_url (backconnect + # mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости + # от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому + # reconnect-retry гейтим ТОЛЬКО на наличие proxy, не на отсутствие rotate_url. + backconnect = bool(settings.scraper_proxy_url) rot_done = 0 transient_left = 2 r429 = 0 diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py b/tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py index 47495462..b9fcb6dd 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py @@ -5,8 +5,10 @@ mark_banned. Каждое новое соединение через backconnect даёт свежий exit-IP, поэтому ретраим пересозданием curl_cffi-сессии до _AVITO_403_MAX_RETRIES раз. -backconnect = scraper_proxy_url задан И avito_proxy_rotate_url НЕ задан. Включаем через -scraper_proxy_url_env (scraper_proxy_url — read-only property, его не monkeypatch'ишь). +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 @@ -136,13 +138,15 @@ async def test_backconnect_403_exhausted_raises_blocked( @pytest.mark.asyncio -async def test_non_backconnect_with_rotate_url_no_reconnect_path( +async def test_reconnect_fires_even_when_rotate_url_set( monkeypatch: pytest.MonkeyPatch, ) -> None: - """rotate_url задан → backconnect=False → одиночный 403 (max_rotations=0) raise сразу. + """Прод-конфиг run-212 регрессия: scraper_proxy_url + leftover avito_proxy_rotate_url + + max_rotations=0 → reconnect-retry ВСЁ РАВНО срабатывает (gate только на наличие proxy). - Новая reconnect-ветка должна быть выключена когда есть changeip-URL: legacy - single-IP mobile-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" @@ -154,18 +158,63 @@ async def test_non_backconnect_with_rotate_url_no_reconnect_path( 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(return_value=r403) + 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)), + patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot, ): - with pytest.raises(AvitoBlockedError): - await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) + html = await scraper._fetch_serp_html("https://www.avito.ru/x", page=1) - reset.assert_not_awaited() # reconnect-ветка выключена при наличии rotate_url - assert mock_session.get.await_count == 1 # немедленный raise + 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