diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index c7a9bf4e..447a4483 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -263,6 +263,15 @@ _TOTAL_COUNT_RE = re.compile( flags=re.I, ) +# Маркер валидной ПУСТОЙ выборки. На 0-результатной странице (напр. студии с +# pmin=17M, которых нет) Avito отдаёт HTTP 200 БЕЗ `page-title/count`, но с +# текстом «ничего не найдено» И ~50 fallback-«похожими» объявлениями (НЕ +# подходящими под ценовой фильтр). Без этого маркера _extract_total_count +# возвращал None → resilience-логика дробила пустой брекет + ротировала IP +# (прод-инцидент run 209: 14+ мин в пустых верхних брекетах, errors=1058). +# Lowercase substring «ничего не найден» ловит «найдено»/«найдены»/любой регистр. +_AVITO_NO_RESULTS_MARKERS = ("ничего не найден",) + def _is_firewall_page(html: str) -> bool: """True если Avito вернул firewall-страницу IP-блока (на HTTP 200).""" @@ -641,21 +650,31 @@ class AvitoScraper(BaseScraper): Это аналог cian totalOffers — gate для бисекции (total > cap → split). Парсинг: текст span → убрать NBSP (\xa0)/пробелы/` ` → int. + Порядок: сначала `page-title/count` (его несёт ТОЛЬКО непустая выборка), + потом no-results-маркер → 0 (валидная пустая выборка: брекет скипается без + split/rotation и без скрапа ~50 fallback-«похожих»), и только при отсутствии + обоих — None (реально битая/captcha/firewall/незагруженная страница). + Returns: - int при успехе (включая 0), либо None при captcha/firewall/missing span. + int при успехе (включая 0 на no-results-странице), либо None при + captcha/firewall/незагруженной странице (ни count, ни no-results). """ m = _TOTAL_COUNT_RE.search(html) - if m is None: - return None - raw = m.group("count") - # Убрать NBSP (\xa0), -entity и обычные пробелы-разделители разрядов. - cleaned = raw.replace("\xa0", "").replace(" ", "").replace(" ", "") - if not cleaned: - return None - try: - return int(cleaned) - except (TypeError, ValueError): - return None + if m is not None: + raw = m.group("count") + # Убрать NBSP (\xa0), -entity и обычные пробелы-разделители разрядов. + cleaned = raw.replace("\xa0", "").replace(" ", "").replace(" ", "") + if cleaned: + try: + return int(cleaned) + except (TypeError, ValueError): + pass + # Счётчик не найден/не распарсился. Проверяем no-results-маркер: его наличие + # = валидная пустая выборка (total=0), несмотря на ~50 fallback-объявлений. + lowered = html.lower() + if any(marker in lowered for marker in _AVITO_NO_RESULTS_MARKERS): + return 0 + return None async def _fetch_rooms_page_html( self, diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_exhaustive.py b/tradein-mvp/backend/tests/scrapers/test_avito_exhaustive.py index fbfa8089..f86ffa33 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_exhaustive.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_exhaustive.py @@ -110,11 +110,41 @@ def test_extract_total_count_zero(scraper: AvitoScraper) -> None: def test_extract_total_count_missing_returns_none(scraper: AvitoScraper) -> None: - """Captcha/firewall (нет span) → None.""" + """Captcha/firewall (нет span, нет no-results-маркера) → None.""" html = "
По вашему запросу ничего не найдено
" + ) + assert scraper._extract_total_count(html) == 0 + + +def test_extract_total_count_no_results_marker_case_insensitive(scraper: AvitoScraper) -> None: + """Маркер регистронезависим: «Ничего не найдено» с заглавной → тоже 0.""" + html = "Попробуйте изменить фильтры