fix(tradein/cian): показать, ЧТО пришло вместо состояния ЖК-страницы, и честно закрывать нулевой прогон (#2767) (#2768)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m11s
This commit is contained in:
parent
a034d7a1c1
commit
5046ac7b4e
4 changed files with 272 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
189
tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
Normal file
189
tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue