fix(tradein/scraper): прогон, у которого отказал каждый якорь, перестаёт быть успехом (#2625) (#2813)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m35s

This commit is contained in:
bot-backend 2026-08-10 09:36:23 +00:00
parent 272abac4d2
commit a227877905
3 changed files with 302 additions and 0 deletions

View file

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

View file

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

View file

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