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
188 lines
11 KiB
Python
188 lines
11 KiB
Python
"""#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
|