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
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:
parent
272abac4d2
commit
a227877905
3 changed files with 302 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
188
tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
Normal file
188
tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue