All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно необмеренный класс карточек «без телефона / только чат»: в очередь они возвращаются, а ключ не появится. redirectPhones измерен тем же замером #3192 и присутствует в обеих ветках (с куками и без). Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None + недогруз) — «N подряд без обогащения», а не «недогруженных».
158 lines
8.2 KiB
Python
158 lines
8.2 KiB
Python
"""Недогруженная карточка Яндекса — отказ, а не успех (#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 = "<html>" + "x" * 1_805_556 + "</html>"
|
||
FULL_HTML = "<html>" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]</html>'
|
||
|
||
|
||
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 = "<html>" + "x" * 3_920_119
|
||
assert detail_incomplete_reason(body + '"redirectPhones":["a"]</html>') is None
|
||
# Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз.
|
||
assert detail_incomplete_reason(body + "</html>") is not None
|