-- 218_scrape_runs_ban_kind.sql -- scrape_runs.ban_kind — статус 'banned' перестаёт смешивать наш сбой с чужим (#2686). -- -- Dependencies: 015_scrape_runs.sql (создала таблицу и CHECK по status). -- Apply after: 217_position_in_serp_unexpressible.sql -- Идемпотентно: ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT + UPDATE только там, -- где ban_kind ещё NULL. -- -- ── ЧТО НАЙДЕНО ────────────────────────────────────────────────────────────── -- Из 115 avito-прогонов со статусом 'banned' (замер на проде 2026-08-06): -- 90 — «browser unavailable (proxy may be down)» — 503 НАШЕГО браузерного -- сайдкара (05.07-03.08), площадка ни при чём; -- 2 — прочие ошибки того же сайдкара (04.07, 15.07); -- 10 — «Avito SERP firewall (browser-mode) … IP banned» — реальная блокировка -- площадкой (16.06-06.08); -- 11 — HTTP 429/403 (30.05-19.06); -- 2 — прочее (21.06, 23.06). -- То есть 92 из 115 (80%) прогонов, помеченных «нас забанили», — отказ нашей -- собственной инфраструктуры. Оба исхода писали РАЗНЫЙ текст ошибки и получали -- ОДИН статус: различитель лежал в данных и терялся ровно в момент присвоения. -- -- ── ЦЕНА, КОТОРАЯ УЖЕ УПЛАЧЕНА ─────────────────────────────────────────────── -- Миграция 206 прочитала статус буквально — как «площадка распознаёт наш паттерн» — -- и на этом основании замедлила avito_full_load_exhaustive более чем вдвое, переведя -- на недельный такт. Основание было ложным. Возврат такта сюда НЕ входит: он вынесен -- в #2687 на данные 9-10.08. -- -- ── ПОЧЕМУ КОЛОНКА, А НЕ НОВЫЙ СТАТУС ──────────────────────────────────────── -- 1. У 'banned' есть побочная функция: в отличие от 'failed' он СОХРАНЯЕТ -- done_buckets-чекпоинт пройденных бакетов. Она нужна обоим исходам — следующий -- прогон не должен начинать с нуля ни при нашем отказе, ни при блокировке. -- Оставив статус, получаем её даром. -- 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых -- молча даёт неверный ответ, если про него забыть: этот CHECK, IN-списки обоих -- сторожей в orchestration/runs.py, Literal-фильтр admin API и хардкод-список -- статусов во фронте (RunsTable.tsx). Это ровно тот класс оборванной проводки, -- из-за которого задача и появилась. -- 3. Прогон в обоих случаях требует одного обращения (оборвать, сохранить частичное); -- различается только ДИАГНОЗ — метаданное, не состояние. -- -- В рантайме значение несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения -- AvitoSidecarUnavailableError), а не разбирается из текста ошибки постфактум. -- Разбор текста ниже — РАЗОВАЯ ретро-классификация уже накопленной истории; для -- новых строк этот путь не используется. ALTER TABLE scrape_runs ADD COLUMN IF NOT EXISTS ban_kind text; COMMENT ON COLUMN scrape_runs.ban_kind IS 'Диагноз status=''banned'' (#2686): platform — площадка заблокировала ' '(firewall/403/captcha); infra — не отдала НАША инфраструктура (браузерный ' 'сайдкар/прокси). NULL для прогонов с другим статусом. Пишется из типа ' 'исключения в момент отказа, не из текста ошибки.'; ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check; ALTER TABLE scrape_runs ADD CONSTRAINT scrape_runs_ban_kind_check CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra')); -- Ретро-классификация истории (разово, только там, где ещё NULL). Порядок веток -- важен: маркер сайдкара проверяется первым, потому что текст полного обхода -- оборачивает его в свой префикс («avito full load aborted: avito SERP -- browser-sidecar error (page=1): browser unavailable (proxy may be down)»). UPDATE scrape_runs SET ban_kind = CASE WHEN error ILIKE '%browser-sidecar error%' OR error ILIKE '%browser unavailable%' OR error ILIKE '%proxy may be down%' THEN 'infra' ELSE 'platform' END WHERE status = 'banned' AND ban_kind IS NULL;