fix(tradein/avito): detect HTTP-200 soft-block → rotate + coords-priority backfill ordering (#1950, #1967)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m26s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
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.
This commit is contained in:
parent
2184453d8e
commit
ca4637e5c8
4 changed files with 142 additions and 5 deletions
|
|
@ -279,6 +279,34 @@ def _build_detail_session() -> AsyncSession:
|
|||
)
|
||||
|
||||
|
||||
# ── Soft-block detection (#1967 / #1950) ──────────────────────────────────────
|
||||
# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ
|
||||
# витриной (<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного
|
||||
# объявления — мягкий анти-бот 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-витринный <title>.
|
||||
"""
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
"""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
"""Soft-block (HTTP 200 generic-витрина) detection на detail-странице (#1967 / #1950).
|
||||
|
||||
Под нагрузкой Avito иногда отдаёт HTTP 200 с ОБЩЕЙ витриной
|
||||
(<title>Авито — Объявления на сайте Авито</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 = (
|
||||
'<!DOCTYPE html><html lang="ru"><head>'
|
||||
"<title>Авито — Объявления на сайте Авито</title>"
|
||||
"</head><body><div>...</div></body></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, "<html/>"))
|
||||
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 = "<html><body><span data-marker='item-view/item-id'>№ 123</span></body></html>"
|
||||
assert detail_mod._is_detail_soft_block(item_view) is False
|
||||
assert detail_mod._is_detail_soft_block("<html/>") is False
|
||||
assert detail_mod._is_detail_soft_block("") is False
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue