fix(tradein/avito): detect HTTP-200 soft-block → rotate (vs silent failed) + coords-priority backfill ordering #2034

Merged
lekss361 merged 1 commit from fix/tradein-avito-detail-coords-priority-firewall into main 2026-06-28 08:06:13 +00:00
Owner

Refs #1950 #1967.

Проблема (прод-замер 2026-06-28)

В каждом ночном avito_detail_backfill run blocked=0, при этом failed доминирует и enriched каскадно падает к 0:

run attempted enriched blocked failed
431 130 115 0 15
432 149 82 0 67
433 154 8 0 146
434 154 2 0 152
437 146 0 0 146

Блок-ветка не срабатывала никогда — весь анти-бот уходил в generic failed, IP не ротировался.

Root cause (подтверждён живым fetch с прод-прокси): под нагрузкой Avito вместо detail-страницы отдаёт HTTP 200 с общей витриной <title>Авито — Объявления на сайте Авито</title> (293 KB, без item-view) — мягкий анти-бот deflect. _is_firewall_page ловит лишь 3 IP-block маркера («доступ ограничен» / «проблема с ip» / «firewall-container») — этой витрины в них нет → parse_detail_html падает ValueError("Cannot extract item_id") → generic except Exceptionfailed, без ротации. Доминирующий лог: Cannot extract item_id from https://www.avito.ru/ekaterinburg/kvartiry/... (каждый URL ×5).

FIX 2 — soft-block → block → ротация

Новый _is_detail_soft_block(html): нет item-view/item-id/item-view/title-info И generic <title> в первых 16 KB. OR-ится в сигнал is_firewall в обоих путях fetch_detail (curl + browser). Эффект: soft-block проходит существующую 403/firewall reconnect-машинерию → пересоздание эфемерной сессии = свежий backconnect exit-IP → escape; либо после _AVITO_DETAIL_403_MAX_RETRIESAvitoBlockedError → блок-ветка backfill ротирует и сбрасывает consecutive_blocks при первом успехе.

Точность детекта (не задевает легит-ответы): реальное объявление содержит item-viewFalse (guard первым, до title-скана); снятое объявление Avito рендерит как item-view + баннер → False; 404/не-200 обрабатывается выше (ValueErrorfailed), сюда не доходит. Множество страниц, достигающих title-скана = ровно те, что и так падали бы ValueError — никогда не парсящееся объявление.

Взаимодействие с #1950 timeout: soft-block-страница возвращается ~1 c (293 KB за <1 c, замерено), 5 reconnect ≈ 16 c ≪ fetch_timeout_s=90s → raises AvitoBlockedError (ротация), не режется в TimeoutError→failed.

FIX 1 — coords-priority ordering (#1967)

Snapshot SELECT: ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST — сперва листинги без координат (detail-страница даёт точные координаты здания), затем по свежести. lat — колонка listings, в ORDER BY без добавления в SELECT.

Честная сверка scope: из 13 832 active avito с detail NULL + lat NULL в snapshot-scope (/ekaterinburg/-URL, #1814) попадает лишь 321; остальные 13 511 — non-EKB URL (moskva/spb/tyumen legacy), намеренно исключённые scope-фильтром #1814 (browser-fetch на legacy не-ЕКБ → curl-бан). Т.е. FIX 1 приоритизирует 321 in-scope coord-hole (гарантированно первыми каждый run вместо ~92 размазанных), а не 13 832 — bulk coord-gap вне snapshot scope by design и здесь не адресуется.

Тесты

tests/scrapers/test_avito_detail_soft_block.py (new): reconnect-escape (200 soft-block → reconnect → clean 200), exhausted → AvitoBlockedError (build.call_count == _AVITO_DETAIL_403_MAX_RETRIES), unit-детектор. + assert (lat IS NULL) DESC в snapshot-тесте. Локально: ruff-100 clean, 20 passed.

Post-deploy verify

Admin-триггер вне окна + проверка в БД: доля enriched/attempted ↑ vs runs 434-437 (~0-2/150), blocked > 0 (ротация включилась), обогащаются именно lat-null листинги. Замер до/после первого окна (next_run 10:39 UTC) — комментом в #1950.

Refs #1950 #1967. ## Проблема (прод-замер 2026-06-28) В **каждом** ночном `avito_detail_backfill` run `blocked=0`, при этом `failed` доминирует и `enriched` каскадно падает к 0: | run | attempted | enriched | blocked | failed | |----|----|----|----|----| | 431 | 130 | **115** | 0 | 15 | | 432 | 149 | 82 | 0 | 67 | | 433 | 154 | 8 | 0 | 146 | | 434 | 154 | 2 | 0 | 152 | | 437 | 146 | **0** | 0 | 146 | Блок-ветка не срабатывала **никогда** — весь анти-бот уходил в generic `failed`, IP не ротировался. **Root cause (подтверждён живым fetch с прод-прокси):** под нагрузкой Avito вместо detail-страницы отдаёт **HTTP 200** с общей витриной `<title>Авито — Объявления на сайте Авито</title>` (293 KB, без `item-view`) — мягкий анти-бот deflect. `_is_firewall_page` ловит лишь 3 IP-block маркера («доступ ограничен» / «проблема с ip» / «firewall-container») — этой витрины в них нет → `parse_detail_html` падает `ValueError("Cannot extract item_id")` → generic `except Exception` → `failed`, без ротации. Доминирующий лог: `Cannot extract item_id from https://www.avito.ru/ekaterinburg/kvartiry/...` (каждый URL ×5). ## FIX 2 — soft-block → block → ротация Новый `_is_detail_soft_block(html)`: **нет `item-view/item-id`/`item-view/title-info` И generic `<title>` в первых 16 KB**. OR-ится в сигнал `is_firewall` в обоих путях `fetch_detail` (curl + browser). Эффект: soft-block проходит **существующую** 403/firewall reconnect-машинерию → пересоздание эфемерной сессии = свежий backconnect exit-IP → escape; либо после `_AVITO_DETAIL_403_MAX_RETRIES` → `AvitoBlockedError` → блок-ветка backfill ротирует и сбрасывает `consecutive_blocks` при первом успехе. **Точность детекта (не задевает легит-ответы):** реальное объявление содержит `item-view` → `False` (guard первым, до title-скана); снятое объявление Avito рендерит как item-view + баннер → `False`; 404/не-200 обрабатывается выше (`ValueError`→`failed`), сюда не доходит. Множество страниц, достигающих title-скана = ровно те, что и так падали бы `ValueError` — никогда не парсящееся объявление. **Взаимодействие с #1950 timeout:** soft-block-страница возвращается ~1 c (293 KB за <1 c, замерено), 5 reconnect ≈ 16 c ≪ `fetch_timeout_s=90s` → raises `AvitoBlockedError` (ротация), не режется в `TimeoutError`→failed. ## FIX 1 — coords-priority ordering (#1967) Snapshot SELECT: `ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST` — сперва листинги без координат (detail-страница даёт точные координаты здания), затем по свежести. `lat` — колонка `listings`, в ORDER BY без добавления в SELECT. **Честная сверка scope:** из 13 832 active avito с `detail NULL + lat NULL` в snapshot-scope (`/ekaterinburg/`-URL, #1814) попадает лишь **321**; остальные **13 511 — non-EKB URL** (moskva/spb/tyumen legacy), намеренно исключённые scope-фильтром #1814 (browser-fetch на legacy не-ЕКБ → curl-бан). Т.е. FIX 1 приоритизирует **321 in-scope coord-hole** (гарантированно первыми каждый run вместо ~92 размазанных), а не 13 832 — bulk coord-gap вне snapshot scope by design и здесь не адресуется. ## Тесты `tests/scrapers/test_avito_detail_soft_block.py` (new): reconnect-escape (200 soft-block → reconnect → clean 200), exhausted → `AvitoBlockedError` (`build.call_count == _AVITO_DETAIL_403_MAX_RETRIES`), unit-детектор. + assert `(lat IS NULL) DESC` в snapshot-тесте. Локально: ruff-100 clean, `20 passed`. ## Post-deploy verify Admin-триггер вне окна + проверка в БД: доля enriched/attempted ↑ vs runs 434-437 (~0-2/150), `blocked > 0` (ротация включилась), обогащаются именно lat-null листинги. Замер до/после первого окна (next_run 10:39 UTC) — комментом в #1950.
lekss361 added 1 commit 2026-06-28 07:53:00 +00:00
fix(tradein/avito): detect HTTP-200 soft-block as block + coords-priority backfill ordering (#1950, #1967)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
81fc5e4014
Под нагрузкой 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 <title> в первых 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-тесте.
lekss361 merged commit ca4637e5c8 into main 2026-06-28 08:06:13 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2034
No description provided.