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)