From 3b6bca80c01226de93ebec49844ae68bb301e8cd Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 23 Aug 2026 21:49:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(avito):=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20HTTP=20439=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=BE=D0=B9,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=86=D0=BE=D0=BC=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=20(#3065)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...test_3044_avito_439_classified_as_block.py | 84 +++++++++++++++++++ .../src/scraper_kit/providers/avito/detail.py | 8 +- .../src/scraper_kit/providers/avito/serp.py | 16 +++- 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py diff --git a/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py b/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py new file mode 100644 index 00000000..ddbf2e1f --- /dev/null +++ b/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py @@ -0,0 +1,84 @@ +"""#3044: HTTP 439 (QRATOR-челлендж Avito) считается блокировкой, а не концом выдачи. + +439 — нестандартный код, которым QRATOR отвечает на странице-челлендже. Ни в одном +RFC его нет, поэтому он не попал ни в один из списков «это блок» — и обе точки входа +трактовали его как штатный исход: + + * SERP — проваливался в `if sc != 200: return None`, то есть выглядел как «страница + закончилась»: пагинация мирно останавливалась, ротации IP не было, `_report_ban` + не звался, прогон не помечался banned. + * detail — доходил до `if sc != 200: raise ValueError`, то есть считался ошибкой + разбора страницы: без реконнекта на свежий exit-IP и без ban-сигнала. + +Обе ветки молчали ровно там, где нас блокировали — отсюда прогоны с диагнозом +«причина НЕ установлена» (#2700) при том, что у отказа был чёткий HTTP-код. + +Фальсификация: до правки первый тест возвращает None вместо исключения, второй +поднимает ValueError вместо AvitoBlockedError. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.avito_exceptions import AvitoBlockedError +from scraper_kit.providers.avito import detail as detail_module +from scraper_kit.providers.avito import serp as serp_module +from scraper_kit.providers.avito.detail import fetch_detail +from scraper_kit.providers.avito.serp import AvitoScraper + +from app.services.scraper_adapters import RealScraperConfig + + +def _resp(status: int, text: str = "QRATOR challenge") -> MagicMock: + r = MagicMock() + r.status_code = status + r.text = text + return r + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [403, 439]) +async def test_serp_439_raises_block_like_403(status: int) -> None: + """SERP: 439 поднимает AvitoBlockedError ровно как 403, а не «конец выдачи». + + Параметризация не для полноты, а для доказательства ПАРИТЕТА: 403 — заведомо + рабочая ветка, и 439 обязан вести себя ровно так же. Если однажды кто-то + перепишет блок-логику и уронит только 439 — упадёт лишь половина кейсов, и это + сразу покажет, что ветки разъехались. + """ + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = None + scraper._cffi = AsyncMock() + scraper._cffi.get = AsyncMock(return_value=_resp(status)) + scraper._rotate_ip = AsyncMock(return_value=False) # ротация недоступна -> сразу raise + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoBlockedError): + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [403, 439]) +async def test_detail_439_raises_block_like_403(status: int) -> None: + """detail: 439 поднимает AvitoBlockedError, а не ValueError «не смог распарсить». + + config=None -> backconnect=False, поэтому reconnect-ветка не задействуется и + исключение поднимается на первом же ответе — проверяем именно классификацию, + не механику ретраев. + """ + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(status)) + + with patch.object(detail_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoBlockedError) as excinfo: + await fetch_detail("/ekaterinburg/kvartiry/1_k_kvartira_1234567", cffi_session=session) + + # Текст ошибки несёт фактический код: до правки он был жёстко зашит как "403" + # и на 439 врал бы в логах ровно там, где по логу и ставят диагноз. + assert str(status) in str(excinfo.value) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 7d2cc2ed..73e7db6a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -106,6 +106,10 @@ _AVITO_WARM_YANDEX_REFERER = "https://ya.ru/" # avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429, # abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё # _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP). +# HTTP 439 — нестандартный код QRATOR'а на странице-челлендже; обрабатывается по +# 403-пути (реконнект = свежий exit-IP). До этого 439 не попадал ни в одну блок- +# ветку и доходил до `if sc != 200: raise ValueError` — то есть считался обычной +# ошибкой парсинга, без _report_ban и без реконнекта на свежий IP. _AVITO_DETAIL_403_MAX_RETRIES = 5 _AVITO_DETAIL_403_BACKOFF_SEC = 2.0 _AVITO_DETAIL_429_MAX_RETRIES = 8 @@ -583,7 +587,7 @@ async def fetch_detail( await asyncio.sleep(_AVITO_DETAIL_429_BACKOFF_SEC) continue - if sc == 403 or is_firewall: + if sc in (403, 439) or is_firewall: if backconnect and r403 < _AVITO_DETAIL_403_MAX_RETRIES: r403 += 1 # Эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP). @@ -604,7 +608,7 @@ async def fetch_detail( ) await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC) continue - raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}") + raise AvitoBlockedError(f"Avito detail HTTP {sc} for {full_url}") if sc == 429: # short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index a5d42e36..5df6dbf8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -322,6 +322,14 @@ _AVITO_429_RECONNECT_RETRIES = 3 _AVITO_403_MAX_RETRIES = 5 _AVITO_403_BACKOFF_SEC = 2.0 +# HTTP 439 — нестандартный код QRATOR'а на странице-челлендже Avito. Классифицируем +# ровно как 403: тот же exit-IP признан подозрительным, помогает смена IP, а не +# повтор на той же сессии. Держим отдельным комментарием, а не в списке "403/429", +# потому что 439 нет ни в одном RFC — без пояснения он читается как опечатка. +# ДО этого 439 не попадал ни в одну блок-ветку и проваливался в `if sc != 200: +# return None` ниже, то есть тихо трактовался как штатный конец пагинации: без +# ретрая, без ротации IP, без _report_ban и без пометки прогона banned. + # tradein-browser sidecar (POST /fetch) отдаёт HTTP 500 на ЛЮБУЮ внутреннюю # ошибку страницы (тело {"error": "ErrorType: message"}) и HTTP 503 когда браузер # не поднимается (прокси упал). Транзиентные сбои (Page.goto Timeout, Target @@ -729,7 +737,7 @@ class AvitoScraper(BaseScraper): jitter = (r429 % 3) * 0.1 await asyncio.sleep(_AVITO_429_BACKOFF_SEC + jitter) continue - if sc in (403, 429) or is_firewall: + if sc in (403, 429, 439) or is_firewall: if rot_done < max_rot and await self._rotate_ip(): rot_done += 1 logger.info( @@ -743,7 +751,11 @@ class AvitoScraper(BaseScraper): # 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: + if ( + backconnect + and (sc in (403, 439) or is_firewall) + and r403 < _AVITO_403_MAX_RETRIES + ): r403 += 1 logger.info( "avito page=%d blocked (HTTP %d, firewall=%s) — "