From 0de22f4bc9828ecd866a85ae9286a991eea089dd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 23:17:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/scraper):=20=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=B3=D0=BD=D0=BE=D0=B7=20=D0=B1=D0=B0=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E?= =?UTF-8?q?=20(#2764)=20(#2765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 8 +- .../backend/app/services/scrape_runs.py | 36 +++- .../app/tasks/avito_detail_backfill.py | 14 +- .../app/tasks/domclick_detail_backfill.py | 6 + .../sql/234_scrape_runs_ban_kind_unknown.sql | 55 +++++ .../backend/data/sql/_manifest_applied.txt | 1 + tradein-mvp/backend/tests/skip_allowlist.txt | 5 + .../tests/tasks/test_avito_detail_backfill.py | 44 ++++ .../backend/tests/test_2686_ban_kind_split.py | 21 +- .../tests/test_2764_ban_kind_no_default.py | 204 ++++++++++++++++++ .../tests/test_backfill_honest_status.py | 4 +- .../tests/test_scraper_kit_pipeline_parity.py | 2 +- .../test_scraper_kit_pipeline_parity2.py | 2 +- .../src/scraper_kit/orchestration/pipeline.py | 19 +- .../src/scraper_kit/orchestration/runs.py | 17 +- .../src/scraper_kit/providers/avito/detail.py | 23 +- 16 files changed, 425 insertions(+), 36 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql create mode 100644 tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 9a9056a6..82c0467a 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2239,9 +2239,11 @@ class UnifiedScrapeRunRow(BaseModel): # не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а # has_running_run перестал бы держать single-run guard. cancellable: bool = False - # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или - # 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только - # «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов. + # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала), + # 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не + # установлена; раньше такие прогоны молча получали 'platform'). Без него + # оператор видит только «забанен» и делает вывод «площадка нас палит» на 80% + # наших же отказов. ban_kind: str | None = None params: dict | None = None counters: dict | None = None diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index dd7a56cf..c83f178e 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -33,7 +33,7 @@ from __future__ import annotations import json import logging -from collections.abc import Callable, Mapping +from collections.abc import Callable, Collection, Mapping from functools import cache from typing import Any @@ -116,6 +116,12 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: # частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние. BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее +# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform': +# на проде оба прогона, помеченных после миграции 218, получили 'platform' по +# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не +# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а +# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения. +BAN_KIND_UNKNOWN = "unknown" def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: @@ -515,9 +521,9 @@ def mark_banned( error: str, counters: dict[str, int], *, - ban_kind: str = BAN_KIND_PLATFORM, + ban_kind: str = BAN_KIND_UNKNOWN, ) -> None: - """Финализация run: status='banned' + диагноз ban_kind (#2686). + """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764). Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим @@ -526,10 +532,11 @@ def mark_banned( `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); - - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси). + - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси); + - BAN_KIND_UNKNOWN (дефолт) — причина не установлена. Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора - текста ошибки. Default 'platform' = историческая семантика статуса, поэтому - вызывающие, которым разводить нечего, не меняются. + текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому + разводить нечего, ничего и не знает — а не «знает, что виновата площадка». Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом. @@ -579,6 +586,7 @@ def mark_backfill_finished( source: str, aborted_by_blocks: bool = False, fail_hint: str | None = None, + ban_kinds: Collection[str] = (), ) -> None: """Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'. @@ -609,6 +617,13 @@ def mark_backfill_finished( которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании контейнера, то есть на первом же деплое после ночного прогона. + + `ban_kinds` — диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача + поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами, + в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и + пишется; разошлись (или их типы ничего не доказывают) → 'unknown'. Смешанный + прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что + попалась последней — какая из них оборвала прогон, мы не знаем (#2764). """ attempted = int(counters.get("attempted") or 0) enriched = int(counters.get("enriched") or 0) @@ -626,7 +641,14 @@ def mark_backfill_finished( f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)" ) logger.error("%s run_id=%d", reason, run_id) - mark_banned(db, run_id, reason, counters) + kinds = set(ban_kinds) + mark_banned( + db, + run_id, + reason, + counters, + ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN, + ) return if produced == 0: diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index fe4ed9c0..d206d869 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -20,6 +20,12 @@ rotate IP on every block, abort after max_consecutive_blocks. Статус об Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт только в логах контейнера, а те исчезают на первом же деплое. + +ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения +(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон +3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ — финализатор +диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ +сайдкара в AvitoBlockedError, так что передавать было бы нечего. """ from __future__ import annotations @@ -40,7 +46,7 @@ from scraper_kit.avito_exceptions import ( AvitoRateLimitedError, ) from scraper_kit.browser_fetcher import BrowserFetcher -from scraper_kit.orchestration.pipeline import CITY_LOCATIONS +from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. @@ -365,6 +371,10 @@ async def run_avito_detail_backfill( # Перепись причин (блоки + отказы) — переживает пересоздание контейнера, # в отличие от логов; см. _failure_signature. failure_census: Counter[str] = Counter() + # #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один — + # он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает + # mark_backfill_finished, один узел на все три backfill'а). + block_ban_kinds: set[str] = set() for idx, row in enumerate(snapshot): # Budget guard @@ -541,6 +551,7 @@ async def run_avito_detail_backfill( consecutive_blocks += 1 counters.blocked += 1 failure_census[_failure_signature(e)] += 1 + block_ban_kinds.add(ban_kind_of_exception(e)) do_sleep = False logger.warning( "avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", @@ -657,6 +668,7 @@ async def run_avito_detail_backfill( source="avito_detail_backfill", aborted_by_blocks=aborted_by_blocks, fail_hint=_top_failure(failure_census), + ban_kinds=block_ban_kinds, ) logger.info( "avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 24d63565..0e304651 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -43,6 +43,12 @@ Exception triad differs from Avito: No IP-rotation/cooldown recovery step exists here (DomClick uses one dedicated residential proxy, not a rotating pool) -- an aborted run simply retries the remaining backlog next window. + ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и + получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на + распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch + (наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два + случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у + avito), любой диагноз отсюда был бы назначенным, а не установленным. - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a block) -- counted as failed++, logged, does NOT touch consecutive_blocks and does NOT abort the run (neutral to the block-breaker, mirrors how Avito's diff --git a/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql new file mode 100644 index 00000000..ecaead7e --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql @@ -0,0 +1,55 @@ +-- 234_scrape_runs_ban_kind_unknown.sql +-- scrape_runs.ban_kind получает третье значение 'unknown' — «причина не установлена» (#2764). +-- +-- Dependencies: 218_scrape_runs_ban_kind.sql (создала колонку и CHECK по ban_kind). +-- Apply after: 233_payments.sql +-- Идемпотентно: DROP/ADD CONSTRAINT + UPDATE, который после первого прогона +-- никого не находит (условие ban_kind='platform' перестаёт выполняться). +-- +-- ── ЗАЧЕМ ──────────────────────────────────────────────────────────────────── +-- 218 дала статусу 'banned' диагноз, но дефолт в сигнатуре mark_banned остался +-- 'platform', а передавали его 3 вызывающих из 13. Замер на проде 2026-08-07: +-- SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1; +-- infra | 92 ← ретро-классификация 218 по тексту (разовая, осмысленная) +-- platform | 41 ← в т.ч. 2 строки, написанные УЖЕ в рантайме, по умолчанию +-- Обе рантайм-строки пришли из общего финализатора backfill-задач +-- (scrape_runs.mark_backfill_finished, #2674), который диагноз не передаёт вовсе: +-- 3306 avito_detail_backfill 2026-08-06 12:42 blocked=5, 0 обогащено из 5 +-- 3313 domclick_detail_backfill 2026-08-06 15:20 blocked=3, 0 обогащено из 3 +-- Причина отказов ни у одной из них не устанавливалась. Метка 'platform' у них — +-- не вывод, а значение по умолчанию, и выглядит она ровно как доказанные 10 +-- строк «Avito SERP firewall». Оставить их — значит сохранить ложное +-- доказательство ровно того класса, ради которого 218 и делалась. +-- +-- ── ГРАНИЦА ПРАВКИ ─────────────────────────────────────────────────────────── +-- Ретро-классификацию 218 (по тексту ошибки, применена 2026-08-06 08:24:19 UTC) +-- НЕ трогаем: разбор текста был там законным — типов исключений у исторических +-- строк уже нет, а маркер сайдкара в тексте есть. Трогаем только строки, которые +-- та миграция УЖЕ НЕ ВИДЕЛА (finished_at позже её применения) и которые пришли из +-- финализатора backfill'ов — единственного вызывающего, который на тот момент +-- писал ban_kind по умолчанию. Условие по error здесь опознаёт ВЫЗЫВАЮЩЕГО +-- (префикс, который он сам ставит), а не причину отказа: диагноз из текста не +-- выводится ни здесь, ни тем более в рантайме. +-- +-- Верхней границы по времени нет намеренно: миграции применяются деплоем ДО +-- рестарта приложения, но если между этим и новым кодом финализатор успеет +-- пометить ещё строки — они тоже написаны дефолтом и тоже подлежат переводу. + +ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check; +ALTER TABLE scrape_runs + ADD CONSTRAINT scrape_runs_ban_kind_check + CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra', 'unknown')); + +COMMENT ON COLUMN scrape_runs.ban_kind IS + 'Диагноз status=''banned'' (#2686, #2764): platform — площадка заблокировала ' + '(firewall/403/captcha, доказано типом исключения); infra — не отдала НАША ' + 'инфраструктура (браузерный сайдкар/прокси); unknown — причина НЕ установлена ' + '(дефолт mark_banned). NULL для прогонов с другим статусом. Пишется из типа ' + 'исключения в момент отказа, не из текста ошибки.'; + +UPDATE scrape_runs +SET ban_kind = 'unknown' +WHERE status = 'banned' + AND ban_kind = 'platform' + AND finished_at > TIMESTAMPTZ '2026-08-06 08:24:19.102335+00' -- 218 applied_at + AND error LIKE 'backfill-honest-status:%'; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 52a76097..bbb3f066 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -240,3 +240,4 @@ 222_db_audit_cleanup.sql 225_listing_source_snapshots_run_id_idx.sql 233_payments.sql +234_scrape_runs_ban_kind_unknown.sql diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 02f7d075..91ebbc03 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -46,3 +46,8 @@ tests/test_data_erasure.py::test_real_erase_by_phone_does_not_match_different_nu tests/test_data_erasure.py::test_real_erase_by_phone_finds_differently_formatted_number tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymous_expired_estimates + +# Диагноз оборванного прогона (#2764) — тот же `_live_session()`. Проверяет, что +# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234: +# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. +tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 4bc663d8..333da637 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -4,6 +4,7 @@ import asyncio import fnmatch import os import sys +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -12,6 +13,10 @@ _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 +from scraper_kit.avito_exceptions import ( # noqa: E402 + AvitoBlockedError, + AvitoSidecarUnavailableError, +) from app.core import shutdown as _sd # noqa: E402 from app.tasks.avito_detail_backfill import ( # noqa: E402 @@ -192,6 +197,45 @@ async def test_backfill_build_warmed_session_receives_config() -> None: runs.mark_failed.assert_not_called() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("exc_factory", "expected_kinds"), + [ + (lambda: AvitoBlockedError("ip blocked"), {"platform"}), + (lambda: AvitoSidecarUnavailableError("browser unavailable"), {"infra"}), + ], +) +async def test_backfill_reports_ban_kind_of_the_blocks_it_saw( + exc_factory: Any, expected_kinds: set[str] +) -> None: + """Диагноз блоков доезжает до финализатора по ТИПУ исключения (#2764). + + Фальсификация: до правки задача не передавала ничего, и обе серии — отказ + площадки и отказ нашего сайдкара — давали в scrape_runs.ban_kind одинаковое + 'platform' по умолчанию (прод, прогон 3306: blocked=5, причина не установлена). + """ + snapshot = _make_snapshot(10) + db = _mock_db(snapshot) + runs = MagicMock() + mock_scraper = MagicMock() + mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True) + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION, return_value=AsyncMock()), + patch(_SCRAPER, mock_scraper), + patch(_RUNS, runs), + patch(_FETCH, AsyncMock(side_effect=exc_factory())), + patch(_SLEEP, new_callable=AsyncMock), + ): + await run_avito_detail_backfill( + db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5} + ) + + runs.mark_backfill_finished.assert_called_once() + assert set(runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]) == expected_kinds + + @pytest.mark.asyncio async def test_backfill_blocked_abort_after_max_consecutive() -> None: """5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674). diff --git a/tradein-mvp/backend/tests/test_2686_ban_kind_split.py b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py index 1b4e1121..53492581 100644 --- a/tradein-mvp/backend/tests/test_2686_ban_kind_split.py +++ b/tradein-mvp/backend/tests/test_2686_ban_kind_split.py @@ -101,11 +101,15 @@ async def test_platform_firewall_is_not_our_infra() -> None: (AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA), (AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM), (AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM), - (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM), + (RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_UNKNOWN), ], ) def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None: - """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип.""" + """Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип. + + Тип, который НИЧЕГО не доказывает, с #2764 даёт 'unknown': `else PLATFORM` здесь + был тем же назначенным диагнозом, что и дефолт mark_banned, только уровнем ниже. + """ assert ban_kind_of_exception(exc) == expected @@ -138,7 +142,7 @@ class _BanRecorder: error: str, counters: dict[str, Any], *, - ban_kind: str = kit_runs.BAN_KIND_PLATFORM, + ban_kind: str = kit_runs.BAN_KIND_UNKNOWN, ) -> None: self.banned.append((ban_kind, dict(counters))) @@ -193,7 +197,8 @@ async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None: Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы - 'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206. + 'unknown' (дефолт с #2764; до него — 'platform', ровно то враньё, которое + стоило миграции 206). """ recorder = await _drive_full_load( AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)") @@ -225,12 +230,16 @@ async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None: @pytest.mark.parametrize("module_name", ["kit", "app"]) @pytest.mark.parametrize( ("kwargs", "expected"), - [({}, "platform"), ({"ban_kind": "infra"}, "infra")], + [({}, "unknown"), ({"ban_kind": "infra"}, "infra")], ) def test_mark_banned_writes_ban_kind( module_name: str, kwargs: dict[str, str], expected: str ) -> None: - """Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform').""" + """Обе копии runs-модуля кладут ban_kind в UPDATE; дефолт — 'unknown' (#2764). + + Дефолт был 'platform' — и на проде обе строки, помеченные уже в рантайме, взяли + именно его: метка выглядела доказательством, не будучи им. + """ if module_name == "kit": mod: Any = kit_runs else: diff --git a/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py b/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py new file mode 100644 index 00000000..227631ec --- /dev/null +++ b/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py @@ -0,0 +1,204 @@ +"""#2764: ban_kind перестаёт проставляться по умолчанию — метка либо доказана, либо 'unknown'. + +Замер на проде 2026-08-07 (после миграции 218 и мержа #2711): + SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1; + infra 92 / platform 41 +Из них ДВЕ строки написаны уже в рантайме — и обе получили 'platform' по дефолту +сигнатуры mark_banned, из общего финализатора backfill-задач, который диагноз не +передаёт вовсе (3306 avito 12:42, 3313 domclick 15:20). Для avito причина 1600 +отказов вообще не устанавливалась, и всё равно была записана блокировкой площадкой. + +Проверяем четыре звена: + 1. дефолт mark_banned = 'unknown' (обе копии runs-модуля) — test_2686 §4; + 2. финализатор backfill'ов несёт диагноз, когда задача его знает, и 'unknown', + когда нет или когда блоки разошлись в диагнозах; + 3. browser-режим fetch_detail больше не выдаёт отказ НАШЕГО сайдкара за + блокировку площадкой (зеркало #2686 для SERP-ветки); + 4. 'unknown' проходит CHECK-констрейнт (миграция 234) — иначе запись упала бы + на проде, а падение внутри задачи выглядело бы как обычный failed-прогон. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.avito_exceptions import ( + AvitoBlockedError, + AvitoRateLimitedError, + AvitoSidecarUnavailableError, +) +from scraper_kit.orchestration.pipeline import ban_kind_of_exception +from scraper_kit.providers.avito.detail import fetch_detail + +from app.services import scrape_runs as runs_mod + +_BLOCKED_RUN = {"attempted": 5, "enriched": 0, "blocked": 5, "failed": 0} + + +def _ban_kind_of_finished(**kwargs: Any) -> str: + """mark_backfill_finished на оборванном блоками прогоне → ban_kind, ушедший в mark_banned.""" + recorded: list[str] = [] + with patch.object( + runs_mod, + "mark_banned", + lambda db, rid, err, c, *, ban_kind: recorded.append(ban_kind), + ): + runs_mod.mark_backfill_finished( + MagicMock(), + 1, + dict(_BLOCKED_RUN), + source="avito_detail_backfill", + aborted_by_blocks=True, + **kwargs, + ) + assert len(recorded) == 1, f"ожидался ровно один mark_banned, получено {recorded}" + return recorded[0] + + +# ── 1. Финализатор backfill'ов: диагноз или честное «не знаю» ──────────────── + + +def test_finalizer_without_diagnosis_writes_unknown() -> None: + """Задача типы не различает → 'unknown'. + + Фальсификация: до правки этот вызывающий (единственный источник обеих + рантайм-строк на проде) не передавал ничего и получал дефолт 'platform' — + assert падает на старом коде. + """ + assert _ban_kind_of_finished() == runs_mod.BAN_KIND_UNKNOWN + + +def test_finalizer_carries_the_diagnosis_when_all_blocks_agree() -> None: + """Все блоки прогона — отказ нашего сайдкара → 'infra' доезжает до строки прогона.""" + assert _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA}) == runs_mod.BAN_KIND_INFRA + assert ( + _ban_kind_of_finished(ban_kinds=[runs_mod.BAN_KIND_PLATFORM, runs_mod.BAN_KIND_PLATFORM]) + == runs_mod.BAN_KIND_PLATFORM + ) + + +def test_finalizer_with_mixed_diagnoses_writes_unknown() -> None: + """Блоки разошлись → 'unknown': какой из них оборвал прогон, мы не знаем. + + Выбрать «последний попавшийся» значило бы вернуть назначенный диагноз под + другим именем. + """ + assert ( + _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA, runs_mod.BAN_KIND_PLATFORM}) + == runs_mod.BAN_KIND_UNKNOWN + ) + + +# ── 2. Диагноз не врёт там, где он передаётся: browser-ветка fetch_detail ──── + + +def _browser(*, side_effect: BaseException | None = None, html: str = "") -> Any: + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=side_effect, return_value=html) + return bf + + +def _sidecar_503() -> httpx.HTTPStatusError: + """Ровно то, что прод писал 90 раз с 05.07 по 03.08 (см. миграцию 218).""" + request = httpx.Request("POST", "http://tradein-browser:3000/fetch") + response = httpx.Response( + 503, json={"error": "browser unavailable (proxy may be down)"}, request=request + ) + return httpx.HTTPStatusError("503", request=request, response=response) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "sidecar_failure", + [_sidecar_503(), httpx.ConnectError("connection refused"), httpx.ReadTimeout("read timeout")], +) +async def test_browser_detail_sidecar_failure_is_infra(sidecar_failure: BaseException) -> None: + """Сайдкар не отдал страницу → диагноз 'infra', поведение прогона прежнее. + + Фальсификация: до правки ветка поднимала AvitoBlockedError на ЛЮБОЙ отказ + сайдкара, то есть ban_kind_of_exception возвращал 'platform' — «площадка нас + заблокировала» на странице, которая до площадки не доехала. + Второй assert стережёт совместимость: все существующие + `except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить + этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт. + """ + with pytest.raises(AvitoSidecarUnavailableError) as excinfo: + await fetch_detail("/items/1", browser_fetcher=_browser(side_effect=sidecar_failure)) + + assert isinstance(excinfo.value, AvitoRateLimitedError) + assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_INFRA + + +@pytest.mark.asyncio +async def test_browser_detail_firewall_page_stays_platform() -> None: + """Площадка ПОКАЗАЛА firewall → 'platform' остаётся: правка сужает метку, а не снимает.""" + firewall = "Доступ ограничен: проблема с IP" + + with pytest.raises(AvitoBlockedError) as excinfo: + await fetch_detail("/items/1", browser_fetcher=_browser(html=firewall)) + + assert not isinstance(excinfo.value, AvitoSidecarUnavailableError) + assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_PLATFORM + + +# ── 3. 'unknown' проходит CHECK-констрейнт (миграция 234) ──────────────────── + + +def _live_session() -> Any | None: + """Session к живой Postgres, если она есть (в CI есть — ci-tradein.yml, #2745).""" + try: + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_default_ban_kind_survives_the_check_constraint() -> None: + """mark_banned без диагноза пишет 'unknown' в живую схему. + + Фальсификация: без миграции 234 CHECK разрешает только platform/infra — UPDATE + падает на IntegrityError. На проде это падение было бы неотличимо от обычного + сбоя задачи (внешний `except Exception` backfill'а увёл бы прогон в + mark_failed, потеряв done_buckets-чекпоинт). + """ + from sqlalchemy import text + + db = _live_session() + assert db is not None + try: + run_id = db.execute( + text( + "INSERT INTO scrape_runs (source, status) " + "VALUES ('test_2764', 'running') RETURNING id" + ) + ).scalar_one() + db.commit() + + runs_mod.mark_banned(db, int(run_id), "test 2764", {"lots_fetched": 0}) + + row = db.execute( + text("SELECT status, ban_kind FROM scrape_runs WHERE id = :id"), {"id": run_id} + ).one() + assert row.status == "banned" + assert row.ban_kind == runs_mod.BAN_KIND_UNKNOWN + finally: + db.execute(text("DELETE FROM scrape_runs WHERE source = 'test_2764'")) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py index 327e32ac..ad72db34 100644 --- a/tradein-mvp/backend/tests/test_backfill_honest_status.py +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -29,8 +29,10 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st patch.object( runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err)) ), + # **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про + # ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py. patch.object( - runs_mod, "mark_banned", lambda db, rid, err, c: calls.append(("banned", err)) + runs_mod, "mark_banned", lambda db, rid, err, c, **k: calls.append(("banned", err)) ), ): runs_mod.mark_backfill_finished( diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index f0615237..f3091685 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -61,7 +61,7 @@ class _RunsRecorder: error: str, counters: dict[str, Any], *, - ban_kind: str = "platform", + ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля ) -> None: self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 0c1dc4c4..ba629b56 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -71,7 +71,7 @@ class _RunsRecorder: error: str, counters: dict[str, Any], *, - ban_kind: str = "platform", + ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля ) -> None: self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.calls.append(("mark_banned", dict(counters))) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 02fae3f5..a900f996 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -56,7 +56,7 @@ from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.orchestration import runs # Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан # работать и когда тесты подменяют весь модуль runs двойником (#2686). -from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM +from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment from scraper_kit.providers.avito.houses import ( fetch_house_catalog, @@ -196,14 +196,23 @@ def ban_kind_of_exception(exc: BaseException) -> str: """Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686). AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша - инфраструктура (браузерный сайдкар не отдал страницу) — всё остальное, что - доводит прогон до 'banned' (firewall, 403/captcha, честный 429 площадки, N - подряд заблокированных якорей), внешнее. + инфраструктура (браузерный сайдкар не отдал страницу) → 'infra'. + AvitoBlockedError (firewall/403/captcha/content-block) и честный 429 площадки — + единственные типы, которыми площадка себя ПОКАЗАЛА → 'platform'. + + Всё остальное → 'unknown' (#2764). Раньше здесь стоял `else PLATFORM`, то есть + любой RuntimeError/timeout/сетевой сбой объявлялся блокировкой площадкой — тот + же дефект, что и дефолт mark_banned, только уровнем ниже: диагноз назначался, + а не устанавливался. Один узел на все avito-сайты mark_banned: разводить исход в каждом из трёх было бы тремя копиями одного условия. """ - return BAN_KIND_INFRA if isinstance(exc, AvitoSidecarUnavailableError) else BAN_KIND_PLATFORM + if isinstance(exc, AvitoSidecarUnavailableError): + return BAN_KIND_INFRA + if isinstance(exc, AvitoBlockedError | AvitoRateLimitedError): + return BAN_KIND_PLATFORM + return BAN_KIND_UNKNOWN # #2160: константы для расчёта watchdog-таймаута Cian city sweep. При # USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index d3fd9c01..1389e9b4 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -111,6 +111,12 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: # частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние. BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее +# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform': +# на проде оба прогона, помеченных после миграции 218, получили 'platform' по +# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не +# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а +# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения. +BAN_KIND_UNKNOWN = "unknown" def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: @@ -580,9 +586,9 @@ def mark_banned( error: str, counters: dict[str, int], *, - ban_kind: str = BAN_KIND_PLATFORM, + ban_kind: str = BAN_KIND_UNKNOWN, ) -> None: - """Финализация run: status='banned' + диагноз ban_kind (#2686). + """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764). Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим @@ -591,10 +597,11 @@ def mark_banned( `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); - - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси). + - BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси); + - BAN_KIND_UNKNOWN (дефолт) — причина не установлена. Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора - текста ошибки. Default 'platform' = историческая семантика статуса, поэтому - вызывающие, которым разводить нечего, не меняются. + текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому + разводить нечего, ничего и не знает — а не «знает, что виновата площадка». Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом. 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 0b6c90e7..60aaeb40 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 @@ -38,6 +38,7 @@ from scraper_kit.avito_exceptions import ( AvitoBlockedError, AvitoListingGoneError, AvitoRateLimitedError, + AvitoSidecarUnavailableError, ) from scraper_kit.geo import is_within_oblast66_bbox from scraper_kit.providers._base import build_document_session @@ -389,13 +390,16 @@ async def fetch_detail( Raises: httpx.HTTPError — если status != 200 (через raise_for_status-like). ValueError — если item_id не извлечён из HTML. + AvitoSidecarUnavailableError — browser-режим: сайдкар не отдал страницу + (наш тракт, не площадка; подтип AvitoRateLimitedError — #2764). """ if browser_fetcher is not None: # Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт # СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого # curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой - # (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка - # браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт + # (429 CONNECT tunnel failed), а browser проходит. Отказ САЙДКАРА и + # блокировку ПЛОЩАДКОЙ различаем (#2764, см. except-ветки ниже); прогон в + # обоих случаях ведёт себя одинаково — caller пометит listing и пойдёт # дальше, НЕ трогая curl. full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url) try: @@ -403,10 +407,17 @@ async def fetch_detail( except (AvitoBlockedError, AvitoRateLimitedError): raise except Exception as exc: - # browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead - # listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы - # caller пометил listing и продолжил без curl-фингерпринта. - raise AvitoBlockedError( + # Сайдкар не отдал страницу: HTTPStatusError (503 «browser unavailable», + # 502), ConnectError/ReadTimeout до сервиса, NoProxyAvailableError пустого + # пула. Площадка тут ни при чём — страница до неё не доехала, и её ответ + # мы не видели. Раньше здесь поднимался AvitoBlockedError, то есть ЛЮБОЙ + # отказ нашего тракта доезжал до scrape_runs.ban_kind как «блокировка + # площадкой» (#2764 п.3) — при том, что зеркальная SERP-ветка + # (providers/avito/serp.py, #2686) ровно эти же отказы уже помечала infra. + # Подтип AvitoRateLimitedError — поведение прогона прежнее: все + # `except (AvitoBlockedError, AvitoRateLimitedError)` (backfill, pipeline + # detail/house-фазы) ловят его без изменений. + raise AvitoSidecarUnavailableError( f"Avito detail browser fetch failed for {full_url}: {exc}" ) from exc # 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без