From 81fc5e401467bd239bde664d53f7c661b1ec6d98 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sun, 28 Jun 2026 10:51:18 +0300
Subject: [PATCH] fix(tradein/avito): detect HTTP-200 soft-block as block +
coords-priority backfill ordering (#1950, #1967)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Под нагрузкой Avito отдаёт HTTP 200 с общей витриной ("Авито — Объявления
на сайте Авито", без item-view) вместо detail-страницы — мягкий анти-бот
deflect. _is_firewall_page (3 IP-block маркера) её не ловил → parse падал
ValueError("Cannot extract item_id") → generic failed, blocked=0, IP не
ротировался, enriched каскадно падал к 0 (прод runs 431→437: 115→0).
FIX 2: _is_detail_soft_block() (нет item-view + generic в первых 16KB)
OR-ится в is_firewall в обоих путях fetch_detail (curl + browser) → soft-block
проходит существующую 403/firewall reconnect-машинерию (свежий backconnect
exit-IP) либо после MAX_RETRIES → AvitoBlockedError → loop-ротация.
FIX 1: snapshot ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST —
сперва листинги без координат (detail-страница даёт координаты здания, #1967).
Tests: test_avito_detail_soft_block.py (reconnect-escape, exhausted->blocked,
unit-detector) + lat-priority assert в snapshot-тесте.
---
.../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 с ОБЩЕЙ витриной
+(Авито — Объявления на сайте Авито) вместо запрошенного объявления —
+мягкий анти-бот 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("