From a5f60eaaa83b50188b5d7295834b35ff235f38ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 02:38:29 +0500 Subject: [PATCH] =?UTF-8?q?test(#3288):=20=D1=84=D0=B8=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D1=83=D1=80=D1=8B=20=D0=B1=D0=B0=D0=BD-=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B0=20403=20=D0=B8=20?= =?UTF-8?q?500,=20=D1=84=D0=B0=D0=BB=D1=8C=D1=81=D0=B8=D1=84=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=B0;=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._3283_avito_sidecar_ban_is_platform_ban.py | 74 ++++++++++++++----- .../tests/test_3288_avito_ban_per_source.py | 43 ++++++++--- .../tests/test_kit_browser_fetcher_status.py | 21 ++++-- .../src/scraper_kit/browser_fetcher.py | 19 +++-- 4 files changed, 113 insertions(+), 44 deletions(-) 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 index 7f32bf37..13c9971f 100644 --- 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 @@ -1,7 +1,8 @@ """#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от -сайдкара как SidecarBanPageError (HTTP 500 + ban_page-маркер в теле, #3239), но -у Авито её никто не читал — она заворачивалась в AvitoSidecarUnavailableError и -уезжала в транспортные сбои ('infra'), а не в отказ площадки ('platform'). +сайдкара как SidecarBanPageError (ban_page-маркер в теле, #3239; код ответа — 403 +с #3288 п.4, 500 у сайдкара старой сборки), но у Авито её никто не читал — она +заворачивалась в AvitoSidecarUnavailableError и уезжала в транспортные сбои +('infra'), а не в отказ площадки ('platform'). Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans — узел не банился и @@ -26,7 +27,7 @@ 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.browser_fetcher import SidecarBanPageError, _raise_for_sidecar_status 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 @@ -36,16 +37,40 @@ 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, +_SIDECAR_BAN_CODES = [403, 500] + + +def _sidecar_ban_page_error( + sidecar_status: int = 403, *, upstream_status: int | None = 403 +) -> SidecarBanPageError: + """Ровно то, что клиент поднимает на подтверждённой бан-странице («доступ + ограничен: проблема с IP») — зеркало _sidecar_ban_page_error из test_domclick_detail.py. + + Исключение строим НАСТОЯЩИМ `_raise_for_sidecar_status` из ответа сайдкара, а не + литералом: текст менялся (#3288 п.4 убрал httpx-преамбулу, и захардкоженная строка + «Server error '500' | tradein-browser: …» пережила фикс, которого уже нет в коде). + + `sidecar_status` — код ОТВЕТА САЙДКАРА: 403 с #3288 п.4, 500 у сайдкара старой + сборки (`tradein-browser` — отдельный образ со своим шагом деплоя, версии штатно + разъезжаются на часы). Признак бана — ТЕЛО, поэтому оба кода обязаны дать один и + тот же тип; прогон по `_SIDECAR_BAN_CODES` это и проверяет. + `upstream_status` — код, который площадка отдала браузеру. + """ + response = httpx.Response( + sidecar_status, + json={ + "error": "BanPageDetectedError: доступ ограничен", + "ban_page": True, + "status": upstream_status, + }, + request=httpx.Request("POST", "http://tradein-browser:3000/fetch"), + ) + try: + _raise_for_sidecar_status(response) + except SidecarBanPageError as exc: + return exc + raise AssertionError( + f"ответ сайдкара {sidecar_status} с ban_page обязан дать SidecarBanPageError" ) @@ -53,12 +78,15 @@ def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPage @pytest.mark.asyncio -async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra( + sidecar_status: int, +) -> None: """Бан-страница — ГЕНУИННЫЙ отказ площадки: AvitoBlockedError, не AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда.""" bf = MagicMock() - bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status)) with pytest.raises(AvitoBlockedError) as excinfo: await fetch_detail(_ITEM_URL, browser_fetcher=bf) @@ -67,7 +95,10 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() - @pytest.mark.asyncio -async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again( + sidecar_status: int, +) -> None: """Бан рапортует ФЕТЧЕР (`_report_platform_ban`), провайдер — уже нет (#3288). До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о @@ -78,7 +109,7 @@ async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again() -> None BrowserFetcher с фейковым пулом, а не MagicMock). """ bf = MagicMock() - bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status)) with pytest.raises(AvitoBlockedError): await fetch_detail(_ITEM_URL, browser_fetcher=bf) @@ -105,7 +136,10 @@ async def test_fetch_detail_transport_failure_stays_infra_no_report_ban() -> Non @pytest.mark.asyncio -async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying( + sidecar_status: int, +) -> None: """Бан-страница у SERP → AvitoBlockedError + report_ban, БЕЗ ретраев. Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а @@ -115,7 +149,7 @@ async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() -> """ scraper = AvitoScraper(RealScraperConfig()) scraper._browser = AsyncMock() - scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) + scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status)) scraper._browser.report_ban = MagicMock() # sync-хук, не корутина (см. test_2686) sleep_mock = AsyncMock() diff --git a/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py b/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py index ecb74cea..316c1d94 100644 --- a/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py +++ b/tradein-mvp/backend/tests/test_3288_avito_ban_per_source.py @@ -4,7 +4,8 @@ (`ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7`), при том что живая проба тех же узлов проходила — они стояли ровно на потолке `MAX_CONSECUTIVE_FAILS`. Механизм: сайдкар на бан-странице Авито («доступ ограничен: -проблема с IP») отвечает HTTP 500 с `ban_page: true`, клиент поднимает +проблема с IP») отвечает `ban_page: true` в теле (кодом 403 с #3288 п.4, кодом 500 у +сайдкара старой сборки), клиент поднимает `SidecarBanPageError` — но она подкласс `httpx.HTTPStatusError`, и общий `except Exception` в `_post_fetch` звал `_report_fetch_result(False)` → `mark_health(ok=False)`. `mark_health` — решение ГЛОБАЛЬНОЕ для пула: три бан-страницы @@ -87,13 +88,22 @@ class _FakePool: self.banned.append((lease.id, source)) -def _ban_page_response() -> httpx.Response: - """РОВНО то, что отдаёт сайдкар на распознанной бан-странице (browser/server.py:1356): - HTTP 500 + `ban_page: true` + статус целевой навигации. Ответ настоящий, а не мок, — - чтобы проба шла тем же трактом, что и прод (_raise_for_sidecar_status разбирает тело).""" +_SIDECAR_BAN_CODES = [403, 500] + + +def _ban_page_response(sidecar_status: int = 403) -> httpx.Response: + """РОВНО то, что отдаёт сайдкар на распознанной бан-странице (browser/server.py): + `ban_page: true` + статус целевой навигации. Ответ настоящий, а не мок, — чтобы + проба шла тем же трактом, что и прод (_raise_for_sidecar_status разбирает тело). + + `sidecar_status` — код ОТВЕТА САЙДКАРА, а не площадки: 403 с #3288 п.4, 500 у + сайдкара старой сборки. `tradein-browser` — отдельный образ со своим шагом + деплоя, поэтому версии сайдкара и backend штатно разъезжаются на часы; оба кода + обязаны давать один диагноз, и тесты ниже прогоняются по `_SIDECAR_BAN_CODES`. + """ request = httpx.Request("POST", "http://browser:3000/fetch") return httpx.Response( - 500, + sidecar_status, json={ "error": "BanPageDetectedError: бан-страница (проблема с IP)", "ban_page": True, @@ -151,15 +161,22 @@ def _iter_causes(exc: BaseException) -> list[BaseException]: # ── (а) бан-страница → отказ ПЛОЩАДКИ + адресный бан пары «узел×источник» ────── -async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only( + sidecar_status: int, +) -> None: """Бан-страница: AvitoBlockedError, диагноз 'platform', mark_banned(13, 'avito'). Фальсификация: до фикса SidecarBanPageError доезжала до общего except в _post_fetch → mark_health(ok=False), а строки в scrape_proxy_source_bans не появлялось вовсе (за 7 суток 0 записей для source=avito при 58 прогонах banned). + Вторая фальсификация (#3288 п.4): заведи в `_sidecar_ban_page_status` гейт + «признавать ban_page только при 500» — 403-кейс покраснеет по значению + (AvitoSidecarUnavailableError вместо бана), т.е. в час рассинхрона версий + отказ площадки снова уезжал бы в инфра-ветку. """ pool = _FakePool() - bf = await _fetcher(_ban_page_response(), pool) + bf = await _fetcher(_ban_page_response(sidecar_status), pool) with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()): with pytest.raises(AvitoBlockedError) as ei: @@ -176,14 +193,15 @@ async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only() -> # ── (б) здоровье узла: бан-страница молчит, транспортный сбой — говорит ──────── -async def test_ban_page_does_not_mark_health_failure() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_ban_page_does_not_mark_health_failure(sidecar_status: int) -> None: """Ключевое значение issue: на бан-странице mark_health НЕ вызывается вовсе. Именно этот вызов гнал узел к MAX_CONSECUTIVE_FAILS и выбивал его из выдачи ВСЕМ источникам (замер 01.09: узлы 9, 13, 14 на потолке при banned_for_source=0). """ pool = _FakePool() - bf = await _fetcher(_ban_page_response(), pool) + bf = await _fetcher(_ban_page_response(sidecar_status), pool) with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()): with pytest.raises(AvitoBlockedError): @@ -324,7 +342,8 @@ async def test_rotation_after_ban_does_not_ban_the_fresh_node() -> None: # ── (е) одна бан-страница — один POST ───────────────────────────────────────── -async def test_ban_page_costs_exactly_one_post() -> None: +@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES) +async def test_ban_page_costs_exactly_one_post(sidecar_status: int) -> None: """Ретрай `fetch()` не имеет права трогать бан-страницу: это ответ площадки. Фальсификация: убери `except SidecarBanPageError: raise` перед @@ -332,7 +351,7 @@ async def test_ban_page_costs_exactly_one_post() -> None: `_lease_fail_streak` вместо +1 (ротация вдвое раньше задуманного). """ pool = _FakePool() - bf = await _fetcher(_ban_page_response(), pool) + bf = await _fetcher(_ban_page_response(sidecar_status), pool) with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()): with pytest.raises(AvitoBlockedError): diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py index b9ee2163..328a6ffc 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py @@ -207,21 +207,28 @@ def test_ban_page_legacy_500_still_recognised() -> None: assert excinfo.value.upstream_status == 401 -def test_ban_page_message_names_platform_not_sidecar_500() -> None: +@pytest.mark.parametrize("sidecar_status", [403, 500]) +def test_ban_page_message_has_no_httpx_preamble(sidecar_status: int) -> None: """Текст ошибки называет виновника: отказ ПЛОЩАДКИ, а не падение нашего сайдкара. - До #3288 п.4 сюда уезжала httpx-преамбула «Server error '500 Internal Server - Error' for url 'http://tradein-browser:3000/fetch'» — и ровно она попадала в - лог провайдера и в imv_error_reason, отправляя разбор по ложному следу. + До #3288 п.4 текст начинался httpx-преамбулой «Server error '500 Internal Server + Error' for url 'http://tradein-browser:3000/fetch'» — и ровно она уезжала в лог + провайдера, в сообщение AvitoBlockedError/DomClickBlockedError и оттуда в + `scrape_runs.error` (mark_failed), отправляя разбор по ложному следу. + + Кусаемся за «for url», а НЕ за «500»/«Server error»: у 403-го ответа преамбула + звучит «Client error '403 Forbidden' for url …», так что оба прежних утверждения + были зелёными и на старом коде — проверка не могла упасть. «for url» httpx + печатает при ЛЮБОМ коде, поэтому она кусается на обоих. """ resp = _sidecar_response( - {"error": "BanPageDetectedError: доступ ограничен", "ban_page": True, "status": 403} + {"error": "BanPageDetectedError: доступ ограничен", "ban_page": True, "status": 403}, + status=sidecar_status, ) with pytest.raises(SidecarBanPageError) as excinfo: _raise_for_sidecar_status(resp) message = str(excinfo.value) - assert "500" not in message - assert "Server error" not in message + assert "for url" not in message, f"httpx-преамбула вернулась в текст: {message}" assert "бан-страницу" in message assert "upstream 403" in message diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 280a5d4f..ae4b653d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -217,11 +217,20 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None: # #3288 п.4: текст СВОЙ, без httpx-преамбулы `{exc}`. Она печатала # «Server error '500 Internal Server Error' for url # 'http://tradein-browser:3000/fetch'», и этой строкой отказ ПЛОЩАДКИ - # уезжал в лог провайдера и в imv_error_reason как падение НАШЕГО - # сайдкара — разбор инцидента каждый раз начинался с ложного следа - # («текст ошибки называет гонца»). Код ответа сайдкара оставляем в - # тексте отдельным числом: он отличает 403 нового сайдкара от 500 - # старого, но больше ничего не значит. + # читался как падение НАШЕГО сайдкара — разбор инцидента каждый раз + # начинался с ложного следа («текст ошибки называет гонца»). + # Куда текст реально доезжает: провайдер подставляет его в свой лог и + # в сообщение доменного исключения (providers/avito/serp.py — logger + # «avito page=%d sidecar ban page» + AvitoBlockedError, + # providers/domclick/detail.py — DomClickBlockedError «...: {exc}»), + # а задача кладёт `str(exc)[:1000]` в `scrape_runs.error` через + # `mark_failed` (например domclick_detail_backfill.py). В + # `houses.imv_error_reason` он НЕ попадает: IMV ходит только через + # `/fetch-json`, а `fetch_json_handler` (browser/server.py) `ban_page` + # в тело не пишет — SidecarBanPageError там не возникает вовсе. + # Код ответа сайдкара оставляем в тексте отдельным числом: он + # отличает 403 нового сайдкара от 500 старого, но больше ничего не + # значит. raise SidecarBanPageError( f"tradein-browser: площадка отдала бан-страницу " f"(upstream {upstream_status if upstream_status is not None else '?'}, "