fix(tradein/scraper): диагноз бана перестаёт назначаться по умолчанию (#2764) (#2765)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m59s
Deploy Trade-In / build-backend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 2m14s

This commit is contained in:
bot-backend 2026-08-06 23:17:59 +00:00
parent 8def690b00
commit 0de22f4bc9
16 changed files with 425 additions and 36 deletions

View file

@ -2239,9 +2239,11 @@ class UnifiedScrapeRunRow(BaseModel):
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а # не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard. # has_running_run перестал бы держать single-run guard.
cancellable: bool = False cancellable: bool = False
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или # #2686: диагноз для status='banned' — 'platform' (площадка заблокировала),
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только # 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов. # установлена; раньше такие прогоны молча получали 'platform'). Без него
# оператор видит только «забанен» и делает вывод «площадка нас палит» на 80%
# наших же отказов.
ban_kind: str | None = None ban_kind: str | None = None
params: dict | None = None params: dict | None = None
counters: dict | None = None counters: dict | None = None

View file

@ -33,7 +33,7 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Callable, Mapping from collections.abc import Callable, Collection, Mapping
from functools import cache from functools import cache
from typing import Any 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_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее 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: def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
@ -515,9 +521,9 @@ def mark_banned(
error: str, error: str,
counters: dict[str, int], counters: dict[str, int],
*, *,
ban_kind: str = BAN_KIND_PLATFORM, ban_kind: str = BAN_KIND_UNKNOWN,
) -> None: ) -> None:
"""Финализация run: status='banned' + диагноз ban_kind (#2686). """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'. Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
@ -526,10 +532,11 @@ def mark_banned(
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус: `ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha); - 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, source: str,
aborted_by_blocks: bool = False, aborted_by_blocks: bool = False,
fail_hint: str | None = None, fail_hint: str | None = None,
ban_kinds: Collection[str] = (),
) -> None: ) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'. """Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
@ -609,6 +617,13 @@ def mark_backfill_finished(
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
контейнера на этот вопрос отвечать не могут они исчезают при пересоздании контейнера на этот вопрос отвечать не могут они исчезают при пересоздании
контейнера, то есть на первом же деплое после ночного прогона. контейнера, то есть на первом же деплое после ночного прогона.
`ban_kinds` диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
пишется; разошлись (или их типы ничего не доказывают) 'unknown'. Смешанный
прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
попалась последней какая из них оборвала прогон, мы не знаем (#2764).
""" """
attempted = int(counters.get("attempted") or 0) attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0) enriched = int(counters.get("enriched") or 0)
@ -626,7 +641,14 @@ def mark_backfill_finished(
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)" f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
) )
logger.error("%s run_id=%d", reason, run_id) 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 return
if produced == 0: if produced == 0:

View file

@ -20,6 +20,12 @@ rotate IP on every block, abort after max_consecutive_blocks. Статус об
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
отказа пишется в текст статуса прогона (_failure_signature) иначе она живёт отказа пишется в текст статуса прогона (_failure_signature) иначе она живёт
только в логах контейнера, а те исчезают на первом же деплое. только в логах контейнера, а те исчезают на первом же деплое.
ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения
(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон
3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ финализатор
диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ
сайдкара в AvitoBlockedError, так что передавать было бы нечего.
""" """
from __future__ import annotations from __future__ import annotations
@ -40,7 +46,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError, AvitoRateLimitedError,
) )
from scraper_kit.browser_fetcher import BrowserFetcher 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): раньше # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -365,6 +371,10 @@ async def run_avito_detail_backfill(
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера, # Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature. # в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter() 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): for idx, row in enumerate(snapshot):
# Budget guard # Budget guard
@ -541,6 +551,7 @@ async def run_avito_detail_backfill(
consecutive_blocks += 1 consecutive_blocks += 1
counters.blocked += 1 counters.blocked += 1
failure_census[_failure_signature(e)] += 1 failure_census[_failure_signature(e)] += 1
block_ban_kinds.add(ban_kind_of_exception(e))
do_sleep = False do_sleep = False
logger.warning( logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", "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", source="avito_detail_backfill",
aborted_by_blocks=aborted_by_blocks, aborted_by_blocks=aborted_by_blocks,
fail_hint=_top_failure(failure_census), fail_hint=_top_failure(failure_census),
ban_kinds=block_ban_kinds,
) )
logger.info( logger.info(
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " "avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "

View file

@ -43,6 +43,12 @@ Exception triad differs from Avito:
No IP-rotation/cooldown recovery step exists here No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an (DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window. 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 - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
block) -- counted as failed++, logged, does NOT touch consecutive_blocks and 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 does NOT abort the run (neutral to the block-breaker, mirrors how Avito's

View file

@ -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:%';

View file

@ -240,3 +240,4 @@
222_db_audit_cleanup.sql 222_db_audit_cleanup.sql
225_listing_source_snapshots_run_id_idx.sql 225_listing_source_snapshots_run_id_idx.sql
233_payments.sql 233_payments.sql
234_scrape_runs_ban_kind_unknown.sql

View file

@ -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_differently_formatted_number
tests/test_data_erasure.py::test_real_erase_by_phone_finds_ru_trunk_prefix_variant 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 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

View file

@ -4,6 +4,7 @@ import asyncio
import fnmatch import fnmatch
import os import os
import sys import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") 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) sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402 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.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # 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() 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 @pytest.mark.asyncio
async def test_backfill_blocked_abort_after_max_consecutive() -> None: async def test_backfill_blocked_abort_after_max_consecutive() -> None:
"""5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674). """5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674).

View file

@ -101,11 +101,15 @@ async def test_platform_firewall_is_not_our_infra() -> None:
(AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA), (AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA),
(AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM), (AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM),
(AvitoRateLimitedError("HTTP 429"), 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: 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 assert ban_kind_of_exception(exc) == expected
@ -138,7 +142,7 @@ class _BanRecorder:
error: str, error: str,
counters: dict[str, Any], counters: dict[str, Any],
*, *,
ban_kind: str = kit_runs.BAN_KIND_PLATFORM, ban_kind: str = kit_runs.BAN_KIND_UNKNOWN,
) -> None: ) -> None:
self.banned.append((ban_kind, dict(counters))) 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 вовсе вызов падал Фальсификация: до правки mark_banned не принимал ban_kind вовсе вызов падал
бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы
'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206. 'unknown' (дефолт с #2764; до него — 'platform', ровно то враньё, которое
стоило миграции 206).
""" """
recorder = await _drive_full_load( recorder = await _drive_full_load(
AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)") 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("module_name", ["kit", "app"])
@pytest.mark.parametrize( @pytest.mark.parametrize(
("kwargs", "expected"), ("kwargs", "expected"),
[({}, "platform"), ({"ban_kind": "infra"}, "infra")], [({}, "unknown"), ({"ban_kind": "infra"}, "infra")],
) )
def test_mark_banned_writes_ban_kind( def test_mark_banned_writes_ban_kind(
module_name: str, kwargs: dict[str, str], expected: str module_name: str, kwargs: dict[str, str], expected: str
) -> None: ) -> None:
"""Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform').""" """Обе копии runs-модуля кладут ban_kind в UPDATE; дефолт — 'unknown' (#2764).
Дефолт был 'platform' и на проде обе строки, помеченные уже в рантайме, взяли
именно его: метка выглядела доказательством, не будучи им.
"""
if module_name == "kit": if module_name == "kit":
mod: Any = kit_runs mod: Any = kit_runs
else: else:

View file

@ -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 = "<html><body>Доступ ограничен: проблема с IP</body></html>"
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()

View file

@ -29,8 +29,10 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st
patch.object( patch.object(
runs_mod, "mark_failed", lambda db, rid, err, c: calls.append(("failed", err)) 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( 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( runs_mod.mark_backfill_finished(

View file

@ -61,7 +61,7 @@ class _RunsRecorder:
error: str, error: str,
counters: dict[str, Any], counters: dict[str, Any],
*, *,
ban_kind: str = "platform", ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None: ) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters))) self.calls.append(("mark_banned", dict(counters)))

View file

@ -71,7 +71,7 @@ class _RunsRecorder:
error: str, error: str,
counters: dict[str, Any], counters: dict[str, Any],
*, *,
ban_kind: str = "platform", ban_kind: str = "unknown", # #2764: дефолт двойника = дефолт модуля
) -> None: ) -> None:
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
self.calls.append(("mark_banned", dict(counters))) self.calls.append(("mark_banned", dict(counters)))

View file

@ -56,7 +56,7 @@ from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration import runs from scraper_kit.orchestration import runs
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан # Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
# работать и когда тесты подменяют весь модуль runs двойником (#2686). # работать и когда тесты подменяют весь модуль 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.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import ( from scraper_kit.providers.avito.houses import (
fetch_house_catalog, fetch_house_catalog,
@ -196,14 +196,23 @@ def ban_kind_of_exception(exc: BaseException) -> str:
"""Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686). """Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686).
AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша
инфраструктура (браузерный сайдкар не отдал страницу) всё остальное, что инфраструктура (браузерный сайдкар не отдал страницу) 'infra'.
доводит прогон до 'banned' (firewall, 403/captcha, честный 429 площадки, N AvitoBlockedError (firewall/403/captcha/content-block) и честный 429 площадки
подряд заблокированных якорей), внешнее. единственные типы, которыми площадка себя ПОКАЗАЛА 'platform'.
Всё остальное 'unknown' (#2764). Раньше здесь стоял `else PLATFORM`, то есть
любой RuntimeError/timeout/сетевой сбой объявлялся блокировкой площадкой тот
же дефект, что и дефолт mark_banned, только уровнем ниже: диагноз назначался,
а не устанавливался.
Один узел на все avito-сайты 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. При # #2160: константы для расчёта watchdog-таймаута Cian city sweep. При
# USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене # USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене

View file

@ -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_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее 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: def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
@ -580,9 +586,9 @@ def mark_banned(
error: str, error: str,
counters: dict[str, int], counters: dict[str, int],
*, *,
ban_kind: str = BAN_KIND_PLATFORM, ban_kind: str = BAN_KIND_UNKNOWN,
) -> None: ) -> None:
"""Финализация run: status='banned' + диагноз ban_kind (#2686). """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'. Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
@ -591,10 +597,11 @@ def mark_banned(
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус: `ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha); - BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha);
- BAN_KIND_INFRA упала НАША инфраструктура (браузерный сайдкар/прокси). - BAN_KIND_INFRA упала НАША инфраструктура (браузерный сайдкар/прокси);
- BAN_KIND_UNKNOWN (дефолт) причина не установлена.
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
вызывающие, которым разводить нечего, не меняются. разводить нечего, ничего и не знает а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом. Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом.

View file

@ -38,6 +38,7 @@ from scraper_kit.avito_exceptions import (
AvitoBlockedError, AvitoBlockedError,
AvitoListingGoneError, AvitoListingGoneError,
AvitoRateLimitedError, AvitoRateLimitedError,
AvitoSidecarUnavailableError,
) )
from scraper_kit.geo import is_within_oblast66_bbox from scraper_kit.geo import is_within_oblast66_bbox
from scraper_kit.providers._base import build_document_session from scraper_kit.providers._base import build_document_session
@ -389,13 +390,16 @@ async def fetch_detail(
Raises: Raises:
httpx.HTTPError если status != 200 (через raise_for_status-like). httpx.HTTPError если status != 200 (через raise_for_status-like).
ValueError если item_id не извлечён из HTML. ValueError если item_id не извлечён из HTML.
AvitoSidecarUnavailableError browser-режим: сайдкар не отдал страницу
(наш тракт, не площадка; подтип AvitoRateLimitedError #2764).
""" """
if browser_fetcher is not None: if browser_fetcher is not None:
# Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт # Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт
# СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого # СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого
# curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой # curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой
# (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка # (429 CONNECT tunnel failed), а browser проходит. Отказ САЙДКАРА и
# браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт # блокировку ПЛОЩАДКОЙ различаем (#2764, см. except-ветки ниже); прогон в
# обоих случаях ведёт себя одинаково — caller пометит listing и пойдёт
# дальше, НЕ трогая curl. # дальше, НЕ трогая curl.
full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url) full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
try: try:
@ -403,10 +407,17 @@ async def fetch_detail(
except (AvitoBlockedError, AvitoRateLimitedError): except (AvitoBlockedError, AvitoRateLimitedError):
raise raise
except Exception as exc: except Exception as exc:
# browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead # Сайдкар не отдал страницу: HTTPStatusError (503 «browser unavailable»,
# listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы # 502), ConnectError/ReadTimeout до сервиса, NoProxyAvailableError пустого
# caller пометил listing и продолжил без curl-фингерпринта. # пула. Площадка тут ни при чём — страница до неё не доехала, и её ответ
raise AvitoBlockedError( # мы не видели. Раньше здесь поднимался 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}" f"Avito detail browser fetch failed for {full_url}: {exc}"
) from exc ) from exc
# 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без # 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без