fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297)
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
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
Замер прода 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 чист.
Замер намеренно жёстче прода (без прогрева сессии и органического перехода из
выдачи), поэтому доля таких отказов в проде из него НЕ следует — её покажет
счётчик после правки.
This commit is contained in:
parent
c4fcedda9a
commit
5c10aee4c2
2 changed files with 206 additions and 1 deletions
|
|
@ -0,0 +1,154 @@
|
|||
"""#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
|
||||
|
|
@ -116,6 +116,31 @@ _AVITO_DETAIL_429_MAX_RETRIES = 8
|
|||
_AVITO_DETAIL_429_BACKOFF_SEC = 1.5
|
||||
_AVITO_DETAIL_429_RECONNECT_RETRIES = 3
|
||||
|
||||
# HTTP-статусы, на которых browser-ветка fetch_detail (#3297) обязана поднять
|
||||
# AvitoBlockedError, а НЕ дать странице уйти в parse_detail_html/ValueError.
|
||||
# Источник каждого статуса — тот же, что у curl-ветки чуть ниже (сток #3297,
|
||||
# замер прода 31.08: 24 попытки/3 узла, 11 отказов площадки):
|
||||
# 403 — стандартный firewall-отказ (curl-путь уже обрабатывает как блок);
|
||||
# 439 — нестандартный код QRATOR-челленджа (см. комментарий у _AVITO_DETAIL_403_*
|
||||
# выше — тот же код на curl-пути идёт по 403-ветке).
|
||||
# До фикса browser-ветка НЕ читала browser_fetcher.last_response_status вовсе —
|
||||
# страница-заглушка (8172 байта, статус 439) уходила в parse_detail_html и падала
|
||||
# ValueError("Cannot extract item_id") — узел не ротировался, отказ площадки
|
||||
# считался generic-ошибкой парсинга.
|
||||
_AVITO_DETAIL_BROWSER_BLOCK_STATUSES = frozenset({403, 439})
|
||||
|
||||
# 429 стоит ОТДЕЛЬНО от блокирующих статусов и поднимает AvitoRateLimitedError.
|
||||
# Разница не косметическая: на AvitoBlockedError оркестратор один раз за прогон
|
||||
# сбрасывает тёплый browser-context (avito_detail_backfill.py, #3251), а
|
||||
# AvitoRateLimitedError его не трогает. Для rate-limit сброс вреден — контекст
|
||||
# ЦЕЛ (в нём живёт пройденный QRATOR-PoW), площадка лишь просит сбавить темп;
|
||||
# выбросив контекст, пришлось бы проходить проверку заново с того же IP.
|
||||
# Зеркалит curl-ветку ниже: там 429 — тоже не блок, а короткий retry, и лишь при
|
||||
# исчерпании попыток AvitoRateLimitedError.
|
||||
# NB: 429 С маркерами бан-страницы сюда не доходит вовсе — сайдкар поднимает
|
||||
# SidecarBanPageError ещё в fetch (замер 31.08 поймал 2 таких).
|
||||
_AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429
|
||||
|
||||
# ── Regex ────────────────────────────────────────────────────────────────────
|
||||
_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
|
||||
_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
|
||||
|
|
@ -437,7 +462,16 @@ async def build_warmed_session(config: ScraperConfig | None = None) -> AsyncSess
|
|||
# ValueError("Cannot extract item_id") → generic failed, IP НЕ ротировался, enriched
|
||||
# каскадно падал к 0 (прод runs 431→437: 115→82→8→2→1→0 при blocked=0). Детектим
|
||||
# как блок → reconnect свежей exit-IP сессией / loop-ротация.
|
||||
_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",)
|
||||
# 31.08.2026 (#3297): прод-замер (24 попытки/3 узла) поймал 3 отказа с title
|
||||
# «Доска объявлений от частных лиц и компаний на Авито» (статус 439, 8172 байта) —
|
||||
# подстрока старого маркера в нём НЕ встречается, все три ушли в ValueError вместо
|
||||
# soft-block. Старый маркер НЕ убираем: площадка отдаёт обе формы заглушки, обе
|
||||
# держим. Маркеры завязаны на конкретный текст витрины и протухают при рестайле —
|
||||
# если титул снова сменится, конструкция та же (добавить, не заменить).
|
||||
_DETAIL_SOFT_BLOCK_TITLE_MARKERS = (
|
||||
"объявления на сайте авито",
|
||||
"доска объявлений от частных лиц и компаний на авито",
|
||||
)
|
||||
|
||||
|
||||
def _is_detail_soft_block(html: str) -> bool:
|
||||
|
|
@ -561,6 +595,23 @@ async def fetch_detail(
|
|||
raise AvitoListingGoneError(
|
||||
f"Avito detail 404/listing-gone (browser-mode) for {full_url}"
|
||||
)
|
||||
# #3297: browser-ветка раньше НИКОГДА не читала last_response_status — отказ
|
||||
# площадки (403/429/439) с телом-заглушкой без известных маркеров доезжал до
|
||||
# parse_detail_html и падал ValueError("Cannot extract item_id"), т.е. отказ
|
||||
# ПЛОЩАДКИ считался обычной ошибкой парсинга и узел не ротировался (зеркало
|
||||
# curl-ветки ниже: `if sc in (403, 439) or is_firewall`). None — сайдкар
|
||||
# старой версии/goto не вернул статус — НЕ считаем отказом, поведение как
|
||||
# было (fallback на html-эвристики ниже).
|
||||
status = browser_fetcher.last_response_status
|
||||
if status is not None:
|
||||
if status in _AVITO_DETAIL_BROWSER_BLOCK_STATUSES:
|
||||
raise AvitoBlockedError(
|
||||
f"Avito detail HTTP {status} (browser-mode) for {full_url}"
|
||||
)
|
||||
if status == _AVITO_DETAIL_BROWSER_RATELIMIT_STATUS:
|
||||
raise AvitoRateLimitedError(
|
||||
f"Avito detail HTTP {status} (browser-mode) for {full_url}"
|
||||
)
|
||||
if _is_firewall_page(html) or _is_detail_soft_block(html):
|
||||
raise AvitoBlockedError(
|
||||
f"Avito detail firewall/soft-block (browser-mode) for {full_url}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue