From f7deed096bcf17221e754a09bd2ed9af1010a4b6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 20:01:50 +0000 Subject: [PATCH] fix(scrapers): avito per-card parse_failures counter + logging (Refs #823) (#830) Co-authored-by: bot-backend Co-committed-by: bot-backend --- .../backend/app/services/scrapers/avito.py | 12 ++ .../fixtures/avito_blockpage_sample.html | 14 ++ .../backend/tests/test_avito_blockpage.py | 125 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tradein-mvp/backend/tests/fixtures/avito_blockpage_sample.html create mode 100644 tradein-mvp/backend/tests/test_avito_blockpage.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 00380ed9..53743593 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -207,6 +207,8 @@ class AvitoScraper(BaseScraper): super().__init__() self.request_delay_sec = get_scraper_delay(self.name) self._cffi: AsyncSession | None = None + # #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM. + self.parse_failures: int = 0 async def __aenter__(self) -> AvitoScraper: await super().__aenter__() @@ -532,6 +534,16 @@ class AvitoScraper(BaseScraper): listing_segment=listing_segment, ) except Exception: + # #823: логируем каждую сломанную карточку и считаем для observability. + # Карточки с неожиданной DOM-структурой скипаем, но не роняем весь парс. + self.parse_failures += 1 + item_id_attr = getattr(card, "attributes", {}).get("data-item-id", "?") + logger.warning( + "avito _dom_card_to_lot: failed to parse card item_id=%s (parse_failures=%d)", + item_id_attr, + self.parse_failures, + exc_info=True, + ) return None diff --git a/tradein-mvp/backend/tests/fixtures/avito_blockpage_sample.html b/tradein-mvp/backend/tests/fixtures/avito_blockpage_sample.html new file mode 100644 index 00000000..29d4b0a6 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_blockpage_sample.html @@ -0,0 +1,14 @@ + + + + +Доступ ограничен: проблема с IP + + +
+

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

+

К сожалению, запросы с вашего IP-адреса выглядят автоматическими. + Попробуйте зайти позже или используйте другой способ подключения.

+
+ + diff --git a/tradein-mvp/backend/tests/test_avito_blockpage.py b/tradein-mvp/backend/tests/test_avito_blockpage.py new file mode 100644 index 00000000..1d99cc8c --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_blockpage.py @@ -0,0 +1,125 @@ +"""#823 — детект HTTP-200 block/captcha страницы и parse_failures счётчик. + +Тест 1: _is_firewall_page возвращает True на fixtures/avito_blockpage_sample.html. +Тест 2: _parse_html на блок-странице возвращает 0 лотов (нет карточек). +Тест 3: fetch_around бросает AvitoContentBlockedError когда page=1 даёт 0 лотов + (гарантия что silent 0-done НЕ происходит — основной DoD #823). +Тест 4: _dom_card_to_lot инкрементирует parse_failures при сломанной карточке. + +Без сети, без БД, без curl_cffi. +""" + +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from selectolax.parser import HTMLParser + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.scrapers.avito import AvitoScraper, _is_firewall_page +from app.services.scrapers.avito_exceptions import AvitoContentBlockedError + +FIXTURES = Path(__file__).parent / "fixtures" +BLOCKPAGE_HTML = (FIXTURES / "avito_blockpage_sample.html").read_text(encoding="utf-8") +SERP_HTML = (FIXTURES / "avito_serp_sample.html").read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# 1. _is_firewall_page детектит блок-маркер в HTML +# --------------------------------------------------------------------------- + + +def test_is_firewall_page_detects_block_fixture() -> None: + """Блок-страница с 'доступ ограничен' и 'firewall-container' → True.""" + assert _is_firewall_page(BLOCKPAGE_HTML) is True + + +def test_is_firewall_page_false_for_normal_serp() -> None: + """Нормальная SERP страница не является блок-страницей.""" + assert _is_firewall_page(SERP_HTML) is False + + +def test_is_firewall_page_false_for_empty_html() -> None: + assert _is_firewall_page("") is False + + +# --------------------------------------------------------------------------- +# 2. _parse_html на блок-странице даёт 0 лотов (нет data-marker="item") +# --------------------------------------------------------------------------- + + +def test_parse_html_block_page_returns_empty_list() -> None: + """Блок-страница не содержит карточек — парсер возвращает [].""" + scraper = AvitoScraper() + lots = scraper._parse_html(BLOCKPAGE_HTML, "https://www.avito.ru/x") + assert lots == [] + + +# --------------------------------------------------------------------------- +# 3. fetch_around raises AvitoContentBlockedError при 0 лотах на page=1 +# (гарантия что silent 0-done НЕ происходит — основной DoD #823) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_around_raises_on_zero_lots_page1() -> None: + """HTTP-200 + пустой SERP (блок/captcha) на page=1 → AvitoContentBlockedError. + + Мокаем _fetch_serp_html чтобы вернуть блок-страницу без сети. + """ + scraper = AvitoScraper() + with patch.object( + scraper, + "_fetch_serp_html", + new=AsyncMock(return_value=BLOCKPAGE_HTML), + ): + with pytest.raises(AvitoContentBlockedError): + await scraper.fetch_around(56.838, 60.605, radius_m=1000, pages=1) + + +# --------------------------------------------------------------------------- +# 4. parse_failures счётчик инкрементируется при сломанной карточке +# --------------------------------------------------------------------------- + + +def test_parse_failures_starts_at_zero() -> None: + scraper = AvitoScraper() + assert scraper.parse_failures == 0 + + +def test_parse_failures_not_incremented_on_link_missing() -> None: + """Карточка без item-title link → ранний return None, НЕ через except.""" + scraper = AvitoScraper() + broken_html = '
no link
' + tree = HTMLParser(broken_html) + cards = tree.css('[data-marker="item"]') + result = scraper._dom_card_to_lot(cards[0], "https://www.avito.ru/x") + assert result is None + assert scraper.parse_failures == 0 # ранний return, не exception path + + +def test_parse_failures_incremented_on_exception() -> None: + """css_first бросает TypeError → except path → parse_failures++.""" + scraper = AvitoScraper() + bad_card = MagicMock() + bad_card.css_first.side_effect = TypeError("simulated DOM error") + bad_card.attributes = {} + + result = scraper._dom_card_to_lot(bad_card, "https://www.avito.ru/x") + assert result is None + assert scraper.parse_failures == 1 # #823: счётчик вырос + + +def test_parse_failures_accumulates_across_cards() -> None: + """Несколько сломанных карточек аккумулируются в одном счётчике.""" + scraper = AvitoScraper() + + for _ in range(3): + bad_card = MagicMock() + bad_card.css_first.side_effect = RuntimeError("bad card") + bad_card.attributes = {} + scraper._dom_card_to_lot(bad_card, "https://www.avito.ru/x") + + assert scraper.parse_failures == 3