fix(tradein/cian): показать, ЧТО пришло вместо состояния ЖК-страницы, и честно закрывать нулевой прогон (#2767) #2768

Merged
bot-backend merged 1 commit from fix/2767-newbuilding-diagnose into main 2026-08-07 08:06:26 +00:00
4 changed files with 272 additions and 5 deletions

View file

@ -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,

View file

@ -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 (

View file

@ -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 = "<html>" + "x" * 1_170_000 + "</html>"
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("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
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 = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
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

View file

@ -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)