fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297) #3300

Merged
lekss361 merged 1 commit from fix/3297-avito-browser-status-check into main 2026-08-31 11:36:20 +00:00
Owner

Закрывает #3297.

Что происходило

Замер прода 31.08 (24 попытки через 3 узла) поймал три отказа, доезжавших до parse_detail_html и падавших ValueError("Cannot extract item_id"): страница 8172 байта, статус 439, title «Доска объявлений от частных лиц и компаний на Авито». Отказ площадки записывался generic-ошибкой разбора — узел не ротировался, не банился, брейкер по доле его не видел.

Две независимые дыры, обе в browser-ветке fetch_detail:

  1. last_response_status не читался вовсе. Curl-ветка того же файла статус проверяет (if sc in (403, 439) or is_firewall), browser-ветка смотрела только на HTML. Прод ходит именно browser-путём.
  2. Маркер витрины-заглушки протух: искали «объявления на сайте авито», фактический title — «доска объявлений от частных лиц и компаний на авито», подстрока в нём не встречается (проверено построчно).

Правка

  • _AVITO_DETAIL_BROWSER_BLOCK_STATUSES = {403, 439} и _AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429, источник каждого статуса назван комментарием.
  • Проверка стоит после _is_detail_not_found (404 остаётся AvitoListingGoneError) и до parse_detail_html.
  • None-статус отказом не считается — поведение прежнее, фолбэк на html-эвристики.
  • Старый title-маркер не удалён, а дополнен снятым вживую: площадка может отдавать обе формы.

Почему 429 отдельно от 403/439

Разница не косметическая. На AvitoBlockedError оркестратор один раз за прогон зовёт request_context_reset() (#3251) и выбрасывает пройденный QRATOR-PoW. При rate-limit контекст цел, площадка лишь просит сбавить темп — сбросив контекст, мы заставим проходить проверку заново с того же IP. Curl-ветка разводит эти случаи так же: 429 → короткий retry, при исчерпании AvitoRateLimitedError.

Замечание пришло от ревьюера; первая версия поднимала AvitoBlockedError на все три статуса.

Тесты — 9

Каждый статус по отдельности; None не ломает разбор и не подавляет html-эвристики; 404 побеждает блокирующий статус (доказывает порядок проверок); оба title-маркера опознаются; 429 не является AvitoBlockedError.

Фальсификация: без правки detail.py 5 из 9 новых тестов падают — тесты проверяют новое поведение, а не переписаны под реализацию.

Прогон: 397 passed, 1 skipped (-k "avito or cadence or scheduler"), ruff чист.

Оговорка

Замер намеренно жёстче прода — без прогрева сессии и без органического перехода из выдачи. Поэтому доля таких отказов в проде из него не следует; её покажет счётчик blocked после выката.

Что этот PR НЕ чинит

Бан-страницы с маркерами (8×403, 2×429 в том же замере) сюда не доходят — сайдкар поднимает SidecarBanPageError ещё в fetch, и она уезжает в infra через общий except. Это #3288, отдельный заход. browser/server.py (_REFUSAL_STATUSES, где нет 439) намеренно не тронут.

Закрывает #3297. ## Что происходило Замер прода 31.08 (24 попытки через 3 узла) поймал три отказа, доезжавших до `parse_detail_html` и падавших `ValueError("Cannot extract item_id")`: страница **8172 байта, статус 439**, title «Доска объявлений от частных лиц и компаний на Авито». Отказ **площадки** записывался generic-ошибкой разбора — узел не ротировался, не банился, брейкер по доле его не видел. Две независимые дыры, обе в browser-ветке `fetch_detail`: 1. **`last_response_status` не читался вовсе.** Curl-ветка того же файла статус проверяет (`if sc in (403, 439) or is_firewall`), browser-ветка смотрела только на HTML. Прод ходит именно browser-путём. 2. **Маркер витрины-заглушки протух**: искали «объявления на сайте авито», фактический title — «доска объявлений от частных лиц и компаний на авито», подстрока в нём не встречается (проверено построчно). ## Правка - `_AVITO_DETAIL_BROWSER_BLOCK_STATUSES = {403, 439}` и `_AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429`, источник каждого статуса назван комментарием. - Проверка стоит **после** `_is_detail_not_found` (404 остаётся `AvitoListingGoneError`) и **до** `parse_detail_html`. - `None`-статус отказом не считается — поведение прежнее, фолбэк на html-эвристики. - Старый title-маркер не удалён, а дополнен снятым вживую: площадка может отдавать обе формы. ### Почему 429 отдельно от 403/439 Разница не косметическая. На `AvitoBlockedError` оркестратор один раз за прогон зовёт `request_context_reset()` (#3251) и выбрасывает **пройденный QRATOR-PoW**. При rate-limit контекст цел, площадка лишь просит сбавить темп — сбросив контекст, мы заставим проходить проверку заново с того же IP. Curl-ветка разводит эти случаи так же: 429 → короткий retry, при исчерпании `AvitoRateLimitedError`. Замечание пришло от ревьюера; первая версия поднимала `AvitoBlockedError` на все три статуса. ## Тесты — 9 Каждый статус по отдельности; `None` не ломает разбор и не подавляет html-эвристики; 404 побеждает блокирующий статус (доказывает порядок проверок); оба title-маркера опознаются; 429 **не** является `AvitoBlockedError`. **Фальсификация:** без правки `detail.py` 5 из 9 новых тестов падают — тесты проверяют новое поведение, а не переписаны под реализацию. Прогон: `397 passed, 1 skipped` (`-k "avito or cadence or scheduler"`), ruff чист. ## Оговорка Замер намеренно жёстче прода — без прогрева сессии и без органического перехода из выдачи. Поэтому **доля таких отказов в проде из него не следует**; её покажет счётчик `blocked` после выката. ## Что этот PR НЕ чинит Бан-страницы с маркерами (8×403, 2×429 в том же замере) сюда не доходят — сайдкар поднимает `SidecarBanPageError` ещё в `fetch`, и она уезжает в `infra` через общий `except`. Это #3288, отдельный заход. `browser/server.py` (`_REFUSAL_STATUSES`, где нет 439) намеренно не тронут.
lekss361 added 1 commit 2026-08-31 11:17:44 +00:00
fix(tradein/avito): browser-путь добора не смотрел на HTTP-статус — отказ площадки уходил в ошибку парсинга (#3297)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
5c10aee4c2
Замер прода 31.08.2026 (24 попытки через 3 узла) поймал три отказа, которые
доезжали до parse_detail_html и падали ValueError("Cannot extract item_id"):
страница 8172 байта, статус 439, title «Доска объявлений от частных лиц и
компаний на Авито». Отказ ПЛОЩАДКИ записывался generic-ошибкой разбора, узел
не ротировался и не банился, брейкер по доле его не видел.

Две независимые дыры, обе в browser-ветке fetch_detail:

1. last_response_status не читался вовсе. Curl-ветка того же файла статус
   проверяет (`if sc in (403, 439) or is_firewall`), browser-ветка смотрела
   только на HTML. Прод ходит именно browser-путём.

2. Маркер витрины-заглушки протух: искали «объявления на сайте авито», а
   фактический title — «доска объявлений от частных лиц и компаний на авито»,
   подстрока в нём не встречается.

Правка:

- новые константы _AVITO_DETAIL_BROWSER_BLOCK_STATUSES = {403, 439} и
  _AVITO_DETAIL_BROWSER_RATELIMIT_STATUS = 429, источник каждого статуса
  назван комментарием;
- проверка стоит ПОСЛЕ _is_detail_not_found (404 остаётся
  AvitoListingGoneError) и ДО parse_detail_html;
- статус None (сайдкар старой версии, goto без статуса) отказом НЕ считается —
  поведение прежнее, фолбэк на html-эвристики;
- 429 разведён с блокирующими статусами и поднимает AvitoRateLimitedError.
  Разница не косметическая: на AvitoBlockedError оркестратор один раз за прогон
  зовёт request_context_reset (#3251) и выбрасывает пройденный QRATOR-PoW. При
  rate-limit контекст цел, сбрасывать его — значит проходить проверку заново с
  того же IP. Зеркалит curl-ветку, где 429 тоже не блок;
- старый title-маркер не удалён, а дополнен снятым вживую: площадка может
  отдавать обе формы.

Тесты (9): каждый статус по отдельности, None-статус не ломает разбор и не
подавляет html-эвристики, 404 побеждает блокирующий статус (порядок проверок),
оба title-маркера опознаются, 429 не является AvitoBlockedError.

Фальсификация: без правки detail.py 5 из 9 новых тестов падают.
Прогон: 397 passed, 1 skipped (-k "avito or cadence or scheduler"), ruff чист.

Замер намеренно жёстче прода (без прогрева сессии и органического перехода из
выдачи), поэтому доля таких отказов в проде из него НЕ следует — её покажет
счётчик после правки.
lekss361 merged commit dbb8ca4ec1 into main 2026-08-31 11:36:20 +00:00
lekss361 deleted branch fix/3297-avito-browser-status-check 2026-08-31 11:36:20 +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#3300
No description provided.