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): статус соврал бы, а
# 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

View file

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

View file

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

View file

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

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
225_listing_source_snapshots_run_id_idx.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_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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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 при смене

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_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): вызывающий, которому
разводить нечего, ничего и не знает а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом.

View file

@ -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» без