fix(tradein): avito _extract_total_count returns 0 on no-results page — stop empty-bracket split/rotate waste #1763
2 changed files with 62 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue