Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
200 lines
9.1 KiB
Python
200 lines
9.1 KiB
Python
"""#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:
|
||
"""Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного.
|
||
|
||
Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть
|
||
на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после
|
||
того, как забанила здоровые узлы (#2767).
|
||
"""
|
||
described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
|
||
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 = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
|
||
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
|