fix(tradein/scrapers): диагноз блока из HTTP-статуса, а не из маркеров чужой площадки (#3196) #3202

Merged
lekss361 merged 1 commit from fix/3196-ban-kind-http-status into main 2026-08-28 20:25:41 +00:00
Owner

Closes #3196.

Что было

Сайдкар не читал код ответа page.goto вообще — страница классифицировалась только по текстовым маркерам, снятым с Авито (startpow, «доступ ограничен»). Домклик отдаёт статическую страницу 403 | Домклик на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026). Она уезжала наверх как валидный HTML, парсер не находил состояние страницы, и прогон получал блок неизвестной природы.

Результат за 14 дней: все 14 прогонов домклика с ban_kind='unknown'; у Яндекса счётчика blocked не существовало, поэтому ветка перевода прогона в banned была недостижима по построению — ноль банов при живых отказах.

Что сделано

Файл Изменение
browser/server.py Статус целевой навигации хранится per-provider и доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут — старый клиент ничего не заметит). 403/429 с маркерами челленджа больше не тратят бюджет ожидания PoW.
scraper_kit/browser_fetcher.py BrowserFetcher.last_response_status + ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None). Поток управления не менялся: fetch() по-прежнему возвращает str и не бросает там, где не бросал.
domclick_exceptions.py, providers/domclick/detail.py DomClickBlockedError несёт .status — один тип исключения на маркер-детект и на сбой фетча разводится без размножения типов (снимает ограничение #2764).
tasks/domclick_detail_backfill.py Перепись диагнозов уезжает в mark_backfill_finished(ban_kinds=...).
tasks/yandex_detail_backfill.py Появился счётчик blocked, оживляющий ветку бана.
tasks/cian_history_backfill.py, services/scheduler.py Диагноз отказа + перевод в banned только при нулевом обогащении.

Инвариант: непустой ban_kinds ⟺ мы видели ответ 403/429/5xx. Значения остаются в пределах CHECK scrape_runs.ban_kind.

Что учтено по ревью

  • Промах парсера не выдаётся за бан. У Циана ban_kinds наполняется только диагностируемым статусом. HTTP 200 с пустым разбором — дрейф разметки на нашей стороне; записав его блоком, правка штамповала бы фиктивные unknown у здорового источника (13 done против 1 banned за 14 дней) — ровно то, что этот тикет убирает. Яндекс ведёт себя так же.
  • Заглушка челленджа не возвращается контентом. На 403/429 сайдкар бросает BanPageDetectedError. Возврат заглушки воскресил бы #3045: бан-страница Авито приходит именно с этими кодами, и парсер получил бы челлендж вместо карточки. Путь «200 + PoW → ждём» не изменён.
  • Серии блоков и промахов парсера считаются раздельно (Яндекс). Общий счётчик давал banned с переписью {platform: 1} на четырёх промахах плюс одном 403 пятым.
  • bool отсекается от int при разборе статуса (JSON true уехал бы наверх статусом, который дальше сравнивается с 403 как 1); статус гасится перед каждой навигацией, чтобы не отдать код предыдущей страницы.

Известный пробел

Шов providers/domclick/detail.py::fetch_detail (blocked.status = status) тестами не покрыт: существующие домкликовые тесты подают DomClickBlockedError(status=...) готовым моком и боевой fetch_detail не исполняют — замена строки на pass оставляет набор зелёным. Закрывается отдельно.

Проверка

  • tradein-mvp/browser: 149 passed
  • tradein-mvp/backend: 4968 passed, 37 skipped
  • ruff check чисто; 5 файлов, которые ruff format хочет переписать, — предсуществующие, в этом PR не затронуты

Побочное следствие

У Яндекса ветка бана была недостижима, теперь достижима — и общее правило #2674 («есть блоки И ноль результата → banned») начнёт срабатывать даже при единственном блоке, когда остальные попытки провалил парсер. Такой прогон раньше уходил в failed, теперь уйдёт в banned. Это поведение общее с avito/domclick, поэтому в рамках этого PR не менялось.

Closes #3196. ## Что было Сайдкар не читал код ответа `page.goto` вообще — страница классифицировалась только по текстовым маркерам, снятым с Авито (`startpow`, «доступ ограничен»). Домклик отдаёт статическую страницу `403 | Домклик` на 26 624 байта, где нет **ни одного** такого маркера (замер прода 28.08.2026). Она уезжала наверх как валидный HTML, парсер не находил состояние страницы, и прогон получал блок неизвестной природы. Результат за 14 дней: все 14 прогонов домклика с `ban_kind='unknown'`; у Яндекса счётчика `blocked` не существовало, поэтому ветка перевода прогона в `banned` была недостижима по построению — ноль банов при живых отказах. ## Что сделано | Файл | Изменение | |---|---| | `browser/server.py` | Статус целевой навигации хранится per-provider и доезжает в тело `/fetch` аддитивным ключом `"status"` (ключ `"html"` не тронут — старый клиент ничего не заметит). 403/429 с маркерами челленджа больше не тратят бюджет ожидания PoW. | | `scraper_kit/browser_fetcher.py` | `BrowserFetcher.last_response_status` + `ban_kind_from_status` (403/429 → `platform`, 5xx → `infra`, прочее → `None`). Поток управления не менялся: `fetch()` по-прежнему возвращает `str` и не бросает там, где не бросал. | | `domclick_exceptions.py`, `providers/domclick/detail.py` | `DomClickBlockedError` несёт `.status` — один тип исключения на маркер-детект и на сбой фетча разводится без размножения типов (снимает ограничение #2764). | | `tasks/domclick_detail_backfill.py` | Перепись диагнозов уезжает в `mark_backfill_finished(ban_kinds=...)`. | | `tasks/yandex_detail_backfill.py` | Появился счётчик `blocked`, оживляющий ветку бана. | | `tasks/cian_history_backfill.py`, `services/scheduler.py` | Диагноз отказа + перевод в `banned` только при нулевом обогащении. | **Инвариант:** непустой `ban_kinds` ⟺ мы видели ответ 403/429/5xx. Значения остаются в пределах `CHECK scrape_runs.ban_kind`. ## Что учтено по ревью - **Промах парсера не выдаётся за бан.** У Циана `ban_kinds` наполняется только диагностируемым статусом. HTTP 200 с пустым разбором — дрейф разметки на нашей стороне; записав его блоком, правка штамповала бы фиктивные `unknown` у здорового источника (13 `done` против 1 `banned` за 14 дней) — ровно то, что этот тикет убирает. Яндекс ведёт себя так же. - **Заглушка челленджа не возвращается контентом.** На 403/429 сайдкар бросает `BanPageDetectedError`. Возврат заглушки воскресил бы #3045: бан-страница Авито приходит именно с этими кодами, и парсер получил бы челлендж вместо карточки. Путь «200 + PoW → ждём» не изменён. - **Серии блоков и промахов парсера считаются раздельно** (Яндекс). Общий счётчик давал `banned` с переписью `{platform: 1}` на четырёх промахах плюс одном 403 пятым. - `bool` отсекается от `int` при разборе статуса (JSON `true` уехал бы наверх статусом, который дальше сравнивается с 403 как `1`); статус гасится перед каждой навигацией, чтобы не отдать код предыдущей страницы. ## Известный пробел Шов `providers/domclick/detail.py::fetch_detail` (`blocked.status = status`) **тестами не покрыт**: существующие домкликовые тесты подают `DomClickBlockedError(status=...)` готовым моком и боевой `fetch_detail` не исполняют — замена строки на `pass` оставляет набор зелёным. Закрывается отдельно. ## Проверка - `tradein-mvp/browser`: 149 passed - `tradein-mvp/backend`: 4968 passed, 37 skipped - `ruff check` чисто; 5 файлов, которые `ruff format` хочет переписать, — предсуществующие, в этом PR не затронуты ## Побочное следствие У Яндекса ветка бана была недостижима, теперь достижима — и общее правило #2674 («есть блоки И ноль результата → `banned`») начнёт срабатывать даже при единственном блоке, когда остальные попытки провалил парсер. Такой прогон раньше уходил в `failed`, теперь уйдёт в `banned`. Это поведение общее с avito/domclick, поэтому в рамках этого PR не менялось.
lekss361 added 1 commit 2026-08-28 20:22:37 +00:00
fix(tradein/scrapers): диагноз блока брался из текстовых маркеров чужой площадки, а не из HTTP-статуса (#3196)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (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
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
bf3214b9e4
Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.

- browser/server.py: статус целевой навигации сохраняется per-provider и
  доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
  403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
  страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
  заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
  ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
  Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
  маркер-детект и на сбой фетча разводится без размножения типов; прогон
  передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
  промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
  пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
  пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
  записав его блоком, мы бы штамповали фиктивные баны у здорового источника
  (13 done против 1 banned за 14 дней).

Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.

Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
lekss361 merged commit 79e469de1c into main 2026-08-28 20:25:41 +00:00
lekss361 deleted branch fix/3196-ban-kind-http-status 2026-08-28 20:25:41 +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#3202
No description provided.