From a227877905515b86e7a95bfd4de42ba3b2fa164f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 09:36:23 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD,=20=D1=83=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D1=8F=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=8C,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=91=D1=82=20=D0=B1=D1=8B=D1=82=D1=8C=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=BC=20(#2625)=20(#2813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_runs.py | 57 ++++++ .../tests/test_2625_run_that_did_nothing.py | 188 ++++++++++++++++++ .../src/scraper_kit/orchestration/runs.py | 57 ++++++ 3 files changed, 302 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index c83f178e..c7c887d5 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -179,6 +179,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non ) +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -445,7 +491,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text( diff --git a/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py new file mode 100644 index 00000000..9dd3b3b0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py @@ -0,0 +1,188 @@ +"""#2625: прогон, у которого отказом кончился каждый якорь, перестаёт быть 'done'. + +Задача заводилась про капчу Циана и пустые выдачи Яндекса. Основную её часть закрыл +#2642 (детект провала извлечения структуры → 'banned'). Замер на проде 2026-08-10 +подтверждает эффект симптоматически: развёртки cian/yandex, статус 'done' с нулём — +42 из 89 до деплоя #2642 (04.08 19:00 UTC) и 1 из 30 после. + +Этот единственный оставшийся — прогон 3557 (`yandex_city_sweep_pervouralsk`, +09.08 17:11): 155 миллисекунд, `{"anchors_total": 1, "anchors_done": 1, +"errors_count": 1, "lots_fetched": 0}`, статус 'done'. И он не новый: за 90 суток под +тот же признак попадают 28 прогонов, включая `yandex_city_sweep_nizhniy_tagil` — +16 суток подряд (15-30.07), каждый ровно 240 секунд (таймаут якоря), ноль лотов, +'done' каждый раз. + +Почему детект #2642 их не видит — и правильно не видит. Он считает попытки РАЗБОРА +(`_track_gate_result`), а transport_error туда НАМЕРЕННО не попадает (#2600 п.1: +«наш прокси сдох» не должен выглядеть баном площадки). Якорь, упавший по таймауту +или исключению до первого разобранного ответа, даёт `attempts == 0`, условие +`attempts > 0 and failures == attempts` молчит — и прогон уходит в 'done'. + +То есть третий исход существовал, но терминального статуса у него не было: + * площадка отбила → 'banned' (#2642, есть); + * площадка честно пуста → 'done' (есть, и это здоровый ответ); + * мы не дошли → ??? → 'done' ← дефект. + +Признак намеренно НЕ опирается на список антибот-маркеров: маркеры объясняют уже +случившийся отказ и молчат про неизвестный (урок 09.08, #2798). Здесь считается +собственная бухгалтерия прогона — `errors_count >= anchors_total` при измеренном +нуле. Что она доказывает: каждый якорь кончился отказом и собрано ноль. Чего НЕ +доказывает: кто виноват. Поэтому 'failed' без ban_kind, а не 'banned'/'platform'. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +_MODULES = {"kit": kit_runs, "app": app_runs} + +# Реальные counters с прода (2026-08-10), не выдуманные. +PROD_3557_PERVOURALSK = { + "anchors_done": 1, + "errors_count": 1, + "lots_fetched": 0, + "lots_updated": 0, + "anchors_total": 1, + "lots_inserted": 0, + "address_failed": 0, + "combos_skipped": 0, + "address_enriched": 0, + "address_attempted": 0, + "price_history_rows": 0, +} +# Тот же source, прогон 3320 от 06.08 — отказ якоря БЫЛ (errors_count=1), но 117 лотов +# собрано. Частичный сбор — сбор, статус обязан остаться 'done'. +PROD_3320_PARTIAL = {**PROD_3557_PERVOURALSK, "lots_fetched": 117, "lots_inserted": 63} +# Прогон 2930 (cian_city_sweep_serov, 02.08): ноль лотов БЕЗ единого отказа — +# это либо честная пустота, либо недетект капчи; и то и другое — не наша тема, +# капча — предмет #2642, а честная пустота обязана оставаться 'done'. +PROD_2930_HONEST_EMPTY = {**PROD_3557_PERVOURALSK, "errors_count": 0} + + +def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]: + """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. + + Читаем СТАТУС В SQL, а не имя вызванной функции: тест должен краснеть на + поведении финализатора, а не на отсутствии нового имени в старом коде. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + # sentry заглушаем: алерт-хуки best-effort и к предмету теста отношения не имеют. + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 3557, dict(counters)) + return statuses + + +# ── 1. Три исхода разведены ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_all_anchors_failed_zero_lots_is_not_done(name: str) -> None: + """Прод-прогон 3557: все якоря отказали, собрано 0 → 'failed', НЕ 'done'. + + Красный на старом коде: mark_done писал status='done'. + """ + assert _capture_status(_MODULES[name], PROD_3557_PERVOURALSK) == ["failed"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_partial_harvest_stays_done(name: str) -> None: + """Прод-прогон 3320: отказ якоря был, но 117 лотов собрано → остаётся 'done'.""" + assert _capture_status(_MODULES[name], PROD_3320_PARTIAL) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_honest_empty_stays_done(name: str) -> None: + """Ноль лотов без единого отказа — честная пустота, 'done' (ложной тревоги нет).""" + assert _capture_status(_MODULES[name], PROD_2930_HONEST_EMPTY) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_failed_run_carries_no_ban_diagnosis(name: str) -> None: + """Причина не установлена → ban_kind не пишется вовсе (#2764). + + 'banned' с диагнозом означал бы «нас забанила площадка» — а мы знаем только, + что якоря отказали. Ротацию IP (#2611) на догадке дёргать нельзя. + """ + mod = _MODULES[name] + sqls: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sqls.append(str(stmt)) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 3557, dict(PROD_3557_PERVOURALSK)) + + assert not any("ban_kind" in s for s in sqls), "статус не должен нести диагноз" + assert any("status = 'failed'" in s for s in sqls) + + +# ── 2. Классификатор: границы, на которых легко получить ложную тревогу ────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "flagged", "why"), + [ + ({"anchors_total": 1, "errors_count": 1, "lots_fetched": 0}, True, "1 из 1 отказал"), + ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, True, "5 из 5 (avito ЕКБ)"), + ({"anchors_total": 5, "errors_count": 1, "lots_fetched": 0}, False, "1 из 5 — не все"), + ({"anchors_total": 5, "errors_count": 5, "lots_fetched": 12}, False, "собрано 12"), + ({"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}, False, "честная пустота"), + # full-load'ы пишут unique_fetched, а не lots_fetched — тот же смысл. + ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 0}, True, "full-load ноль"), + ({"anchors_total": 2, "errors_count": 2, "unique_fetched": 340}, False, "full-load сбор"), + # Результат НЕ измерен — судить нечем, ноль не выдумывается (#2703). + ({"anchors_total": 1, "errors_count": 1}, False, "результата в counters нет"), + # Якорей нет вовсе: это не развёртка, чужой словарь счётчиков не трогаем. + ({"errors_count": 9, "lots_fetched": 0}, False, "не развёртка"), + ({"attempted": 5, "enriched": 0, "failed": 5}, False, "detail-backfill, чужой словарь"), + ({}, False, "пустые counters"), + ({"anchors_total": 0, "errors_count": 0, "lots_fetched": 0}, False, "нуль якорей"), + ], +) +def test_classifier_boundaries( + name: str, counters: dict[str, Any], flagged: bool, why: str +) -> None: + reason = _MODULES[name]._sweep_run_did_nothing(counters) + assert (reason is not None) is flagged, why + if flagged: + assert "#2625" in (reason or "") + + +# ── 3. Ровно те 16 суток, что прод прожил молча ────────────────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_sixteen_silent_tagil_runs_would_have_been_failed(name: str) -> None: + """15-30.07: 16 прогонов `yandex_city_sweep_nizhniy_tagil` по 240 с, 0 лотов. + + Каждый отчитался 'done'. Ни один алерт их не поднял: `_alert_if_consecutive_failures` + считает только failed/banned. С этой правкой все 16 — 'failed', то есть первый же + из них попадает в лестницу вех failed-стрика (#2670). + """ + tagil = {"anchors_total": 1, "anchors_done": 1, "errors_count": 1, "lots_fetched": 0} + statuses = [_capture_status(_MODULES[name], tagil) for _ in range(16)] + assert statuses == [["failed"]] * 16 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 1389e9b4..108a7891 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -174,6 +174,52 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non ) +def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -510,7 +556,18 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки — иначе admin/observability показывает 0 (audit #1926). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( text(