gendesign/tradein-mvp/backend/tests/test_2625_run_that_did_nothing.py
bot-backend a227877905
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
fix(tradein/scraper): прогон, у которого отказал каждый якорь, перестаёт быть успехом (#2625) (#2813)
2026-08-10 09:36:23 +00:00

188 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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