tradein/avito: browser-путь detail не смотрит на HTTP-статус — отказ 439 приезжает как ошибка парсинга, узел не ротируется #3297

Closed
opened 2026-08-31 10:36:23 +00:00 by lekss361 · 2 comments
Owner

Замер прода 31.08.2026, узел 9 (asocks-mobile-1), две карточки подряд — обе вернули ровно одно и то же:

статус = 439
размер = 8172 байта
title  = «Доска объявлений от частных лиц и компаний на Авито»

Это отказ QRATOR'а: 439 — его нестандартный код, и в репозитории он уже опознан как таковой (providers/avito/detail.py:109, providers/avito/serp.py:326). Но добор карточек этого не видит и падает с ValueError: Cannot extract item_id, то есть отказ площадки записывается ошибкой разбора.

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

1. Статус в browser-ветке не проверяется вообще.

curl-ветка проверяет: providers/avito/detail.py:622if sc in (403, 439) or is_firewall: → reconnect на свежий exit-IP. Browser-ветка (:528-568) смотрит только на HTML:

if _is_detail_not_found(html): ...        # 404
if _is_firewall_page(html) or _is_detail_soft_block(html): ...
return parse_detail_html(html, full_url)  # ← 439 приезжает сюда

browser_fetcher.last_response_status в этой ветке не читается ни разу, хотя атрибут существует ровно для этого (#3196) и в замере честно показал 439. Прод ходит именно browser-путём (scraper_fetch_mode="browser"), то есть работает та ветка, где проверки нет.

2. Маркер мягкого блока протух.

_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",) (:440). Фактический заголовок страницы-заглушки — другой:

маркер: 'объявления на сайте авито'
title:  'доска объявлений от частных лиц и компаний на авито'
вхождение: False

Формулировка сместилась, детектор молчит. Комментарий над ним (#1967) описывает ровно этот сценарий — «HTTP 200 с ОБЩЕЙ витриной вместо объявления» — и предупреждает, чем он кончается: «generic failed, IP НЕ ротировался, enriched каскадно падал к 0».

Что из-за этого происходит

Отказ 439 не считается блоком → counters.failed, не blocked. Значит:

  • узел не ротируется и не банится — следующая карточка идёт через тот же отвергнутый IP;
  • брейкер по доле его не видит, прогон продолжает биться в стену;
  • в телеметрии отказ выглядит порчей схемы разбора, а не отказом площадки.

Масштаб честно: в прод-прогонах failed невелик (0–6 на прогон против blocked 17–95), так что это не главная течь — но это течь невидимая, и в моём замере она дала 4 отказа из 4 на одном узле.

Оговорка о замере

Проба ходила без прогрева сессии и без органического перехода из выдачи, то есть жёстче, чем прод. Поэтому доля 439 в проде из неё не следует — из неё следует только то, что такой ответ существует, приходит со статусом 439 и текущим кодом не опознаётся ни одной веткой. Долю покажет счётчик после правки.

Правка

  1. В browser-ветке fetch_detail читать browser_fetcher.last_response_status и трактовать 403/429/439 как отказ площадки (AvitoBlockedError), до вызова parse_detail_html — зеркало curl-ветки :622.
  2. Обновить _DETAIL_SOFT_BLOCK_TITLE_MARKERS по снятому вживую заголовку. Маркеры площадки протухают — в соседнем месте это уже записано прямым текстом («при протухании маркеров переснять их заново вживую, а не гадать по памяти», browser/server.py:1602). Держать статус ОСНОВНЫМ сигналом, а заголовок — дополнительным: строка меняется, код 439 держится.
  3. Тест: страница 8172 байта со снятым заголовком и статусом 439 → AvitoBlockedError, а не ValueError.

Смежное

  • #3288 — тот же класс: бан-страница Авито приезжает как infra. Вместе с этим тикетом дают три разных вида отказа площадки, из которых код верно опознаёт один.
  • #2638 — дефицит узлов; каждая неопознанная форма отказа множит его цену, потому что сожжённый узел продолжают использовать.
Замер прода 31.08.2026, узел 9 (`asocks-mobile-1`), две карточки подряд — обе вернули **ровно одно и то же**: ``` статус = 439 размер = 8172 байта title = «Доска объявлений от частных лиц и компаний на Авито» ``` Это отказ QRATOR'а: 439 — его нестандартный код, и в репозитории он уже опознан как таковой (`providers/avito/detail.py:109`, `providers/avito/serp.py:326`). Но добор карточек этого не видит и падает с `ValueError: Cannot extract item_id`, то есть отказ площадки записывается ошибкой разбора. ## Две независимые дыры, обе в browser-ветке **1. Статус в browser-ветке не проверяется вообще.** curl-ветка проверяет: `providers/avito/detail.py:622` — `if sc in (403, 439) or is_firewall:` → reconnect на свежий exit-IP. Browser-ветка (`:528-568`) смотрит только на HTML: ```python if _is_detail_not_found(html): ... # 404 if _is_firewall_page(html) or _is_detail_soft_block(html): ... return parse_detail_html(html, full_url) # ← 439 приезжает сюда ``` `browser_fetcher.last_response_status` в этой ветке не читается ни разу, хотя атрибут существует ровно для этого (#3196) и в замере честно показал 439. Прод ходит именно browser-путём (`scraper_fetch_mode="browser"`), то есть работает та ветка, где проверки нет. **2. Маркер мягкого блока протух.** `_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",)` (`:440`). Фактический заголовок страницы-заглушки — другой: ``` маркер: 'объявления на сайте авито' title: 'доска объявлений от частных лиц и компаний на авито' вхождение: False ``` Формулировка сместилась, детектор молчит. Комментарий над ним (#1967) описывает ровно этот сценарий — «HTTP 200 с ОБЩЕЙ витриной вместо объявления» — и предупреждает, чем он кончается: «generic failed, IP НЕ ротировался, enriched каскадно падал к 0». ## Что из-за этого происходит Отказ 439 не считается блоком → `counters.failed`, не `blocked`. Значит: - узел не ротируется и не банится — следующая карточка идёт через тот же отвергнутый IP; - брейкер по доле его не видит, прогон продолжает биться в стену; - в телеметрии отказ выглядит порчей схемы разбора, а не отказом площадки. Масштаб честно: в прод-прогонах `failed` невелик (0–6 на прогон против `blocked` 17–95), так что это не главная течь — но это течь **невидимая**, и в моём замере она дала 4 отказа из 4 на одном узле. ## Оговорка о замере Проба ходила без прогрева сессии и без органического перехода из выдачи, то есть жёстче, чем прод. Поэтому **доля 439 в проде из неё не следует** — из неё следует только то, что такой ответ существует, приходит со статусом 439 и текущим кодом не опознаётся ни одной веткой. Долю покажет счётчик после правки. ## Правка 1. В browser-ветке `fetch_detail` читать `browser_fetcher.last_response_status` и трактовать 403/429/439 как отказ площадки (`AvitoBlockedError`), до вызова `parse_detail_html` — зеркало curl-ветки `:622`. 2. Обновить `_DETAIL_SOFT_BLOCK_TITLE_MARKERS` по снятому вживую заголовку. Маркеры площадки протухают — в соседнем месте это уже записано прямым текстом («при протухании маркеров переснять их заново вживую, а не гадать по памяти», `browser/server.py:1602`). Держать статус ОСНОВНЫМ сигналом, а заголовок — дополнительным: строка меняется, код 439 держится. 3. Тест: страница 8172 байта со снятым заголовком и статусом 439 → `AvitoBlockedError`, а не `ValueError`. ## Смежное - #3288 — тот же класс: бан-страница Авито приезжает как `infra`. Вместе с этим тикетом дают три разных вида отказа площадки, из которых код верно опознаёт один. - #2638 — дефицит узлов; каждая неопознанная форма отказа множит его цену, потому что сожжённый узел продолжают использовать.
Author
Owner

Дописался полный прогон замера — 24 попытки через три узла. Уточняет два места в тексте выше.

Раскладка

исход сколько текущий диагноз
бан-страница, upstream 403 8 infra
soft-block (AvitoBlockedError) 9 platform
бан-страница, upstream 429 2 infra
ValueError: Cannot extract item_id 3 ошибка разбора ✗
бан-страница, upstream 439 1 infra
успех 1

23 отказа из 24 — площадка. Ни таймаута, ни транспортного сбоя, ни 500-ки без маркера бана. Верно опознаны 9 из 23.

Поправка к пункту 1 правки

Выше я написал «трактовать 403/429/439 как отказ» на основании двух замеров с 439. Полный прогон показывает, что бан-страница приходит минимум с тремя разными upstream-статусами (403, 429, 439). Поэтому:

  • проверять множеством статусов-отказов, а не перечислением в условии — и держать его в одном месте на провайдера, рядом с _REFUSAL_STATUSES сайдкара (browser/server.py:1630, сейчас там только {403, 429}, 439 отсутствует);
  • 429 внутри browser-ветки не путать с curl-веткой: там 429 намеренно трактуется как conn-limit backconnect'а с коротким retry (detail.py:612), а здесь он приходит С маркером бан-страницы в теле, то есть это отказ площадки. Разводить по наличию ban_page, а не по коду;
  • тест на каждый из трёх статусов, а не на один.

Наблюдение к #2638

Узел 12 в пробе получасом ранее отдавал карточки, в этом прогоне — 7 soft-block из 8. Узлы выгорают в пределах часа. Это довод за расширение пула, не зависящий от правок опознания: правки вернут ротацию и адресный бан, но не создадут узлов, через которые ходить.

Дописался полный прогон замера — 24 попытки через три узла. Уточняет два места в тексте выше. ## Раскладка | исход | сколько | текущий диагноз | |---|---|---| | бан-страница, upstream **403** | 8 | `infra` ✗ | | soft-block (`AvitoBlockedError`) | 9 | `platform` ✓ | | бан-страница, upstream **429** | 2 | `infra` ✗ | | `ValueError: Cannot extract item_id` | 3 | ошибка разбора ✗ | | бан-страница, upstream **439** | 1 | `infra` ✗ | | успех | 1 | | 23 отказа из 24 — площадка. Ни таймаута, ни транспортного сбоя, ни 500-ки без маркера бана. Верно опознаны 9 из 23. ## Поправка к пункту 1 правки Выше я написал «трактовать 403/429/439 как отказ» на основании двух замеров с 439. Полный прогон показывает, что бан-страница приходит **минимум с тремя разными upstream-статусами** (403, 429, 439). Поэтому: - проверять **множеством** статусов-отказов, а не перечислением в условии — и держать его в одном месте на провайдера, рядом с `_REFUSAL_STATUSES` сайдкара (`browser/server.py:1630`, сейчас там только `{403, 429}`, 439 отсутствует); - 429 внутри browser-ветки не путать с curl-веткой: там 429 намеренно трактуется как conn-limit backconnect'а с коротким retry (`detail.py:612`), а здесь он приходит С маркером бан-страницы в теле, то есть это отказ площадки. Разводить по наличию `ban_page`, а не по коду; - тест на каждый из трёх статусов, а не на один. ## Наблюдение к #2638 Узел 12 в пробе получасом ранее отдавал карточки, в этом прогоне — 7 soft-block из 8. Узлы выгорают в пределах часа. Это довод за расширение пула, не зависящий от правок опознания: правки вернут ротацию и адресный бан, но не создадут узлов, через которые ходить.
Collaborator

Закрываю: мерж dbb8ca4e (PR #3300) — browser-путь detail теперь смотрит на HTTP-статус, ровно заявленный фикс. Ревизия 01.09.2026.

Закрываю: мерж dbb8ca4e (PR #3300) — browser-путь detail теперь смотрит на HTTP-статус, ровно заявленный фикс. Ревизия 01.09.2026.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#3297
No description provided.