"""Недогруженная карточка Яндекса — отказ, а не успех (#3191). Наблюдение 2026-08-28 (три карточки, прод-сайдкар, аноним): offer 52275656 3 920 119 б encryptedPhones 65 offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз offer 12108408 4 732 884 б encryptedPhones 65 Средняя вернулась вдвое меньше нормы и БЕЗ блока контактов. Не капча и не ошибка: HTTP 200, HTML валиден, `window.INITIAL_STATE` на месте, парсер отрабатывает. Единственная проверка размера в провайдере (`newbuilding.py`, `len(html) < 500`) отвечает на вопрос «пришло ли хоть что-то» — 1,8 МБ проходит её в 3600 раз, и частичная карточка уезжала в БД с `detail_enriched_at`, выбывая из очереди навсегда. Проверка ПО ЗНАЧЕНИЮ: сравниваются счётчики и факт вызова записи, а не «не бросило исключение». Первый кейс — недогруз (карточка НЕ должна сохраниться), второй — регресс на полной странице (сохраняется как раньше). Тождество attempted (#3338) проверяется в обоих: incomplete ⊆ failed, как blocked. """ from __future__ import annotations import os import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from app.tasks.yandex_detail_backfill import ( # noqa: E402 YandexDetailBackfillResult, run_yandex_detail_backfill, ) _ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" _PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" _SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment" _RUNS = "app.tasks.yandex_detail_backfill.runs_mod" _SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" _RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" # Размеры взяты с наблюдения: недогруз 1,8 МБ (порог в 1 МБ проходит — ловит его # именно отсутствие блока контактов), полная карточка 3,9 МБ. Маркер написан здесь # литералом НАРОЧНО: тест не должен падать на импорте, когда правку снимают — # фальсификация обязана краснеть по значению («карточка успешна»), а не по ImportError. CONTACTS_MARKER = "encryptedPhones" UNDERRENDERED_HTML = "" + "x" * 1_805_556 + "" FULL_HTML = "" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]' def _mock_db(n: int) -> MagicMock: snapshot = [ {"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n) ] db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0) db.execute.return_value = sel return db def _resp(html: str) -> MagicMock: resp = MagicMock() resp.status_code = 200 resp.text = html return resp async def _run(html: str) -> tuple[YandexDetailBackfillResult, MagicMock]: """Один прогон на одну карточку. parse НАРОЧНО отдаёт разобранную модель: именно так и было на проде — частичная страница парсится успешно.""" save = MagicMock(return_value=True) db = _mock_db(1) session = AsyncMock() session.get = AsyncMock(side_effect=[_resp(html)]) ctx = MagicMock() ctx.__aenter__ = AsyncMock(return_value=session) ctx.__aexit__ = AsyncMock(return_value=None) with ( patch(_ASYNC_SESSION, MagicMock(return_value=ctx)), patch(_PARSE, return_value=MagicMock()), patch(_SAVE, save), patch(_RUNS, MagicMock()), patch(_SLEEP, new_callable=AsyncMock), patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), ): counters = await run_yandex_detail_backfill( db, run_id=3191, params={"batch_size": 1, "budget_sec": 3600} ) return counters, save @pytest.mark.asyncio async def test_underrendered_page_is_refusal_not_success() -> None: """1,8 МБ без блока контактов → incomplete, detail_enriched_at не проставлен.""" counters, save = await _run(UNDERRENDERED_HTML) # save_detail_enrichment — единственный писатель detail_enriched_at (UPDATE ... # detail_enriched_at = NOW()). Не вызван = карточка осталась в очереди, её # возьмёт следующий снапшот (WHERE detail_enriched_at IS NULL). save.assert_not_called() assert (counters.enriched, counters.incomplete, counters.failed) == (0, 1, 1), ( f"enriched={counters.enriched} incomplete={counters.incomplete} " f"failed={counters.failed}: недогруз обязан быть отказом, а не успехом" ) assert counters.blocked == 0, ( f"blocked={counters.blocked}: площадка ответила HTTP 200 — это не блок" ) # Тождество #3338: incomplete ⊆ failed, в сумму исходов входит только failed. assert counters.attempted == counters.enriched + counters.failed == 1 @pytest.mark.asyncio async def test_full_page_still_enriches() -> None: """Регресс: полная карточка сохраняется как раньше.""" counters, save = await _run(FULL_HTML) save.assert_called_once() assert (counters.enriched, counters.incomplete, counters.failed) == (1, 0, 0), ( f"enriched={counters.enriched} incomplete={counters.incomplete} " f"failed={counters.failed}: полная страница должна обогащать как раньше" ) assert counters.attempted == counters.enriched + counters.failed == 1 def test_reason_names_the_two_signals() -> None: """Признака два, и любой из них — отказ.""" from scraper_kit.providers.yandex.detail import detail_incomplete_reason assert detail_incomplete_reason(FULL_HTML) is None # Структурный: 1,8 МБ проходит размерный порог, но контактов нет. assert CONTACTS_MARKER in (detail_incomplete_reason(UNDERRENDERED_HTML) or "") # Размерный: контакты есть, а страница обрезана. reason = detail_incomplete_reason(f'"{CONTACTS_MARKER}":[]', min_html_bytes=1_000_000) assert reason is not None and "недогружена" in reason def test_redirect_phones_alone_counts_as_complete() -> None: """Карточка «без телефона / только чат»: redirectPhones есть, encryptedPhones нет. Класс в замер #3192 не попадал (там у всех 12 были оба ключа). Требуя именно encryptedPhones, мы браковали бы такие карточки вечно — они возвращаются в очередь, а ключ у них не появится. Маркеры литералами: правку снимут — тест обязан краснеть по значению («карточка отбракована»), а не по ImportError. """ from scraper_kit.providers.yandex.detail import detail_incomplete_reason body = "" + "x" * 3_920_119 assert detail_incomplete_reason(body + '"redirectPhones":["a"]') is None # Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз. assert detail_incomplete_reason(body + "") is not None