From 5c10aee4c25c7b37ffe751a084e7bbbc69afbb5d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 14:16:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20browser-=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=BE=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D0=BB=20=D0=BD?= =?UTF-8?q?=D0=B0=20HTTP-=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=E2=80=94?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BF=D0=BB=D0=BE=D1=89?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=BD=D0=B3=D0=B0=20(#3297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер прода 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 чист. Замер намеренно жёстче прода (без прогрева сессии и органического перехода из выдачи), поэтому доля таких отказов в проде из него НЕ следует — её покажет счётчик после правки. --- .../test_3297_avito_detail_browser_status.py | 154 ++++++++++++++++++ .../src/scraper_kit/providers/avito/detail.py | 53 +++++- 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_3297_avito_detail_browser_status.py diff --git a/tradein-mvp/backend/tests/test_3297_avito_detail_browser_status.py b/tradein-mvp/backend/tests/test_3297_avito_detail_browser_status.py new file mode 100644 index 00000000..e6de2270 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3297_avito_detail_browser_status.py @@ -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 = '
№ 1234567890
' + +# Ровно страница из прод-замера 31.08: нет item-view, нет старого soft-block-маркера +# ("объявления на сайте авито") — до фикса такая страница проваливалась мимо ВСЕХ +# известных детекторов прямиком в parse_detail_html/ValueError. +_UNRECOGNIZED_BLOCK_PAGE = ( + "Доска объявлений от частных лиц и компаний на Авито" + "антибот-заглушка без известных текстовых маркеров" +) + +_NOT_FOUND_HTML = ( + "Ошибка 404Страница не найдена" +) + + +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 = "Авито — Объявления на сайте Авито" + assert _is_detail_soft_block(old_style) is True diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 731cc468..6877d064 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -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}"