fix(tradein/avito): бан-страница площадки уезжала в транспортные сбои и не банила узел (#3283) #3304

Merged
bot-backend merged 1 commit from fix/3283c-avito-sidecar-ban-is-platform-ban into main 2026-08-31 14:53:18 +00:00
3 changed files with 189 additions and 1 deletions

View file

@ -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()

View file

@ -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 пустого

View file

@ -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