"""#3297: browser-ветка fetch_detail не смотрела на HTTP-статус ответа. Прод-замер 31.08.2026 (24 попытки через 3 узла): 11 отказов площадки (8×403, 2×429, 1×439), из них 3 пришли страницей-заглушкой (8172 байта, title «Доска объявлений от частных лиц и компаний на Авито»), которая не содержит НИ ОДНОГО известного на тот момент маркера — ни firewall, ни soft-block. До фикса browser-ветка `fetch_detail` (providers/avito/detail.py) НИКОГДА не читала `browser_fetcher.last_response_status` и такие страницы уходили прямиком в `parse_detail_html`, который падал `ValueError("Cannot extract item_id")` — отказ ПЛОЩАДКИ считался обычной ошибкой парсинга, узел не ротировался (зеркало давно рабочей curl-ветки того же файла, `if sc in (403, 439) or is_firewall`). Фальсификация: без правки (a) поднимает ValueError вместо AvitoBlockedError. """ from __future__ import annotations import os from unittest.mock import AsyncMock, MagicMock import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.avito_exceptions import ( AvitoBlockedError, AvitoListingGoneError, AvitoRateLimitedError, ) from scraper_kit.providers.avito.detail import ( _is_detail_soft_block, fetch_detail, ) _MIN_HTML = '
№ 1234567890
' # Ровно страница из прод-замера 31.08: нет item-view, нет старого soft-block-маркера # ("объявления на сайте авито") — до фикса такая страница проваливалась мимо ВСЕХ # известных детекторов прямиком в parse_detail_html/ValueError. _UNRECOGNIZED_BLOCK_PAGE = ( "Доска объявлений от частных лиц и компаний на Авито" "антибот-заглушка без известных текстовых маркеров" ) _NOT_FOUND_HTML = ( "Ошибка 404Страница не найдена" ) def _bf(html: str, *, status: int | None) -> MagicMock: bf = MagicMock() bf.fetch = AsyncMock(return_value=html) bf.last_response_status = status return bf # ── (a) 403/439 -> AvitoBlockedError, 429 -> AvitoRateLimitedError, не ValueError ── @pytest.mark.asyncio @pytest.mark.parametrize("status", [403, 439]) async def test_browser_block_status_raises_avito_blocked_not_valueerror(status: int) -> None: bf = _bf(_UNRECOGNIZED_BLOCK_PAGE, status=status) with pytest.raises(AvitoBlockedError): await fetch_detail( "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890", browser_fetcher=bf ) @pytest.mark.asyncio async def test_browser_429_raises_rate_limited_not_blocked() -> None: """429 — не блок, а просьба сбавить темп; тип решает судьбу тёплого контекста. На AvitoBlockedError оркестратор один раз за прогон зовёт request_context_reset() (avito_detail_backfill.py, #3251) и выбрасывает пройденный QRATOR-PoW. При rate-limit контекст ЦЕЛ, сбрасывать его — значит проходить проверку заново с того же IP. Curl-ветка того же файла разводит эти случаи так же: 429 → короткий retry, при исчерпании AvitoRateLimitedError, а не AvitoBlockedError. Проверяем ИМЕННО невхождение в AvitoBlockedError: AvitoRateLimitedError ему не родственник, так что pytest.raises(AvitoRateLimitedError) прошёл бы и на блоке, будь иерархия обратной — поэтому ассертим тип точно. """ bf = _bf(_UNRECOGNIZED_BLOCK_PAGE, status=429) with pytest.raises(AvitoRateLimitedError) as exc_info: await fetch_detail( "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890", browser_fetcher=bf ) assert not isinstance(exc_info.value, AvitoBlockedError) # ── (b) None-статус ничего не ломает — старое поведение сохранено ─────────────── @pytest.mark.asyncio async def test_browser_none_status_does_not_break_normal_parse() -> None: """Сайдкар старой версии / goto без статуса — фетч ведёт себя как ДО фикса.""" bf = _bf(_MIN_HTML, status=None) enrichment = await fetch_detail( "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890", browser_fetcher=bf ) assert enrichment.item_id == "1234567890" @pytest.mark.asyncio async def test_browser_none_status_still_falls_back_to_html_heuristics() -> None: """None-статус НЕ подавляет остальные проверки — soft-block-страница всё ещё блок.""" bf = _bf(_UNRECOGNIZED_BLOCK_PAGE, status=None) with pytest.raises(AvitoBlockedError): await fetch_detail( "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890", browser_fetcher=bf ) # ── (c) 404 остаётся AvitoListingGoneError, даже если статус — блокирующий ────── @pytest.mark.asyncio async def test_browser_404_page_wins_over_block_status() -> None: """Проверка статуса стоит ПОСЛЕ _is_detail_not_found: 404-текст не должен маскироваться под блок, даже если last_response_status совпал с одним из блокирующих кодов (площадка вполне может отдать 404-текст с любым HTTP-кодом). """ bf = _bf(_NOT_FOUND_HTML, status=403) with pytest.raises(AvitoListingGoneError): await fetch_detail( "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890", browser_fetcher=bf ) # ── (d) новый title-маркер опознаётся _is_detail_soft_block ───────────────────── def test_new_title_marker_is_recognized_as_soft_block() -> None: """Маркер снят вживую 31.08.2026 — старый ('объявления на сайте авито') в этом title НЕ встречается подстрокой, поэтому нужен отдельный маркер, а не расширение старого. """ assert _is_detail_soft_block(_UNRECOGNIZED_BLOCK_PAGE) is True def test_old_title_marker_still_recognized() -> None: """Старый маркер не удалён — площадка может отдавать обе формы заглушки.""" old_style = "Авито — Объявления на сайте Авито" assert _is_detail_soft_block(old_style) is True