fix(tradein/avito): reap dead 404 listings in detail backfill (don't trip block breaker) #2047

Merged
lekss361 merged 1 commit from fix/tradein-avito-detail-dead-listing-reaper into main 2026-06-28 11:16:58 +00:00
Owner

Refs #1950 #1967.

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

После перевода detail-backfill на browser-режим (Camoufox + новый прокси) прод-прогон run 458 дал attempted=5 enriched=0 blocked=5 — брейкер consecutive-blocks оборвал прогон, не дойдя до живых листингов.

Root cause: очередь lat-null pending забита мёртвыми листингами (HTTP 404 — потому они и висят как coord-holes: давно сняты). В browser-режиме страница-404 («Ошибка 404. Страница не найдена») не содержит item-view, поэтому _is_detail_soft_block(html) возвращает Truefetch_detail бросает AvitoBlockedError → backfill считает это blocked и инкрементит счётчик consecutive-blocks → брейкер abort. Подтверждённые мёртвые URL из run 458: kvartira-studiya_227_m_926_et._8242998446, 2-k._kvartira_573_m_610_et._7872931506 (обе отдают 404-страницу). В старом curl-режиме мёртвый листинг возвращал HTTP 302 → failed (брейкер не рвался), browser-режим рендерит 404-страницу → ложный block.

Фикс

  • AvitoListingGoneError (avito_exceptions.py) — подкласс AvitoError, НЕ AvitoBlockedError/AvitoRateLimitedError, чтобы block-trap его не ловил.
  • _is_detail_not_found(html) (avito_detail.py) — True если страница-404 (нет item-view И в первых 16 KB есть «страница не найдена»/«ошибка 404»). Item-view-guard первым → живой листинг и анти-бот-дефлект («объявления на сайте авито» без 404-текста) → False. Проверка вставлена в browser-ветку fetch_detail ПЕРЕД firewall/soft-block-чеком. Curl-путь не тронут (там 404 → non-200 → ValueErrorfailed).
  • backfill (avito_detail_backfill.py): новый счётчик gone; отдельный except AvitoListingGoneError перед block-trap — считает gone (НЕ blocked), consecutive_blocks не трогает (брейкер не рвётся), и помечает листинг is_active=FALSE через db.begin_nested() SAVEPOINT → мёртвый уходит из scope (snapshot фильтрует is_active=TRUE), очередь дренажится. Ложно-помеченный самозалечивается следующим SERP-свипом (re-insert is_active=TRUE).

Тесты

  • tests/scrapers/test_avito_detail_not_found.py (new): unit _is_detail_not_found (404→True; дефлект→False; item-view→False; пусто→False) + browser-mode fetch_detail на 404-html бросает AvitoListingGoneError (не AvitoBlockedError).
  • tests/tasks/test_avito_detail_backfill.py: test_backfill_listing_gone_marks_inactive_no_breaker — gone→gone==1, blocked==0, брейкер не abort'нул (_rotate_ip не вызван, mark_done один раз), UPDATE listings SET is_active=FALSE для row id.
  • Локально: ruff-100 clean, 17 passed.

Cross-caller проверка (code-review)

Все вызовы fetch_detail прослежены: scrape_pipeline.py (browser-режим) имеет trailing except Exception → новый тип безопасно поглощается в detail_failed (net-улучшение: мёртвый 404 больше не раздувает enrich_consecutive_blocks/не триггерит лишнюю ротацию). admin.py — curl-путь, нового типа не бросает. cian-вызовы — другой модуль.

Post-deploy smoke

После деплоя — browser-mode прогон на двух подтверждённо-мёртвых URL: ожидаем gone + is_active=FALSE вместо blocked; затем дренаж pending_nolat на живых листингах.

Follow-up (не в этом PR)

  • Durable reap: gone-UPDATE коммитится на следующем heartbeat/mark_done; промежуточный db.rollback() соседнего листинга может откатить накопленные gone (самокорректируется — re-detect в следующем snapshot).
  • Curl-mode reaping: 404 на curl-пути → failed (не reaped) — мёртвые остаются (брейкер там и так не рвётся).
Refs #1950 #1967. ## Проблема (прод-замер 2026-06-28) После перевода detail-backfill на browser-режим (Camoufox + новый прокси) прод-прогон **run 458** дал `attempted=5 enriched=0 blocked=5` — брейкер consecutive-blocks оборвал прогон, **не дойдя до живых листингов**. **Root cause:** очередь lat-null pending забита **мёртвыми листингами** (HTTP 404 — потому они и висят как coord-holes: давно сняты). В browser-режиме страница-404 («Ошибка 404. Страница не найдена») не содержит `item-view`, поэтому `_is_detail_soft_block(html)` возвращает `True` → `fetch_detail` бросает `AvitoBlockedError` → backfill считает это `blocked` и инкрементит счётчик consecutive-blocks → брейкер abort. Подтверждённые мёртвые URL из run 458: `kvartira-studiya_227_m_926_et._8242998446`, `2-k._kvartira_573_m_610_et._7872931506` (обе отдают 404-страницу). В старом curl-режиме мёртвый листинг возвращал HTTP 302 → `failed` (брейкер не рвался), browser-режим рендерит 404-страницу → ложный `block`. ## Фикс - **`AvitoListingGoneError`** (`avito_exceptions.py`) — подкласс `AvitoError`, **НЕ** `AvitoBlockedError`/`AvitoRateLimitedError`, чтобы block-trap его не ловил. - **`_is_detail_not_found(html)`** (`avito_detail.py`) — `True` если страница-404 (нет `item-view` И в первых 16 KB есть «страница не найдена»/«ошибка 404»). Item-view-guard первым → живой листинг и анти-бот-дефлект («объявления на сайте авито» без 404-текста) → `False`. Проверка вставлена в browser-ветку `fetch_detail` **ПЕРЕД** firewall/soft-block-чеком. Curl-путь не тронут (там 404 → non-200 → `ValueError` → `failed`). - **backfill** (`avito_detail_backfill.py`): новый счётчик `gone`; отдельный `except AvitoListingGoneError` **перед** block-trap — считает `gone` (НЕ `blocked`), `consecutive_blocks` не трогает (брейкер не рвётся), и помечает листинг `is_active=FALSE` через `db.begin_nested()` SAVEPOINT → мёртвый уходит из scope (snapshot фильтрует `is_active=TRUE`), очередь дренажится. Ложно-помеченный самозалечивается следующим SERP-свипом (re-insert `is_active=TRUE`). ## Тесты - `tests/scrapers/test_avito_detail_not_found.py` (new): unit `_is_detail_not_found` (404→True; дефлект→False; item-view→False; пусто→False) + browser-mode `fetch_detail` на 404-html бросает `AvitoListingGoneError` (не `AvitoBlockedError`). - `tests/tasks/test_avito_detail_backfill.py`: `test_backfill_listing_gone_marks_inactive_no_breaker` — gone→`gone==1`, `blocked==0`, брейкер не abort'нул (`_rotate_ip` не вызван, `mark_done` один раз), `UPDATE listings SET is_active=FALSE` для row id. - Локально: ruff-100 clean, `17 passed`. ## Cross-caller проверка (code-review) Все вызовы `fetch_detail` прослежены: `scrape_pipeline.py` (browser-режим) имеет trailing `except Exception` → новый тип безопасно поглощается в `detail_failed` (net-улучшение: мёртвый 404 больше не раздувает `enrich_consecutive_blocks`/не триггерит лишнюю ротацию). `admin.py` — curl-путь, нового типа не бросает. cian-вызовы — другой модуль. ## Post-deploy smoke После деплоя — browser-mode прогон на двух подтверждённо-мёртвых URL: ожидаем `gone` + `is_active=FALSE` вместо `blocked`; затем дренаж `pending_nolat` на живых листингах. ## Follow-up (не в этом PR) - Durable reap: gone-UPDATE коммитится на следующем heartbeat/mark_done; промежуточный `db.rollback()` соседнего листинга может откатить накопленные gone (самокорректируется — re-detect в следующем snapshot). - Curl-mode reaping: 404 на curl-пути → `failed` (не reaped) — мёртвые остаются (брейкер там и так не рвётся).
lekss361 added 1 commit 2026-06-28 11:15:53 +00:00
fix(tradein/avito): reap dead 404 listings in detail backfill (don't trip block breaker)
All checks were successful
CI / changes (pull_request) Successful in 7s
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
a50af40ad6
avito_detail_backfill (mode=browser) aborted prod run 458 with
attempted=5 enriched=0 blocked=5: the lat-null pending queue is dominated
by DEAD listings (404 — that's why they are stale coord-holes). In browser
mode a dead listing's 404 page ("Ошибка 404. Страница не найдена") has no
item-view, so _is_detail_soft_block returned True → AvitoBlockedError →
backfill counted it as `blocked` and the consecutive-block breaker aborted
the run before reaching live listings.

- New AvitoListingGoneError (subclass of common AvitoError, NOT of
  AvitoBlockedError/AvitoRateLimitedError) so the backfill block-trap
  does not catch it.
- New _is_detail_not_found() detector + browser-mode branch in fetch_detail,
  checked BEFORE firewall/soft-block (keyed on the 404 title so it does not
  swallow the generic soft-block deflect). Curl path unchanged (404 → 302/
  non-200 → ValueError → failed, as before).
- Backfill: new `gone` counter; dedicated except AvitoListingGoneError branch
  that is neutral to the consecutive-block breaker and marks the listing
  is_active=FALSE (SAVEPOINT) so it leaves the snapshot scope.

Tests: unit for _is_detail_not_found (404 True; soft-block/item-view/empty
False), browser-mode fetch_detail raising AvitoListingGoneError (not Blocked),
and backfill gone-row marks inactive without aborting the breaker.
lekss361 merged commit d651dac99a into main 2026-06-28 11:16:58 +00:00
lekss361 deleted branch fix/tradein-avito-detail-dead-listing-reaper 2026-06-28 11:16:58 +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#2047
No description provided.