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, "№ 123"
+ assert detail_mod._is_detail_soft_block(item_view) is False
+ assert detail_mod._is_detail_soft_block("