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
Сайдкар вообще не читал код ответа 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 не исполняют.
224 lines
9.7 KiB
Python
224 lines
9.7 KiB
Python
"""#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
|