gendesign/tradein-mvp/backend/tests/test_3297_avito_detail_browser_status.py
bot-backend 5c10aee4c2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297)
Замер прода 31.08.2026 (24 попытки через 3 узла) поймал три отказа, которые
доезжали до parse_detail_html и падали ValueError("Cannot extract item_id"):
страница 8172 байта, статус 439, title «Доска объявлений от частных лиц и
компаний на Авито». Отказ ПЛОЩАДКИ записывался generic-ошибкой разбора, узел
не ротировался и не банился, брейкер по доле его не видел.

Две независимые дыры, обе в browser-ветке fetch_detail:

1. last_response_status не читался вовсе. Curl-ветка того же файла статус
   проверяет (`if sc in (403, 439) or is_firewall`), browser-ветка смотрела
   только на HTML. Прод ходит именно browser-путём.

2. Маркер витрины-заглушки протух: искали «объявления на сайте авито», а
   фактический title — «доска объявлений от частных лиц и компаний на авито»,
   подстрока в нём не встречается.

Правка:

- новые константы _AVITO_DETAIL_BROWSER_BLOCK_STATUSES = {403, 439} и
  _AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429, источник каждого статуса
  назван комментарием;
- проверка стоит ПОСЛЕ _is_detail_not_found (404 остаётся
  AvitoListingGoneError) и ДО parse_detail_html;
- статус None (сайдкар старой версии, goto без статуса) отказом НЕ считается —
  поведение прежнее, фолбэк на html-эвристики;
- 429 разведён с блокирующими статусами и поднимает AvitoRateLimitedError.
  Разница не косметическая: на AvitoBlockedError оркестратор один раз за прогон
  зовёт request_context_reset (#3251) и выбрасывает пройденный QRATOR-PoW. При
  rate-limit контекст цел, сбрасывать его — значит проходить проверку заново с
  того же IP. Зеркалит curl-ветку, где 429 тоже не блок;
- старый title-маркер не удалён, а дополнен снятым вживую: площадка может
  отдавать обе формы.

Тесты (9): каждый статус по отдельности, None-статус не ломает разбор и не
подавляет html-эвристики, 404 побеждает блокирующий статус (порядок проверок),
оба title-маркера опознаются, 429 не является AvitoBlockedError.

Фальсификация: без правки detail.py 5 из 9 новых тестов падают.
Прогон: 397 passed, 1 skipped (-k "avito or cadence or scheduler"), ruff чист.

Замер намеренно жёстче прода (без прогрева сессии и органического перехода из
выдачи), поэтому доля таких отказов в проде из него НЕ следует — её покажет
счётчик после правки.
2026-08-31 14:16:47 +03:00

154 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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 = '<html><body><div data-marker="item-view/item-id">№ 1234567890</div></body></html>'
# Ровно страница из прод-замера 31.08: нет item-view, нет старого soft-block-маркера
# ("объявления на сайте авито") — до фикса такая страница проваливалась мимо ВСЕХ
# известных детекторов прямиком в parse_detail_html/ValueError.
_UNRECOGNIZED_BLOCK_PAGE = (
"<html><head><title>Доска объявлений от частных лиц и компаний на Авито"
"</title></head><body>антибот-заглушка без известных текстовых маркеров</body></html>"
)
_NOT_FOUND_HTML = (
"<html><head><title>Ошибка 404</title></head><body>Страница не найдена</body></html>"
)
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 = "<html><head><title>Авито — Объявления на сайте Авито</title></head><body/></html>"
assert _is_detail_soft_block(old_style) is True