gendesign/tradein-mvp/backend/tests/test_3368_yandex_leaf_cap_complete.py
bot-backend 9519c89d92
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
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 5m5s
fix(scraper-kit/yandex): выпавшая страница leaf'а делает бакет неполным; capped_buckets переживает дрейн
Ревью #3373, два minor:
1. `_leaf` считал полноту только по потолку страниц — упавшая страница
   (`payload=None` → `[]`) молча уходила в чекпоинт как собранная. Теперь
   паритет с cian по-настоящему: `complete = not capped and dropped_pages == 0`.
2. `counters.capped_buckets` присваивался ПОСЛЕ await — cancel/shutdown
   (RuntimeError из `_on_bucket`) уносил управление мимо строки. Перенесено в
   `finally`, счётчик виден в финальном payload дрейна.

Нит: `ceil(total / 20)` → `_GATE_PAGE_SIZE`.

Тесты по значению: бакет с 1 выпавшей страницей из 3 — не в done-леджере
(+ контроль: 3 успешных страницы по-прежнему complete); дрейн после обрезанного
бакета → `capped_buckets == 1` в финальных counters. Фейк `_DrainAtFirstBucket`
(#3355) теперь объявляет `capped_buckets` явно: его catch-all `__getattr__`
отдавал корутину на любое имя и ронял сериализацию counters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:16:14 +05:00

243 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Leaf-бакет яндекса, обрезанный потолком страниц, не идёт в чекпоинт (#3368).
Довесок к #3362: там честной сделали degraded-ветку (`on_bucket(..., complete=False)`),
а `_leaf` продолжал писать бакет как полный, даже когда его пагинация упиралась в
`max_pages_per_bucket`/`_GATE_MAX_PAGES_CAP`. С containment-гейтом (#3358/#3359) такой
ключ покрывает СВОЙ интервал целиком → резюм больше не заходит в полосу, чей хвост не
читали ни разу. У cian тот же случай считается честно (`complete = pages_needed <=
max_pages`, cian/serp.py).
Переполненный leaf возможен только там, где бисекции дробить нечем: размах меньше
`_YANDEX_SPLIT_MIN_BRACKET`, открытый верхний брекет или потолок глубины. Здесь берётся
первый случай: плотная выдача (totalItems=2000 > cap=500) делится до размаха 499 999 и
дальше делиться не может.
Сеть не нужна: `_fetch_page_json` подменяется счётчиком. Проверки ПО ЗНАЧЕНИЮ — что
попало в done-леджер и сколько запросов сделал резюм.
"""
from __future__ import annotations
import asyncio
import json
import os
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.base import ScrapedLot
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from app.services.scraper_adapters import RealScraperConfig
_ROOMS = "2"
def _page(total_items: int) -> dict[str, Any]:
return {
"response": {
"search": {
"offers": {
"entities": [],
"pager": {
"totalItems": total_items,
"totalPages": max(1, total_items // 20),
"page": 0,
},
}
}
}
}
def _walk(
total_items: int,
max_pages_per_bucket: int,
skip_buckets: set[str] | None = None,
fail_pages: set[int] | None = None,
) -> tuple[list[tuple[str, bool]], int, int]:
"""Прогнать бисекцию [4M, 5M) и вернуть (отметки бакетов, запросы, capped_buckets).
Колбэк — той же формы, что pipeline._on_bucket: третий позиционный аргумент =
признак полноты; в done-леджер `_mark_bucket` кладёт только complete=True.
"""
s = YandexRealtyScraper(RealScraperConfig())
s.request_delay_sec = 0.0
calls = [0]
async def fake_fetch(
rooms: str | None,
page: int,
price_min: int | None,
price_max: int | None,
new_flat: str = "NO",
) -> dict[str, Any] | None:
calls[0] += 1
if fail_pages and page in fail_pages:
return None # капча/тарпит/сеть — страница выпала из пагинации
return _page(total_items)
s._fetch_page_json = fake_fetch # type: ignore[method-assign]
marked: list[tuple[str, bool]] = []
def on_bucket(key: str, _count: int, complete: bool = True) -> None:
marked.append((key, complete))
seen: dict[str, ScrapedLot] = {}
asyncio.run(
s._walk_price_range(
rooms=_ROOMS,
lo=4_000_000,
hi=4_999_999,
seen=seen,
price_cap_per_bucket=500,
max_pages_per_bucket=max_pages_per_bucket,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
)
# getattr, а не атрибут напрямую: без счётчика тест обязан краснеть НЕВЕРНЫМ
# ЗНАЧЕНИЕМ (ключ в леджере / ноль запросов на резюме), а не AttributeError'ом —
# «возможности нет» неотличимо от «проверка не проведена».
return marked, calls[0], getattr(s, "capped_buckets", 0)
def test_capped_leaf_stays_out_of_done_ledger() -> None:
"""Приёмка: leaf, которому нужно больше страниц, чем потолок, — не в леджере."""
marked, _, capped = _walk(total_items=2000, max_pages_per_bucket=1)
assert marked, "leaf не вызвал on_bucket — тест ничего не проверяет"
ledger = {key for key, complete in marked if complete}
assert not ledger, (
f"бакеты {sorted(ledger)} прочитаны на 1 страницу из 100 (totalItems=2000), "
"но помечены complete — их интервал зачтётся containment-гейтом целиком"
)
assert capped == len(marked), (
f"capped_buckets={capped} при {len(marked)} обрезанных бакетах — "
"счётчик прогона не покажет, что полоса недобрана по построению"
)
def test_fully_read_leaf_goes_into_done_ledger() -> None:
"""Контроль честности: дочитанный до конца бакет по-прежнему чекпоинтится."""
marked, _, capped = _walk(total_items=15, max_pages_per_bucket=1)
ledger = {key for key, complete in marked if complete}
assert ledger, (
"бакет из одной страницы (totalItems=15) не попал в леджер — резюм будет "
"перечитывать уже собранную территорию"
)
assert capped == 0, f"полный бакет посчитан обрезанным (capped_buckets={capped})"
def test_capped_band_is_rewalked_on_resume() -> None:
"""По значению: полоса с обрезанным leaf'ом на резюме снова обходится."""
marked, _, _ = _walk(total_items=2000, max_pages_per_bucket=1)
ledger = {key for key, complete in marked if complete}
_, calls, _ = _walk(total_items=2000, max_pages_per_bucket=1, skip_buckets=ledger or None)
assert calls > 0, (
"резюм не сделал ни одного запроса по полосе, у которой прочитана 1 страница "
"из 100 — недобранный хвост потерян навсегда"
)
def test_dropped_page_makes_leaf_incomplete() -> None:
"""Ревью #3373: выпавшая страница пагинации — второй путь частичности (как у cian).
`_fetch_page_json` → None (капча/тарпит/сеть) молча давала пустой список, и бакет
из 2 прочитанных страниц вместо 3 уходил в чекпоинт полным. У cian оба пути
считаются вместе: `complete = dropped_pages == 0 and pages_needed <= max_pages`.
"""
ok, _, _ = _walk(total_items=45, max_pages_per_bucket=5)
assert [c for _k, c in ok] == [True], (
"контроль: бакет из 3 успешных страниц обязан быть complete — "
f"иначе тест ниже красный по любой причине, отметки={ok}"
)
marked, _, capped = _walk(total_items=45, max_pages_per_bucket=5, fail_pages={3})
assert marked, "leaf не вызвал on_bucket — тест ничего не проверяет"
ledger = {key for key, complete in marked if complete}
assert not ledger, (
f"бакеты {sorted(ledger)} прочитаны на 2 страницы из 3 (одна выпала с "
"payload=None), но помечены complete — их интервал зачтётся containment-гейтом"
)
# pipeline._mark_bucket увеличивает partial_buckets ровно на complete=False.
assert len([1 for _k, complete in marked if not complete]) == 1, (
f"ровно один бакет обязан лечь в partial_buckets, отметки={marked}"
)
assert capped == 0, (
f"выпавшая страница посчитана потолком (capped_buckets={capped}) — "
"лечение у этих случаев разное: повторить прогон vs снизить min_bracket"
)
class _FakeDb:
"""Пишет каждый UPDATE с counters (как в test_3355_drain_mark_full_loads.py)."""
def __init__(self) -> None:
self.writes: list[dict[str, Any]] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params:
self.writes.append(json.loads(params["counters"]))
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
class _CappedThenDrain:
"""Один обрезанный потолком leaf, затем дрейн на границе бакета."""
def __init__(self, *_a: Any, **_kw: Any) -> None:
self.request_delay_sec = 0.0
self.capped_buckets = 0
self.gate_fetch_attempts = 0
self.gate_fetch_failures = 0
async def __aenter__(self) -> _CappedThenDrain:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
async def fetch_all_secondary(self, **kw: Any) -> None:
self.capped_buckets += 1
kw["on_bucket"]("2:4000000-4999999", [], False)
raise AssertionError("_on_bucket не оборвал прогон при shutdown_requested()")
@pytest.mark.asyncio
async def test_capped_counter_survives_shutdown() -> None:
"""Ревью #3373: счётчик писался ПОСЛЕ await — cancel/shutdown его терял."""
from scraper_kit.orchestration import pipeline as pl
db = _FakeDb()
with (
patch.object(pl, "YandexRealtyScraper", _CappedThenDrain),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
await pl.run_yandex_full_load(
db, # type: ignore[arg-type]
run_id=3373,
config=types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
scraper_skip_seen_today=False,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
),
matcher=MagicMock(),
enrichment=MagicMock(),
request_delay_sec=0.0,
shutdown_requested=lambda: True,
)
assert db.writes, "дрейн не оставил ни одной записи counters"
assert db.writes[-1].get("capped_buckets") == 1, (
"оборванный дрейном прогон не сообщил про обрезанный потолком бакет: "
f"финальные counters={db.writes[-1]}"
)