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 = ( + "
" + '