diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py index e46fe9d9..93dd3113 100644 --- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py +++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py @@ -119,6 +119,27 @@ class NewbuildingEnrichBackfillResult: def to_dict(self) -> dict[str, int]: return {f.name: int(getattr(self, f.name)) for f in fields(self)} + def to_backfill_counters(self) -> dict[str, int]: + """to_dict() + ключи, которые читает общий финализатор (#2695, #2767). + + runs.mark_backfill_finished смотрит на attempted / enriched / failed; у этой + задачи те же величины называются иначе, поэтому через общий финализатор она не + проходила и ЛЮБОЙ прогон закрывался mark_done — включая 25 попыток с нулём + обогащений восемь суток подряд. + + `attempted` — дома, до которых дошли руки: пропущенный уже-обогащённый домом + попыткой не был, иначе прогон, которому просто нечего делать, выглядел бы + отказом. `blocked` НЕ заводим намеренно: задача блоки источника не различает, + а нулевой прогон без доказанных блоков финализатор помечает 'failed' — наш + тракт, а не «виновата площадка» (#2764: диагноз ставится по доказательству). + """ + return { + **self.to_dict(), + "attempted": self.processed - self.skipped_already_enriched, + "enriched": self.succeeded, + "failed": self.failed_resolve + self.failed_fetch + self.failed_save, + } + # SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and # (unless force) skip houses already enriched. @@ -673,7 +694,9 @@ async def run_newbuilding_enrich( Thin scheduler wrapper around backfill_newbuilding_enrichment() — mirrors tasks/yandex_address_backfill.run_yandex_address_backfill: emit a heartbeat before the - batch, delegate to the proven backfill, then mark the scrape_run done/failed. + batch, delegate to the proven backfill, then finalise the scrape_run through the + SHARED runs.mark_backfill_finished (#2695) — ноль обогащений при ненулевых попытках + закрывается 'failed', а не 'done' (#2767; счётчики маппит to_backfill_counters). Idempotency is inherited from backfill_newbuilding_enrichment(): with force=False its SELECT excludes houses that already have BOTH price_dynamics AND reliability rows, and @@ -727,10 +750,31 @@ async def run_newbuilding_enrich( on_progress=_heartbeat, ) - counters = result.to_dict() - runs_mod.mark_done(db, run_id, counters) + # Честный финал через ОБЩИЙ финализатор (#2695): ноль обогащений при ненулевых + # попытках больше не 'done'. fail_hint называет ЭТАП, на котором чаще всего + # отказывало (resolve / fetch / save) — «failed=25» на этот вопрос не отвечает. + counters = result.to_backfill_counters() + stage, stage_n = max( + ( + ("resolve", result.failed_resolve), + ("fetch", result.failed_fetch), + ("save", result.failed_save), + ), + key=lambda kv: kv[1], + ) + runs_mod.mark_backfill_finished( + db, + run_id, + counters, + source="newbuilding_enrich", + fail_hint=( + f"чаще всего отказ на этапе {stage} ({stage_n} из {counters['attempted']})" + if stage_n + else None + ), + ) logger.info( - "scheduler: newbuilding_enrich run_id=%d done — processed=%d ok=%d skip=%d " + "scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d " "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " "| pending=%d %.1fs", run_id, diff --git a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py index a0450aa2..5dfc15c4 100644 --- a/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py +++ b/tradein-mvp/backend/tests/test_2725_heartbeat_in_batch.py @@ -213,6 +213,9 @@ async def test_newbuilding_enrich_passes_progress_callback() -> None: update_heartbeat=lambda db, run_id, counters: beats.append(counters), mark_done=MagicMock(), mark_failed=MagicMock(), + # Финал прогона ушёл в общий mark_backfill_finished (#2767) — без него подмена + # runs_mod роняет AttributeError и тест меряет не то, что проверяет. + mark_backfill_finished=MagicMock(), ) with ( diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py new file mode 100644 index 00000000..3ca02ab6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -0,0 +1,189 @@ +"""#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: + """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" + described = _describe_parse_miss("
Вы не робот?
") + assert "captcha" in described + assert "вы не робот" in described + assert "html_len=" in described + + +def _spy_browser_fetcher(html: str) -> MagicMock: + """BrowserFetcher-подмена: 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: + """Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое + разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен).""" + shell = "Доступ ограничен" # короткая заглушка + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.BrowserFetcher", _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"), + ) + + 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_rows=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_rows"] == 7 # исходные счётчики на месте + assert counters["succeeded"] == 3 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 00f8a5bd..566b2a13 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -70,6 +70,33 @@ _CATPH_NEWBUILDING_SERP = ( "&offer_type=flat&object_type[0]=2&newobject[0]={nb_id}" ) +# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767). +# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное. +_ANTIBOT_MARKERS = ( + "captcha", # покрывает и recaptcha, и smartcaptcha + "qrator", + "ddos-guard", + "cf-chl", # Cloudflare challenge + "доступ ограничен", + "вы не робот", +) + + +def _describe_parse_miss(html: str) -> str: + """Факты о полученной странице для строки отказа разбора: размер + найденные маркеры. + + НЕ диагноз (#2767): перечисляет найденное, причину не называет. Размер разделяет + две гипотезы одним числом — здоровая ЖК-карточка весит 1.17 МБ и содержит + initialState (verified live 2026-06-15), поэтому ~1 МБ без состояния читается как + «страница пришла целиком, изменилась разметка», а десятки килобайт — как защитная + заглушка (страница без состояния = подпись антибота, #972). До этой строки в логе + не было ни байтов, ни признаков защиты: восемь суток по 25 предупреждений за + прогон не отвечали на единственный вопрос, ради которого их читают. + """ + lowered = html.lower() + found = [m for m in _ANTIBOT_MARKERS if m in lowered] + return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}" + @dataclass class NewbuildingEnrichment: @@ -163,7 +190,11 @@ async def fetch_newbuilding( nb_state = extract_state(html, mfe=mfe, key="initialState") if nb_state is None: - logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url) + logger.warning( + "Cian newbuilding %s: initialState extraction failed — %s", + zhk_url, + _describe_parse_miss(html), + ) return None # Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)