diff --git a/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py b/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py new file mode 100644 index 00000000..71f3441b --- /dev/null +++ b/tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py @@ -0,0 +1,145 @@ +"""#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от +сайдкара как SidecarBanPageError (HTTP 500 + ban_page-маркер в теле, #3239), но +у Авито её никто не читал — она заворачивалась в AvitoSidecarUnavailableError и +уезжала в транспортные сбои ('infra'), а не в отказ площадки ('platform'). + +Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при +этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans — узел не банился и +продолжал выдаваться в аренду. Ровно эта проблема уже решена для DomClick (#3239, +providers/domclick/detail.py) — этот файл переносит фикс на Avito, тем же стилем: +providers/avito/detail.py (browser-режим fetch_detail) и providers/avito/serp.py +(исчерпание transient-бюджета сайдкар-ошибок в _fetch_serp_html_browser). + +Тесты держат ОБЕ ветки раздельно (зеркало test_domclick_detail.py и test_2686): +подтверждённая бан-страница банит узел (report_ban + AvitoBlockedError), голый +транспортный сбой (без ban_page-маркера) остаётся инфра-диагнозом и НЕ банит. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError +from scraper_kit.browser_fetcher import SidecarBanPageError +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 + +_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890" + + +def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPageError: + """Ровно то, что сайдкар шлёт на подтверждённую бан-страницу («доступ ограничен: + проблема с IP») — зеркало _sidecar_ban_page_error из test_domclick_detail.py.""" + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response(500, request=request) + return SidecarBanPageError( + "Server error '500' | tradein-browser: BanPageDetectedError: доступ ограничен", + request=request, + response=response, + upstream_status=upstream_status, + ) + + +# ── providers/avito/detail.py: fetch_detail (browser-режим) ───────────────────── + + +@pytest.mark.asyncio +async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -> None: + """Бан-страница — ГЕНУИННЫЙ отказ площадки: AvitoBlockedError, не + AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала + в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда.""" + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + + with pytest.raises(AvitoBlockedError) as excinfo: + await fetch_detail(_ITEM_URL, browser_fetcher=bf) + + assert not isinstance(excinfo.value, AvitoSidecarUnavailableError) + + +@pytest.mark.asyncio +async def test_fetch_detail_sidecar_ban_page_reports_ban() -> None: + """Подтверждённый маркер-бан обязан попасть в scrape_proxy_source_bans через + report_ban — иначе узел не ротируется и продолжает выдаваться в аренду.""" + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + + with pytest.raises(AvitoBlockedError): + await fetch_detail(_ITEM_URL, browser_fetcher=bf) + + bf.report_ban.assert_called_once() + assert _ITEM_URL in bf.report_ban.call_args.args[0] + + +@pytest.mark.asyncio +async def test_fetch_detail_transport_failure_stays_infra_no_report_ban() -> None: + """Голый сбой транспорта (без ban_page-маркера) — НЕ подтверждённый бан. Остаётся + AvitoSidecarUnavailableError ('infra'), report_ban НЕ вызывается (#2600 п.4 — не + смешивать «бан» и «сетевой сбой»). Регрессия: правка не должна раздуть диагноз + 'platform' на всё подряд.""" + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway")) + + with pytest.raises(AvitoSidecarUnavailableError): + await fetch_detail(_ITEM_URL, browser_fetcher=bf) + + bf.report_ban.assert_not_called() + + +# ── providers/avito/serp.py: _fetch_serp_html_browser (транзиент-бюджет) ──────── + + +@pytest.mark.asyncio +async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() -> None: + """Бан-страница у SERP → AvitoBlockedError + report_ban, БЕЗ ретраев. + + Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а + ротация в этой ветке мертва (max_rot=0) — значит ретраи ушли бы на ТОТ ЖЕ + забаненный адрес. Поэтому проверяем не только диагноз, но и что к сайдкару + сходили ровно один раз и ни разу не ушли в backoff. + """ + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + scraper._browser.report_ban = MagicMock() # sync-хук, не корутина (см. test_2686) + + sleep_mock = AsyncMock() + with patch.object(serp_module.asyncio, "sleep", sleep_mock): + with pytest.raises(AvitoBlockedError) as excinfo: + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + assert not isinstance(excinfo.value, AvitoSidecarUnavailableError) + scraper._browser.report_ban.assert_called_once() + assert scraper._browser.fetch.await_count == 1, "бан-страницу ретраить нечем" + sleep_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_serp_plain_sidecar_failure_stays_infra_no_report_ban() -> None: + """Регрессия: обычный 503 сайдкара (без ban_page-маркера) остаётся + AvitoSidecarUnavailableError, report_ban НЕ вызывается.""" + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response( + 503, json={"error": "browser unavailable (proxy may be down)"}, request=request + ) + exc = httpx.HTTPStatusError("503", request=request, response=response) + + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = AsyncMock() + scraper._browser.fetch = AsyncMock(side_effect=exc) + scraper._browser.report_ban = MagicMock() + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoSidecarUnavailableError): + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + scraper._browser.report_ban.assert_not_called() 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 6877d064..a34a1835 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 @@ -65,6 +65,7 @@ from scraper_kit.avito_exceptions import ( AvitoSidecarUnavailableError, AvitoWarmupCookiesMissingError, ) +from scraper_kit.browser_fetcher import SidecarBanPageError from scraper_kit.ceiling_height import plausible_ceiling_m from scraper_kit.geo import is_within_oblast66_bbox from scraper_kit.providers._base import build_document_session, referer_headers @@ -572,6 +573,20 @@ async def fetch_detail( html = await browser_fetcher.fetch(full_url, origin=origin, referer=browser_referer) except (AvitoBlockedError, AvitoRateLimitedError): raise + except SidecarBanPageError as exc: + # #3283: сайдкар опознал бан-страницу площадки по маркерам тела + # (см. SidecarBanPageError.__doc__, browser_fetcher.py) — это ГЕНУИННЫЙ + # отказ Avito, а не сбой нашего тракта. ПОРЯДОК ВЕТОК ВАЖЕН: SidecarBanPageError + # — подкласс httpx.HTTPStatusError, и если бы её ловил `except Exception` + # ниже, она заворачивалась бы в AvitoSidecarUnavailableError ('infra') — что и + # происходило до этого фикса (прогон 5606: 64 бан-страницы, ban_kinds= + # {"infra":26,"platform":7}, при этом ни одной записи для source=avito в + # scrape_proxy_source_bans — узел продолжал выдаваться в аренду). Зеркалит + # DomClick (#3239/#3283, providers/domclick/detail.py). + browser_fetcher.report_ban(f"avito detail: sidecar ban page for {full_url}") + raise AvitoBlockedError( + f"Avito detail: sidecar detected platform refusal for {full_url}: {exc}" + ) from exc except Exception as exc: # Сайдкар не отдал страницу: HTTPStatusError (503 «browser unavailable», # 502), ConnectError/ReadTimeout до сервиса, NoProxyAvailableError пустого 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 bf017691..e22d1e94 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 @@ -46,7 +46,7 @@ from scraper_kit.avito_exceptions import ( AvitoSidecarUnavailableError, ) from scraper_kit.base import BaseScraper, ScrapedLot -from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.browser_fetcher import BrowserFetcher, SidecarBanPageError from scraper_kit.price_brackets import get_price_seed_brackets from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range from scraper_kit.providers._base import build_browser_fetcher, build_document_session @@ -583,6 +583,34 @@ class AvitoScraper(BaseScraper): error_text = f"{type(exc).__name__}: {exc}" lowered = error_text.lower() + if isinstance(exc, SidecarBanPageError): + # #3283: сайдкар опознал бан-страницу площадки по МАРКЕРАМ ТЕЛА + # (SidecarBanPageError.__doc__) — это положительный детект, а не + # догадка по коду ответа, и переспрашивать его нечем. Выходим ДО + # ротации и ДО transient-бюджета: ротация тут мертва (max_rot=0, + # см. ниже), поэтому оба ретрая ушли бы на ТОТ ЖЕ забаненный адрес + # — гарантированно ещё два захода на Авито с него же и ~4-5с + # задержки перед report_ban. Сам бюджет задуман под блипы сайдкара + # (503 «browser unavailable»), а не под отказ площадки. + # + # До этого фикса ветка отсутствовала вовсе: бан уезжал ниже в + # AvitoSidecarUnavailableError ('infra'), report_ban не звался, узел + # не попадал в scrape_proxy_source_bans и продолжал выдаваться в + # аренду при source=avito (прод, прогон 5606: 64 бан-страницы, + # ban_kinds={"infra":26,"platform":7}, ноль записей бана). + # Зеркалит detail.py и DomClick (#3239/#3283). + logger.error( + "avito page=%d sidecar ban page url=%s: %s", + page, + url, + error_text[:200], + ) + self._report_ban(f"avito SERP sidecar ban page (page={page})") + raise AvitoBlockedError( + f"avito SERP sidecar detected platform refusal (page={page}): " + f"{error_text[:200]}" + ) from exc + is_soft_ban = ( "too_many_requests" in lowered or status == 503