"""#2767 — сделать причину отказа newbuilding_enrich видимой. Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и «пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни признаков защиты, а статус утверждал успех. Две проверки, обе падают на коде до правки: 1. строка отказа разбора несёт размер полученной страницы и найденные маркеры; 2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий runs.mark_backfill_finished (#2695), а не через mark_done. Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы — заглушками. """ from __future__ import annotations import logging import os import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock # DATABASE_URL required by config before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint stub — not installed in CI without GTK. sys.modules.setdefault("weasyprint", MagicMock()) import pytest # noqa: E402 from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 _describe_parse_miss, fetch_newbuilding, ) from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402 from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 NewbuildingEnrichBackfillResult, run_newbuilding_enrich, ) # --------------------------------------------------------------------------- # 1. Размер страницы + маркеры защиты в строке отказа разбора. # --------------------------------------------------------------------------- def test_describe_parse_miss_reports_size_and_no_markers() -> None: """Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка».""" html = "" + "x" * 1_170_000 + "" described = _describe_parse_miss(html) assert f"html_len={len(html)}" in described assert "antibot_markers=none" in described def test_describe_parse_miss_names_found_markers() -> None: """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после того, как забанила здоровые узлы (#2767). """ described = _describe_parse_miss("
Вы не робот?
") assert "вы не робот" in described assert "html_len=" in described def _spy_browser_fetcher(html: str) -> MagicMock: """Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html.""" fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value=html) fetcher.__aenter__ = AsyncMock(return_value=fetcher) fetcher.__aexit__ = AsyncMock(return_value=None) return MagicMock(return_value=fetcher) @pytest.mark.asyncio async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None: """Отказ разбора обязан писать РАЗМЕР полученной страницы. Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см. test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа. """ shell = "Доступ ограничен" # короткая заглушка monkeypatch.setattr( "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", _spy_browser_fetcher(shell), ) with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): result = await fetch_newbuilding( "https://zhk-tihiy-centr-ekb-i.cian.ru", config=SimpleNamespace( browser_http_endpoint="http://tradein-browser:3000", use_proxy_pool_browser=False, ), ) assert result is None message = "\n".join(r.getMessage() for r in caplog.records) assert "initialState extraction failed" in message assert f"html_len={len(shell)}" in message assert "доступ ограничен" in message # --------------------------------------------------------------------------- # 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'. # --------------------------------------------------------------------------- def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict: """Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов.""" calls: dict = {} monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None) monkeypatch.setattr( task_mod.runs_mod, "mark_done", lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)), ) monkeypatch.setattr( task_mod.runs_mod, "mark_failed", lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err), ) monkeypatch.setattr( task_mod.runs_mod, "mark_backfill_finished", lambda _db, run_id, counters, **kw: calls.setdefault( "finished", {"counters": dict(counters), **kw} ), ) return calls def _stub_backfill( monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult ) -> None: async def _fake(_db, **_kwargs): return result monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake) @pytest.mark.asyncio async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None: """Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done.""" _stub_backfill( monkeypatch, NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25), ) calls = _stub_finalisers(monkeypatch) await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type] assert "mark_done" not in calls # ← падает на старом коде finished = calls["finished"] assert finished["source"] == "newbuilding_enrich" assert finished["counters"]["attempted"] == 25 assert finished["counters"]["enriched"] == 0 assert finished["counters"]["failed"] == 25 # Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764). assert finished["counters"].get("blocked") is None assert "fetch" in finished["fail_hint"] @pytest.mark.asyncio async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None: """Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ (общий финализатор такой прогон закрывает 'done').""" _stub_backfill( monkeypatch, NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25), ) calls = _stub_finalisers(monkeypatch) await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type] assert calls["finished"]["counters"]["attempted"] == 0 assert calls["finished"]["fail_hint"] is None @pytest.mark.asyncio async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: """Ключи финализатора ДОБАВЛЯЮТСЯ к своим счётчикам задачи, а не заменяют их.""" _stub_backfill( monkeypatch, NewbuildingEnrichBackfillResult( processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7 ), ) calls = _stub_finalisers(monkeypatch) await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type] counters = calls["finished"]["counters"] assert counters["attempted"] == 10 assert counters["enriched"] == 3 assert counters["failed"] == 7 assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте assert counters["succeeded"] == 3