fix(tradein/scraper): диагноз бана перестаёт назначаться по умолчанию (#2764) #2765
16 changed files with 425 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:%';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
204
tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
Normal file
204
tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
Normal 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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 при смене
|
||||
|
|
|
|||
|
|
@ -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): вызывающий, которому
|
||||
разводить нечего, ничего и не знает — а не «знает, что виновата площадка».
|
||||
|
||||
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
|
||||
|
||||
|
|
|
|||
|
|
@ -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» без
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue