fix(tradein): avito _extract_total_count returns 0 on no-results page — stop empty-bracket split/rotate waste #1763

Merged
lekss361 merged 1 commit from fix/avito-empty-page-total-zero into main 2026-06-18 17:05:35 +00:00
2 changed files with 62 additions and 13 deletions

View file

@ -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,

View file

@ -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 = "<html><head><title>Доступ ограничен</title></head></html>"
assert scraper._extract_total_count(html) is None
def test_extract_total_count_no_results_marker_returns_zero(scraper: AvitoScraper) -> None:
"""Валидная пустая выборка: нет `page-title/count`, но есть «ничего не найдено» → 0.
Даже если на странице есть fallback-«похожие» объявления (item-id'ы) — это
не подходящие под фильтр листинги, total всё равно 0. Это предотвращает
дробление пустого брекета + ротацию IP (прод-инцидент run 209).
"""
html = (
'<div data-marker="item" data-item-id="1234567"></div>'
'<div data-marker="item" data-item-id="7654321"></div>'
"<p>По вашему запросу ничего не найдено</p>"
)
assert scraper._extract_total_count(html) == 0
def test_extract_total_count_no_results_marker_case_insensitive(scraper: AvitoScraper) -> None:
"""Маркер регистронезависим: «Ничего не найдено» с заглавной → тоже 0."""
html = "<div><h2>Ничего не найдено</h2><p>Попробуйте изменить фильтры</p></div>"
assert scraper._extract_total_count(html) == 0
def test_extract_total_count_count_wins_over_no_results(scraper: AvitoScraper) -> None:
"""Непустая выборка с count имеет приоритет, даже если где-то ниже есть маркер."""
html = (
f"<div><h1>Квартиры</h1>{_REAL_COUNT_SPAN}</div>"
"<footer>Если ничего не найдено, измените запрос</footer>"
)
assert scraper._extract_total_count(html) == 1178
# ── Бисекция: split / paginate / dedup ────────────────────────────────────────