From 9dc3be56e3f98a9fcccf75cf678e4c8ba231e52d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 18 Jun 2026 20:41:32 +0300 Subject: [PATCH] fix(avito): reconnect-retry on SERP 403/firewall under backconnect proxy Under a backconnect rotating proxy (no changeip URL) a single HTTP 403 or firewall interstitial aborted the entire avito_full_load sweep via AvitoBlockedError -> mark_banned. Each new connection through backconnect gets a fresh exit IP, so retry by rebuilding the curl_cffi session up to _AVITO_403_MAX_RETRIES times before giving up. Legacy single-IP changeip rotation and browser-mode paths unchanged. Mirrors existing 429 short-retry. --- .../backend/app/services/scrapers/avito.py | 54 +++++- .../test_avito_403_backconnect_reconnect.py | 171 ++++++++++++++++++ 2 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 447a4483..52f65c22 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -255,6 +255,17 @@ _AVITO_OFFERS_PER_PAGE = 50 _AVITO_429_MAX_RETRIES = 4 _AVITO_429_BACKOFF_SEC = 1.5 +# HTTP 403 / firewall-интерстициал в curl_cffi-режиме через backconnect-прокси +# (mproxy.site, нет changeip-URL): провайдер выдаёт СВЕЖИЙ exit-IP на каждое НОВОЕ +# соединение (5 последовательных запросов → 5 разных IP). 403 значит залочен лишь +# ТЕКУЩИЙ exit-IP — открытие нового CONNECT-туннеля escape'ит бан. Keep-alive curl_cffi +# сессия переиспользует тот же туннель = тот же IP, поэтому retry на том же self._cffi +# не помогает: нужно пересоздать сессию (_reset_cffi). Делаем до _AVITO_403_MAX_RETRIES +# reconnect-попыток прежде чем свалиться в AvitoBlockedError (mark_banned). Старый +# single-IP changeip-путь и browser-режим не затрагиваются. +_AVITO_403_MAX_RETRIES = 5 +_AVITO_403_BACKOFF_SEC = 2.0 + # Счётчик результатов в шапке SERP: 1 178. # Текст может содержать NBSP (\xa0),  -entity или обычные пробелы как разделители # разрядов — чистятся в _extract_total_count. [^<]* нежадно ловит весь текст до . @@ -322,11 +333,20 @@ class AvitoScraper(BaseScraper): logger.info("avito: SERP fetch via BrowserFetcher (camoufox) — #901") return self await super().__aenter__() + self._cffi = self._build_cffi_session() + return self + + def _build_cffi_session(self) -> AsyncSession: + """Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан). + + Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый + CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP. + """ proxies = None if settings.scraper_proxy_url: proxies = {"http": settings.scraper_proxy_url, "https": settings.scraper_proxy_url} logger.info("avito: routing through mobile proxy egress (#623)") - self._cffi = AsyncSession( + return AsyncSession( impersonate="chrome120", timeout=25, proxies=proxies, @@ -341,7 +361,16 @@ class AvitoScraper(BaseScraper): "Upgrade-Insecure-Requests": "1", }, ) - return self + + async def _reset_cffi(self) -> None: + """Закрыть и пересоздать curl_cffi-сессию — новый CONNECT-туннель через + backconnect-прокси = свежий exit-IP (escape 403 на залоченном IP).""" + if self._cffi is not None: + try: + await self._cffi.close() + except Exception: + pass + self._cffi = self._build_cffi_session() async def __aexit__(self, *args: Any) -> None: if self._browser is not None: @@ -408,9 +437,13 @@ 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 rot_done = 0 transient_left = 2 r429 = 0 + r403 = 0 while True: try: response = await self._cffi.get(url) @@ -454,6 +487,23 @@ class AvitoScraper(BaseScraper): rot_done, ) continue + # backconnect: 403/firewall = только текущий exit-IP залочен. Новый + # CONNECT-туннель (пересоздание сессии) = свежий IP → reconnect-retry. + # Только для 403/firewall (НЕ 429 — у него свой short-retry выше). + if backconnect and (sc == 403 or is_firewall) and r403 < _AVITO_403_MAX_RETRIES: + r403 += 1 + logger.info( + "avito page=%d blocked (HTTP %d, firewall=%s) — " + "backconnect reconnect retry %d/%d", + page, + sc, + is_firewall, + r403, + _AVITO_403_MAX_RETRIES, + ) + await self._reset_cffi() + await asyncio.sleep(_AVITO_403_BACKOFF_SEC) + continue if sc == 429: logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url) raise AvitoRateLimitedError( 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 new file mode 100644 index 00000000..47495462 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_403_backconnect_reconnect.py @@ -0,0 +1,171 @@ +"""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 раз. + +backconnect = scraper_proxy_url задан И avito_proxy_rotate_url НЕ задан. Включаем через +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_non_backconnect_with_rotate_url_no_reconnect_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """rotate_url задан → backconnect=False → одиночный 403 (max_rotations=0) raise сразу. + + Новая reconnect-ветка должна быть выключена когда есть changeip-URL: legacy + single-IP mobile-proxy путь без изменений. + """ + 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 = "" + 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), + patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)), + ): + with pytest.raises(AvitoBlockedError): + 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