From 62980396db548f41ac89ef281ab116d64b3616ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 17:24:21 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20=D0=B1=D0=B0=D0=BD-=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=89=D0=B0=D0=B4=D0=BA=D0=B8=20=D1=83=D0=B5=D0=B7=D0=B6=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B2=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=BD=D1=8B=D0=B5=20=D1=81=D0=B1=D0=BE=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=BD=D0=B5=20=D0=B1=D0=B0=D0=BD=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=20=D1=83=D0=B7=D0=B5=D0=BB=20(#3283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сайдкар распознаёт бан-страницу Авито («доступ ограничен: проблема с IP») по маркерам тела и отдаёт 500 с ban_page=true; клиент поднимает SidecarBanPageError. У Домклика её разбирают (#3239/#3283), у Авито — нет: она попадала в общий `except Exception` и заворачивалась в AvitoSidecarUnavailableError, то есть в ban_kind='infra'. Следствие — не ярлык, а неработающая защита: report_ban не вызывался, узел не попадал в scrape_proxy_source_bans и продолжал выдаваться в аренду для source=avito. Прод, прогон 5606 (31.08): 64 бан-страницы за 23 минуты, ban_kinds={"infra":26,"platform":7}, ноль записей бана для avito, прогон оборван по доле блоков 14/20. detail.py — ветка `except SidecarBanPageError` перед общим `except Exception` (порядок важен: это подкласс httpx.HTTPStatusError). serp.py — та же проверка ДО ротации и transient-бюджета: ротация в этой ветке мертва (max_rot=0), поэтому ретраи маркерного бана ушли бы на тот же забаненный адрес — ещё два захода на площадку и ~4-5 с задержки перед report_ban. Бюджет задуман под блипы сайдкара (503 «browser unavailable»), а не под отказ площадки. Обычная 500 без ban_page по-прежнему остаётся транспортным сбоем и report_ban не вызывает — закреплено тестами в обе стороны. Тесты: 5257 passed, 37 skipped. --- ..._3283_avito_sidecar_ban_is_platform_ban.py | 145 ++++++++++++++++++ .../src/scraper_kit/providers/avito/detail.py | 15 ++ .../src/scraper_kit/providers/avito/serp.py | 30 +++- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_3283_avito_sidecar_ban_is_platform_ban.py 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 -- 2.45.3