gendesign/tradein-mvp/backend/tests/test_3196_cian_ban_kind.py
bot-backend bf3214b9e4
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
fix(tradein/scrapers): диагноз блока брался из текстовых маркеров чужой площадки, а не из HTTP-статуса (#3196)
Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.

- browser/server.py: статус целевой навигации сохраняется per-provider и
  доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
  403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
  страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
  заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
  ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
  Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
  маркер-детект и на сбой фетча разводится без размножения типов; прогон
  передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
  промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
  пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
  пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
  записав его блоком, мы бы штамповали фиктивные баны у здорового источника
  (13 done против 1 banned за 14 дней).

Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.

Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
2026-08-28 23:21:54 +03:00

224 lines
9.7 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.

"""#3196: циановский прогон обязан ставить диагноз отказа по HTTP-статусу.
До правки браузерный путь `fetch_detail` глотал ЛЮБОЙ отказ: `BrowserFetcher.fetch`
возвращает str и на 403 не бросает, парсер не находил состояние и провайдер отдавал
None. Батч считал это `listings_failed_fetch`, планировщик звал `mark_done` — прогон
уходил в 'done' с пустым `scrape_runs.ban_kind`, и отличить «Циан нас отбил» от
«упал наш сайдкар» постфактум было нечем.
Теперь отказ читается из `bf.last_response_status` (контракт фундамента #3196):
403/429 → 'platform', 5xx → 'infra', всё прочее → 'unknown'. В 'banned' переводится
только прогон, который отказы видел и не обогатил НИЧЕГО, — рабочий путь Циана
(13 done против 1 banned за 14 суток) не трогаем.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import scheduler as sched_mod
from app.tasks import cian_history_backfill
class _FakeBrowserFetcher:
"""Сайдкар, который отдаёт заданный HTTP-статус последнего ответа."""
def __init__(self, status: int | None = None, **kwargs: Any) -> None:
self.last_response_status = status
async def __aenter__(self) -> _FakeBrowserFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
def _fetcher_factory(status: int | None):
def _make(**kwargs: Any) -> _FakeBrowserFetcher:
return _FakeBrowserFetcher(status)
return _make
def _db_with_rows(n: int = 1) -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [
{"id": i, "source_url": f"https://ekb.cian.ru/sale/flat/{i}/"} for i in range(1, n + 1)
]
return db
async def _run(status: int | None, *, detail: Any, rows: int = 1):
"""Один прогон listings-блока с заданным статусом и поведением fetch_detail."""
with (
patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(status)),
patch.object(cian_history_backfill, "fetch_detail", detail),
patch("asyncio.sleep", new_callable=AsyncMock),
):
return await cian_history_backfill.backfill_cian_history(
_db_with_rows(rows), do_listings=True, do_houses=False, do_valuations=False
)
# ── Диагноз по статусу ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"), # WAF Циана отбил узел
(429, "platform"), # тот же класс: площадка отказала
(500, "infra"), # наш сайдкар/прокси не отдал страницу
(503, "infra"),
# Недиагностируемый ответ отказом НЕ считается (#3196): статуса нет —
# сайдкар не дошёл до навигации; HTTP 200 — площадка ответила, а пусто
# вернул наш парсер (дрейф разметки). Записав это в ban_kinds, мы бы
# объявляли фиктивный бан площадки на собственной поломке.
(None, None),
(200, None),
],
)
async def test_ban_kind_from_http_status(status: int | None, expected: str | None) -> None:
result = await _run(status, detail=AsyncMock(return_value=None))
# Попытка провалилась в любом случае — этот счётчик от диагноза не зависит.
assert result.listings_failed_fetch == 1
if expected is None:
assert result.listings_blocked == 0
assert result.ban_kinds == {}
else:
assert result.listings_blocked == 1
assert result.ban_kinds == {expected: 1}
assert result.ban_kind == expected
async def test_fetch_exception_also_carries_diagnosis() -> None:
"""Отказ, поднятый исключением (CianBlockedError на curl-пути), диагноз тоже несёт."""
result = await _run(403, detail=AsyncMock(side_effect=Exception("HTTP 403 (WAF-блок узла)")))
assert result.listings_blocked == 1
assert result.ban_kind == "platform"
async def test_mixed_census_without_majority_is_unknown() -> None:
"""Ровно поровну platform/infra → честное 'unknown' (правило #3178)."""
result = cian_history_backfill.CianBackfillResult()
cian_history_backfill._note_refusal(result, 403)
cian_history_backfill._note_refusal(result, 503)
assert result.ban_kinds == {"platform": 1, "infra": 1}
assert result.ban_kind == "unknown"
async def test_healthy_run_records_no_refusals() -> None:
"""Рабочий путь: обогатили — переписи отказов нет, диагноз не навязывается."""
with (
patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(200)),
patch.object(
cian_history_backfill,
"fetch_detail",
AsyncMock(return_value=SimpleNamespace(price_changes=[])),
),
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
patch("asyncio.sleep", new_callable=AsyncMock),
):
result = await cian_history_backfill.backfill_cian_history(
_db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False
)
assert result.listings_succeeded == 1
assert result.listings_blocked == 0
assert result.ban_kinds == {}
# ── Диагноз доезжает до строки прогона ───────────────────────────────────────
async def _execute(result: Any) -> SimpleNamespace:
fake_runs = SimpleNamespace(
update_heartbeat=MagicMock(),
mark_done=MagicMock(),
mark_banned=MagicMock(),
mark_failed=MagicMock(),
)
async def _batch(db: Any, **kwargs: Any) -> Any:
return result
with (
patch.object(sched_mod, "runs_mod", fake_runs),
patch.object(cian_history_backfill, "backfill_cian_history", _batch),
):
await sched_mod._execute_cian_backfill(MagicMock(), run_id=7, params={})
return fake_runs
@pytest.mark.parametrize(
("status", "expected"),
[(403, "platform"), (503, "infra")],
)
async def test_run_row_gets_ban_kind(status: int | None, expected: str) -> None:
"""Отказы + ноль обогащений → status='banned' с диагнозом из HTTP-статуса."""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 3
for _ in range(3):
cian_history_backfill._note_refusal(result, status)
runs = await _execute(result)
assert not runs.mark_done.called, "прогон без единого обогащения не 'done'"
assert runs.mark_banned.called
assert runs.mark_banned.call_args.kwargs["ban_kind"] == expected
assert runs.mark_banned.call_args.args[3]["blocked"] == 3
assert runs.mark_banned.call_args.args[3]["ban_kinds"] == {expected: 3}
async def test_parser_drift_is_not_a_ban() -> None:
"""Ключевой инвариант #3196: HTTP 200 с пустым разбором — НЕ бан площадки.
Три карточки подряд отдали 200, а enrichment не собрался — это дрейф разметки
Циана либо наш парсер. Прогон обязан остаться нашей поломкой (сюда сходится
ветка 'failed' у backfill-ей), а не превратиться в 'banned' с выдуманным
диагнозом: именно такие фиктивные 'unknown' и убирает #3196.
"""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 3
result.listings_failed_fetch = 3
for _ in range(3):
assert cian_history_backfill._note_refusal(result, 200) is None
runs = await _execute(result)
assert result.ban_kinds == {}
assert result.listings_blocked == 0
assert not runs.mark_banned.called, "промах парсера не объявляем отказом площадки"
assert runs.mark_done.called
async def test_partial_success_stays_done() -> None:
"""Рабочий путь не трогаем: обогатили хоть что-то — 'done', даже при отказах."""
result = cian_history_backfill.CianBackfillResult()
result.listings_processed = 2
result.listings_succeeded = 1
cian_history_backfill._note_refusal(result, 403)
runs = await _execute(result)
assert runs.mark_done.called
assert not runs.mark_banned.called
async def test_clean_empty_run_stays_done() -> None:
"""Кандидатов не было — ни отказов, ни диагноза: 'done' как и раньше."""
runs = await _execute(cian_history_backfill.CianBackfillResult())
assert runs.mark_done.called
assert not runs.mark_banned.called