From ca4637e5c8e5239a7d1dd1f12e360f7fc5669e39 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 28 Jun 2026 08:06:12 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito):=20detect=20HTTP-200=20soft-?= =?UTF-8?q?block=20=E2=86=92=20rotate=20+=20coords-priority=20backfill=20o?= =?UTF-8?q?rdering=20(#1950,=20#1967)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-block (HTTP 200 generic витрина без item-view) теперь детектится как блок → существующая 403/firewall reconnect-машинерия → ротация exit-IP вместо silent failed (blocked=0, enriched→0 каскад). + snapshot ORDER BY (lat IS NULL) DESC для приоритизации coord-holes (#1967, in-scope 321). Tests: test_avito_detail_soft_block.py. Full tradein suite 2591 passed. --- .../app/services/scrapers/avito_detail.py | 41 ++++++- .../app/tasks/avito_detail_backfill.py | 4 +- .../scrapers/test_avito_detail_soft_block.py | 101 ++++++++++++++++++ .../tests/tasks/test_avito_detail_backfill.py | 1 + 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index a17d61ad..239ed334 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -279,6 +279,34 @@ def _build_detail_session() -> AsyncSession: ) +# ── Soft-block detection (#1967 / #1950) ────────────────────────────────────── +# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ +# витриной (Авито — Объявления на сайте Авито) вместо запрошенного +# объявления — мягкий анти-бот deflect. У такой страницы НЕТ ни firewall-маркеров +# (_is_firewall_page=False), ни item-view → parse_detail_html падал +# 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 = ("объявления на сайте авито",) + + +def _is_detail_soft_block(html: str) -> bool: + """True если detail-fetch вернул HTTP 200, но это анти-бот deflect — общая + витрина Авито вместо объявления. + + Сигнатура строго специфична deflect-витрине, чтобы НЕ задевать легитимные + не-блок ответы: + * нормальное объявление содержит item-view-маркеры → False; + * снятое объявление Avito рендерит как item-view + баннер «снято» → False; + * 404 / не-200 обрабатывается выше (ValueError → failed), сюда не попадает. + Положительный сигнал: НЕТ item-view-структуры И generic-витринный . + """ + if "item-view/item-id" in html or "item-view/title-info" in html: + return False + head = html[:16384].lower() + return any(m in head for m in _DETAIL_SOFT_BLOCK_TITLE_MARKERS) + + # ── fetch_detail ────────────────────────────────────────────────────────────── async def fetch_detail( item_url: str, @@ -313,8 +341,10 @@ async def fetch_detail( raise AvitoBlockedError( f"Avito detail browser fetch failed for {full_url}: {exc}" ) from exc - if _is_firewall_page(html): - raise AvitoBlockedError(f"Avito detail firewall (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}" + ) return parse_detail_html(html, full_url) from app.core.config import settings @@ -344,7 +374,9 @@ async def fetch_detail( while True: response = await attempt_session.get(full_url) sc = response.status_code - is_firewall = sc == 200 and _is_firewall_page(response.text) + is_firewall = sc == 200 and ( + _is_firewall_page(response.text) or _is_detail_soft_block(response.text) + ) # 429 через backconnect = transient conn-limit, НЕ IP-ban: короткий retry той # же сессии без reconnect (зеркало SERP). Только при исчерпании — raise. @@ -371,7 +403,8 @@ async def fetch_detail( retry_session = _build_detail_session() attempt_session = retry_session logger.info( - "avito detail 403/firewall %s — backconnect reconnect retry %d/%d", + "avito detail 403/firewall/soft-block %s — backconnect reconnect " + "retry %d/%d", full_url, r403, _AVITO_DETAIL_403_MAX_RETRIES, diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index a6f2e625..ee978633 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -159,7 +159,9 @@ async def run_avito_detail_backfill( AND source_url IS NOT NULL AND is_active = TRUE AND source_url LIKE '%/ekaterinburg/%' - ORDER BY scraped_at DESC NULLS LAST + -- сперва листинги без координат (#1967 — detail-страница даёт + -- координаты здания), затем по свежести + ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ ), diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py new file mode 100644 index 00000000..1d974a46 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_soft_block.py @@ -0,0 +1,101 @@ +"""Soft-block (HTTP 200 generic-витрина) detection на detail-странице (#1967 / #1950). + +Под нагрузкой Avito иногда отдаёт HTTP 200 с ОБЩЕЙ витриной +(<title>Авито — Объявления на сайте Авито) вместо запрошенного объявления — +мягкий анти-бот deflect. У такой страницы нет ни firewall-маркеров, ни item-view → +parse_detail_html ронял ValueError → generic failed, IP НЕ ротировался, enriched +каскадно падал к 0. _is_detail_soft_block детектит её как блок → существующая +403/firewall reconnect-машинерия пересоздаёт эфемерную сессию (свежий exit-IP). + +Зеркало fixture-стиля test_avito_detail_403_reconnect.py. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings +from app.services.scrapers import avito_detail as detail_mod +from app.services.scrapers.avito_detail import fetch_detail +from app.services.scrapers.avito_exceptions import AvitoBlockedError + +_SENTINEL = object() +_SOFT_BLOCK_HTML = ( + '' + "Авито — Объявления на сайте Авито" + "
...
" +) + + +def _resp(status_code: int, text: str = "") -> MagicMock: + r = MagicMock() + r.status_code = status_code + r.text = text + return r + + +def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619") + + +@pytest.fixture(autouse=True) +def _patch_common() -> object: + """asyncio.sleep → no-op; parse_detail_html → sentinel (нет нужды в реальном HTML).""" + with ( + patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()), + patch( + "app.services.scrapers.avito_detail.parse_detail_html", return_value=_SENTINEL + ) as parse, + ): + yield parse + + +@pytest.mark.asyncio +async def test_soft_block_200_reconnect_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + """soft-block(HTTP 200) → 200: generic-витрина детектится как блок → reconnect.""" + _enable_backconnect(monkeypatch) + + caller = AsyncMock() + caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML)) + retry1 = AsyncMock() + retry1.get = AsyncMock(return_value=_resp(200, "")) + build = MagicMock(side_effect=[retry1]) + + with patch.object(detail_mod, "_build_detail_session", build): + result = await fetch_detail("/x", cffi_session=caller) + + assert result is _SENTINEL + assert build.call_count == 1 + caller.close.assert_not_called() + + +@pytest.mark.asyncio +async def test_soft_block_exhausted_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None: + """soft-block на каждом attempt _AVITO_DETAIL_403_MAX_RETRIES+1 раз → AvitoBlockedError.""" + _enable_backconnect(monkeypatch) + + caller = AsyncMock() + caller.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML)) + + def _make_retry() -> AsyncMock: + s = AsyncMock() + s.get = AsyncMock(return_value=_resp(200, _SOFT_BLOCK_HTML)) + return s + + build = MagicMock(side_effect=lambda: _make_retry()) + + with patch.object(detail_mod, "_build_detail_session", build): + with pytest.raises(AvitoBlockedError): + await fetch_detail("/x", cffi_session=caller) + + assert build.call_count == detail_mod._AVITO_DETAIL_403_MAX_RETRIES + caller.close.assert_not_called() + + +def test_is_detail_soft_block_unit() -> None: + """Юнит _is_detail_soft_block: generic-витрина → True; item-view / пусто → False.""" + assert detail_mod._is_detail_soft_block(_SOFT_BLOCK_HTML) is True + item_view = "№ 123" + assert detail_mod._is_detail_soft_block(item_view) is False + assert detail_mod._is_detail_soft_block("") is False + assert detail_mod._is_detail_soft_block("") is False diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 5e1c66d7..c285f1ba 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -242,6 +242,7 @@ async def test_backfill_snapshot_filters_ekb_active_only() -> None: assert "is_active = TRUE" in sql_text assert "/ekaterinburg/" in sql_text assert "detail_enriched_at IS NULL" in sql_text + assert "(lat IS NULL) DESC" in sql_text @pytest.mark.asyncio