fix(tradein/avito): бан-страница площадки уезжала в транспортные сбои и не банила узел (#3283) (#3304)
Some checks are pending
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Some checks are pending
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
9219ab6507
commit
72cb410b05
3 changed files with 189 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
@ -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 пустого
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue