"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection. Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» — неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker= "item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError` (см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер): - page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise. - page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка. - page>1, 0 карточек → всегда graceful end-of-pagination (не regressed). - exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла). Refs: audit-scrapers 2026-07-26, finding 1 (medium). """ from __future__ import annotations import os from unittest.mock import AsyncMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.avito_exceptions import AvitoContentBlockedError from scraper_kit.base import ScrapedLot from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper from app.services.scraper_adapters import RealScraperConfig # HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек # И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift. _NO_MARKER_HTML = "" + ("x" * 500) + "" # Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS). _NO_RESULTS_HTML = ( "По вашему запросу ничего не найдено. Попробуйте изменить фильтры." + ("y" * 200) + "" ) # Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) — # используется только для проверки, что page>1 остаётся graceful независимо от # содержимого (проверка применяется ТОЛЬКО к page==1). _BLOCKPAGE_HTML = "

Доступ ограничен

" def _make_lot(source_id: str) -> ScrapedLot: return ScrapedLot( source="avito", source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}", source_id=source_id, price_rub=6_000_000, ) # ── fetch_city_wide (_paginate_sweep) ──────────────────────────────────────── @pytest.mark.asyncio async def test_citywide_page1_zero_cards_no_marker_raises() -> None: s = AvitoScraper(RealScraperConfig()) with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)): with pytest.raises(AvitoContentBlockedError): await s.fetch_city_wide(pages=5, delay_override_sec=0) @pytest.mark.asyncio async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None: s = AvitoScraper(RealScraperConfig()) with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)): result = await s.fetch_city_wide(pages=5, delay_override_sec=0) assert result == [] @pytest.mark.asyncio async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None: """page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой firewall-текст без карточек НЕ должен поднимать исключение (только page==1).""" s = AvitoScraper(RealScraperConfig()) call_n = 0 async def _fetch(url: str, page: int) -> str: return "page1" if page == 1 else _BLOCKPAGE_HTML def _parse(html: str, source_url_base: str) -> list[ScrapedLot]: nonlocal call_n call_n += 1 return [_make_lot("A"), _make_lot("B")] if call_n == 1 else [] with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)): with patch.object(s, "_parse_html", side_effect=_parse): with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)): result = await s.fetch_city_wide(pages=5, delay_override_sec=0) assert len(result) == 2 assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise # ── fetch_by_rooms ──────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None: s = AvitoScraper(RealScraperConfig()) with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)): with pytest.raises(AvitoContentBlockedError): await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1]) @pytest.mark.asyncio async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None: s = AvitoScraper(RealScraperConfig()) with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)): result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1]) assert result == [] # ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ─────────────────── @pytest.mark.asyncio async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None: """Probe независимо утверждал total=5 (bucket не может быть легитимно пустым), но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет.""" s = AvitoScraper(RealScraperConfig()) seen: dict[str, ScrapedLot] = {} with patch.object(s, "_parse_html", return_value=[]): with pytest.raises(AvitoContentBlockedError): await s._paginate_leaf_bucket( room_slug="studii-ASgBAgICAUSSA8YQ", room_label="studio", lo=0, hi=3_000_000, html="probe-page-1", max_pages=1, seen=seen, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, expected_total=5, ) assert seen == {} @pytest.mark.asyncio async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None: """Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете).""" s = AvitoScraper(RealScraperConfig()) seen: dict[str, ScrapedLot] = {} with patch.object(s, "_parse_html", return_value=[]): # Не должно поднимать исключение. await s._paginate_leaf_bucket( room_slug="studii-ASgBAgICAUSSA8YQ", room_label="studio", lo=0, hi=3_000_000, html=None, max_pages=1, seen=seen, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, expected_total=None, ) assert seen == {} @pytest.mark.asyncio async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None: """Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия.""" s = AvitoScraper(RealScraperConfig()) seen: dict[str, ScrapedLot] = {} lots = [_make_lot("L1"), _make_lot("L2")] with patch.object(s, "_parse_html", return_value=lots): await s._paginate_leaf_bucket( room_slug="studii-ASgBAgICAUSSA8YQ", room_label="studio", lo=0, hi=3_000_000, html="probe-page-1", max_pages=1, seen=seen, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, expected_total=2, ) assert set(seen.keys()) == {"L1", "L2"}