Сайдкар вообще не читал код ответа 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 не исполняют.
NoProxyAvailableError поднимается из BrowserFetcher.__aenter__ (_acquire_lease)
ДО первого HTTP-запроса, когда пул прокси пуст — это НАША инфраструктура, не
блокировка площадкой. У run_avito_full_load/run_cian_full_load/run_yandex_full_load
уже есть выделенный except NoProxyAvailableError -> mark_banned(ban_kind='infra'),
у run_domclick_city_sweep его не было: исключение проваливалось в общий except
Exception внутри SERP-фазы, _scraper_ref оставался пустым, и честный статус ниже
видел "0 лотов + errors>0" -> mark_failed("fetch errors — 0 listings") с
ban_kind=NULL. Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1,
ban_kind=NULL — неотличимо от честного отказа сбора площадкой.
Добавлен except NoProxyAvailableError перед generic except Exception (порядок
важен: класс — подкласс RuntimeError). Обработчик зеркалит avito/cian/yandex:
mark_banned + ban_kind_of_exception(exc) (даёт BAN_KIND_INFRA), и сохраняет
унаследованный чекпоинт (skip_buckets) вместо потери его на нашем же отказе.
Тест test_3118_domclick_no_proxy.py проверен на обратимость: без обработчика
падает (mark_failed вместо mark_banned), с обработчиком — проходит.
_on_combo в run_yandex_city_sweep делал done_combos.add(combo_label) ДО
вызова save_listings. Отказ save_listings перехватывается (осознанно —
одна упавшая единица не должна ронять весь sweep) и логируется, но combo
уже был отмечен пройденным и уходил в heartbeat done_buckets. Следующий
resume брал skip_combos из done_buckets (членство в множестве — само по
себе корректно, не трогал) и пропускал этот combo навсегда: молча, прогон
завершался штатно, просто сегмент выдачи не собирался никогда.
Тот же инвариант "отмечаем пройденным только после успешного save", что
уже есть у страницы в run_avito_newbuilding_sweep (_saved_ok), якоря в
run_avito_city_sweep (_anchor_ok) и бакета в run_cian_full_load
(_mark_bucket) — применил к combo. Heartbeat пишется в любом случае
(и при отказе save тоже), иначе reap_zombies посчитает живой прогон
мёртвым.
Тесты: test_3170_yandex_combo_checkpoint.py — combo с упавшим save не
попадает в done_buckets, успешный (включая пустую выдачу) — попадает.
Обратимость проверена: с возвращённым дефектом (git stash) первый тест
красный, со снятым — зелёный вместе с существующим test_3074_yandex_
sweep_checkpoint.py (6/6).
Последний длинный свип без возобновления: при обрыве прогон начинался с
первой страницы, а собранное терялось целиком — save_listings вызывался
один раз на весь sweep.
Единица возобновления — страница выдачи, по образцу якорей в city sweep.
`_paginate_sweep`/`fetch_newbuildings` получили `start_page` (уже собранные
страницы не запрашиваются) и колбэк `on_page`, который вызывается только
после того, как страница пройдена до конца. Сохранение стало постраничным,
номера пройденных страниц копятся в `scrape_runs.counters.done_buckets`
мержем через `update_heartbeat`.
Два инварианта, без которых фича вредна:
1. В чекпоинт попадает только страница, чьи лоты СОХРАНЕНЫ. Отказ
save_listings перехвачен и прогон продолжается, но отметить такую
страницу пройденной значило бы, что следующий прогон её пропустит и
объявления оттуда не соберутся никогда — молча, потому что прогон
завершится штатно.
2. Подхват начинается с ПЕРВОЙ несобранной страницы, а не с max+1. Дыра в
чекпоинте возможна ровно из-за п.1, и max+1 перепрыгнул бы её навсегда.
Страницы после дыры перечитаются — это дешевле потери и безопасно,
повторная запись схлопывается по dedup_hash.
Оба инварианта закрыты тестами, которые падают при их нарушении.
Обе ветки повтора в `fetch_detail` — 403/firewall и 429 — пересоздавали
эфемерную сессию вызовом `_build_detail_session()` БЕЗ `config`. Прокси
kit-версия читает только из `config.scraper_proxy_url`, поэтому такая сессия
уходила напрямую.
Замысел ветки прямо обратный, он записан в её же комментарии: «эфемерная
свежая сессия (новый CONNECT-туннель = свежий exit-IP)». Ветка вообще
исполняется только при backconnect=True, а он вычисляется как «задан
config.scraper_proxy_url» — то есть в момент вызова достоверно известно, что
прокси есть, и он терялся.
ПОЧЕМУ НЕ БЫЛО ВИДНО. Пока адрес самой машины не был заблокирован, прямой
повтор часто срабатывал, и подмена канала выглядела как успех. Замер 27.08 из
прод-контейнера, один и тот же URL Авито:
через прокси — 200, 3.3 МБ страницы
напрямую — 429, «доступ ограничен», firewall
С этого момента каждый повтор после блока обречён. Обогащение
avito_detail_backfill по суткам: 21-25.08 — 178/129/147/138/111, 26.08 — 23,
27.08 — 0 при 25 блоках. Обвал начинается ровно с окна, в котором сменился
адрес машины.
Тот же класс ошибки чинили в #2330 для build_warmed_session; в пути повтора он
оставался.
Три теста: обе ветки на месте (страховка от проверки пустоты), ни одна не
строит сессию без config, и отдельно доказано, что без config прокси в сессии
действительно нет. Проверил красноту на старом коде — падает с точным текстом.
Прогон: 107 тестов scrapers зелёные, ruff чист.
Refs #3034, #3045
Первая живая приёмка #3122: деплоевский startup-reap успел снять зомби
раньше, boot-reap отработал с нулём — и не оставил НИКАКОГО следа
исполнения. Ноль — штатный исход, но «сторож, молчащий при нуле» — это
слепая зона по построению: оборванную проводку не отличить от чистого
прода. INFO-строка при нуле, warning при снятых — как было.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт 27.08 (после ночного офлайна #3119): 4 прогона 'running' со
стартами до старта контейнера блокировали свои источники через
has_running_run до 6-часового порогового reap'а — до пяти часов слепоты
на источник ровно после простоя, когда догон нужнее всего.
Критерий — started_at < старт процесса планировщика (минус минута на
дрейф), пульс не участвует: ложные срабатывания класса #2702 (редкий
пульс длинных прогонов) невозможны по построению — живой прогон этого
процесса не может быть старше самого процесса.
Маркер counters.boot_reaped=true открывает boot-зомби подхват чекпоинта
(_resume_decision): у порогового zombie процесс может быть жив
(движущаяся точка — причина исключения 'zombie' из _RESUME_STATUSES),
у boot-зомби — гарантированно мёртв. Пороговый zombie без маркера
по-прежнему отвергается (закреплено тестом-инвариантом).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Миграция 272: колонка + бэкфилл ТОЛЬКО по bbox региона 66 (значения
байт-в-байт из реестра, синхронизацию держит тест). Карантин NULL:
620 домов без geom, 23 порченых (ЕКБ-адреса с чужими координатами —
«Вильгельма де Геннина» на Байкале, «Крауля» под Москвой, «Учителей» в
Таллине, с живыми ссылками листингов) — им регион не присваивается,
включая 3 дома с координатами в bbox Москвы (порча, не переезд). NOT NULL
из постановки — отдельной миграцией, когда карантин опустеет.
Запись: новый дом наследует регион РАЗВЁРТКИ (base.py → контракт →
адаптер → matching), только если координаты не противоречат; координаты
другого региона → NULL-карантин + warning. Вне-bbox координаты при живой
развёртке наследуют её регион (адрес и развёртка согласны, координатам
веры нет) — семантика закреплена тестом, чтобы смена была осознанной.
Матчинг-запросы НЕ тронуты — гард по региону это #3052.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Третий шард после yandex (#3098) и avito (#3112). Выбран по замеру за 60 дней:
65 прогонов, среднее 35 минут, максимум 72, две отмены деплоем. Пятиминутного
дренажа (#3029) на такие прогоны не хватает - убитый на 35-й минуте сбор
начинался заново с первого якоря.
Ключ чекпоинта - ИМЯ якоря, а не индекс: состав списка зависит от city_slug
(областные свипы идут по своим наборам), позиция между городами не устойчива.
По той же причине гарда по числу якорей не нужна - в отличие от combo-чекпоинта
яндекса, где ключ якоря не содержал.
Отличие от avito-шарда: там успех и неудача якоря сходились в одной строке и
потребовался отдельный флаг _anchor_ok. У циана граница уже проведена самим
потоком управления - все ветки отказа делают return или continue и до записи
чекпоинта не доходят. Добавлять флаг значило бы дублировать то, что уже
выражено структурой; достаточно писать чекпоинт в единственной точке успеха.
Тест сторожит эту границу отдельно, потому что рефакторинг, сливающий ветки,
сломал бы её незаметно.
Пропущенный якорь двигает anchors_done - чтобы счётчик продолжал означать
"докуда дошли по списку", а не "сколько собрал именно этот прогон".
Тесты (4) поведенческие, с подменой CianScraper и save_listings: якорь из
чекпоинта не опрашивается вовсе; пройденный дописывается поверх унаследованных;
без чекпоинта обходятся все; упавший в чекпоинт не попадает. Двойнику пришлось
добавить счётчики state_extraction_* - конвейер читает их после каждого якоря
(#2625), и без них падал бы сам двойник, а не проверяемая логика.
Фальсификация: на исходном коде краснеют все 4. Весь набор #3074 (yandex, avito,
cian, claim) - 14 passed.
Продолжение после yandex-свипа. Выбор источника — по замеру, а не «для
полноты»: за 60 дней avito_city_sweep дал 68 прогонов, 25 банов и 3 отмены
деплоем при среднем времени 15 мин и максимуме 97.
Прод-факт, который решает дело. Типичный итог свипа:
{"anchors_done": 1, "anchors_total": 5, ...,
"enrichment_abort_note": "detail enrichment aborted (Avito detail
firewall/soft-block ...)"}
Прогон срывается блокировкой на ПЕРВОМ из пяти якорей. Без чекпоинта
следующий прогон снова идёт в первый якорь, упирается в ту же стену, и якоря
2-5 не собираются никогда.
Ключ чекпоинта — ИМЯ якоря, а не индекс: состав списка зависит от city_slug,
позиция в нём между городами не устойчива. По той же причине здесь не нужна
гарда по числу якорей, которая есть у combo-чекпоинта яндекса: там ключ
якоря не содержал, здесь якорь и есть ключ.
Инвариант, ради которого отдельный флаг _anchor_ok: в чекпоинт попадает
только якорь, пройденный до конца. Ветка блокировки делает return и до записи
не доходит, а generic-except доходит — якорь упал, но цикл продолжается.
Записать такой якорь пройденным значило бы, что следующий прогон пропустит
его навсегда, причём молча: прогон завершится штатно, просто часть города не
соберётся. Флаг сбрасывается на каждой итерации, иначе один упавший якорь
заразил бы все последующие.
Пропущенный якорь двигает anchors_done — чтобы счётчик продолжал означать
«докуда дошли по списку», а не «сколько собрал именно этот прогон».
domclick_city_sweep намеренно НЕ трогаю: 54 прогона, ноль отмен деплоем,
среднее время 3 минуты — чекпоинт там не окупается. cian_city_sweep (среднее
35 мин, 2 отмены) — следующий шард.
Тесты (4) поведенческие: якорь из чекпоинта не опрашивается вовсе; пройденный
дописывается поверх унаследованных; без чекпоинта обходятся все; упавший в
чекпоинт НЕ попадает. Оговорка: на исходном коде они падают по сигнатуре
(unexpected keyword argument), то есть доказывают отсутствие параметра, а не
поведение — поведенческую часть держат сами проверки. Весь набор #3074 —
10 passed.
Detail-обогащение извлекало agency_name, но признак «продаёт профи» не
выводило — 4 469 активных листингов с известным агентством стояли с
is_pro_seller=NULL (признак эрозирован SERP-затиранием до PR #3067, а
заново не появлялся). Признак идёт в оценщик trade-in.
- save_detail_enrichment: is_pro_seller=TRUE при известном agency_name,
fill-only COALESCE; отсутствие блока НЕ доказывает «частник» (п.3 задачи —
отдельное решение), в ту сторону ничего не пишем;
- миграция 271: одноразовый бэкфилл уже существующих строк всех источников
(правило источник-независимо), идемпотентна.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Из таблицы убитых деплоем: yandex_city_sweep 15.08 прожил 65 мин, 12.08 —
2ч33м; оба потеряны целиком — у свипа не было чекпоинтов вовсе.
Единица чекпоинта — combo (сегмент × комнатность × ценовой диапазон), ровно
то, чем цикл обхода уже итерируется. Три слоя:
- провайдер: skip_combos (ни одного HTTP по собранным) + on_combo для каждого
ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в
чекпоинт и перечитывался бы вечно; оборванный отказом combo (gate failure)
on_combo по-прежнему не вызывает;
- пайплайн: done_combos → heartbeat с done_buckets (мерж jsonb, финализаторы
не затирают); подхват гейтится единственным якорем — combo_label не содержит
якоря, multi-anchor подхват пропускал бы чужие якоря;
- планировщик: resume_run_id=_pick_resume(...) в диспатче (generic-механизм
#2845 — params-идентичность, свежесть точки, потолок цепочки — бесплатно).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем
на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу
написать heartbeat с done_buckets. Его собственный чекпоинт пуст: следующий
кандидат увидел бы no_checkpoint, цепочка оборвалась бы с потерей 42 корзин.
_resume_decision при вердикте 'ok' теперь кладёт done_buckets предшественника
в counters-заготовку нового прогона — она персистится при claim, до старта
пайплайна. Heartbeat мержит jsonb: первый настоящий bucket-heartbeat
перезапишет ключ надмножеством, двойной записи нет. Отказные вердикты
чекпоинт не наследуют (прогон с нуля не должен врать о собранном).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прод-факт: замер по avito_detail_backfill прочитал «обогащено 0 за 7 дней»
при реальных 801 — джобы пишут результат только ключом 'enriched', которого
_column_counts не знал, и колонка total_seen оставалась 0 у всех прогонов.
'enriched'-фолбэк добавлен именно в _column_counts (витринная колонка), а НЕ в
_RESULT_COUNTER_KEYS: тот список кормит zero-result-стрик, где догнавший
очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую
ревью уже выкинуло из списка 'rows_inserted' (#2703). Контракт стрик-сторожа
закреплён инвариант-тестом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
Воркер сослался на #3086, которого не существует — тест с таким именем
остался бы загадкой для следующего читателя. Заведён настоящий issue #3047
с замерами покрытия и разбором дефекта; все ссылки и имя файла приведены
к нему.
Refs #3047
Разобрались по эталонной разметке (2 живые detail-карточки, 2026-08-21), что реально
парсится и что нет:
- sale_type: item-view/item-params — Avito отдаёт "О квартире" и "О доме" как ДВА
ОТДЕЛЬНЫХ <div> с ОДНИМ И ТЕМ ЖЕ маркером, а не один div с двумя <ul>, как
предполагал старый код. css_first брал только ПЕРВЫЙ div — sale_type это не
задевало (он в первом блоке), но house_type/total_floors_house/лифты из второго
блока терялись молча через мёртвую ветку `len(ul_els) >= 2`. Теперь читаем <ul>
из ВСЕХ блоков с этим маркером — устойчиво к порядку блоков.
- metro_stations: раньше только эвристика по тексту описания (28 строк из 54 855).
Основной источник теперь — структурная разметка (#item-view-address, иконка
"Пешком до метро"), покрывает станции без ограничения на суффикс имени
(METRO_RE ловил только "-ская"/"-инская"). Описание — фолбэк.
- is_homeowner: не парсился вовсе. [data-marker='seller-info/label'] — "Частное
лицо" -> True, "Агентство" -> False. Подтверждено разными значениями на двух
эталонах.
- days_on_market: на странице явно нет, но есть publish_date, из которого честно
выводится. Заодно чинит сам publish_date — искали дату ВНУТРИ item-id-блока, а
она в СОСЕДНЕМ [data-marker='item-view/item-date'] ("сегодня в HH:MM").
- cadastral_number: подтверждено отсутствие на странице (не парсим, не выдумываем).
Тесты на реальной разметке (backend/tests/test_avito_detail_fields_3086.py) на
обеих эталонных фикстурах (большие embedded JS-блобы вырезаны из фикстур —
не используются DOM-based парсером, экономят место). estimator.py не тронут —
ни одно поле не влияет на цену.
Замер живьём 2026-08-21 через tradein-browser (camoufox, JS исполняется):
59-60 уникальных `data-item-id` на странице выдачи. «Лишние» сверх 50 —
обычные объявления с платным продвижением (`vas-icon_type-promoted`), они
лежат в том же списке под `page-title/count`, а не отдельным рекламным
блоком, и собираются наравне с остальными.
Направление эффекта важно понимать правильно. Константа участвует ТОЛЬКО
в `ceil(total / PAGE)`, поэтому занижение размера страницы ЗАВЫШАЛО
расчётное число страниц, а не занижало:
- запрашивали примерно на 17 % страниц больше, чем нужно; при доле банов
37-83 % по avito-заданиям лишние запросы — основная цена ошибки;
- `tail_loss` считался как `total - cap * 50` и завышал потерю;
- флаг `complete` в пагинации листа чаще ложно показывал «неполно».
Тихой потери данных НЕ было: условия «страница вернула меньше PAGE
карточек, значит последняя» в коде нет, пагинация ограничена только
`max_pages`. Тест закрепляет и значение, и направление арифметики, чтобы
неверная трактовка не вернулась при следующем рефакторинге.
Найдено при разборе Авито сверкой живого браузера со скраппером; полный
разбор — в волте `research/Avito_Live_Browser_Recon_0821.md`.
Refs #3033
DEFAULT_IMPERSONATE переведён на chrome146, но оба pyproject продолжали
объявлять `curl-cffi>=0.7.0`. В 0.7.0 такого профиля нет: установка по
нижней границе — и curl_cffi роняет КАЖДЫЙ запрос скраппера на невалидном
impersonate, причём одинаково для avito, cian и yandex.
На проде стоит 0.15.0 (проверено в контейнере tradein-scraper: профили
chrome99 … chrome146 плюс алиас chrome), поэтому текущий деплой бы не
пострадал — но любая пересборка окружения с резолвом к нижней границе
воспроизвела бы отказ, и диагностировался бы он как «скрапперы легли»,
а не как несогласованная зависимость.
Refs #3034
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:
- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
(макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
дерево scraper_kit на литерал "chrome120".
- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
добавлялся per-request (curl_cffi мёржит per-request headers поверх
session-level) - живой Referer с "none" рядом не бывает у настоящего
Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
теперь его использует. Внутренний avito-search -> avito-detail Referer
(fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.
- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
Яндекс, подделка хуже отсутствия.
- warm_up_session/research_in_session считали прогрев успешным по HTTP-
статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
(__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
"прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
изменений).
Полный backend pytest suite зелёный (4672 passed), ruff check чист.
Refs #3034
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому
включить новостройки в полный обход можно было только деплоем. Теперь это
параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на
строку, но решение становится правкой одной ячейки
`scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же
движением.
Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе:
они скачиваются, разбираются и выбрасываются последним шагом
(`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian
ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment`
после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP
и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а
`totalOffers` включает ОБЕ категории — то есть страницы с новостройками
уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены.
Включение стоит ноль дополнительных запросов.
Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик
логировался (`dropped_nb=`), но не персистился, и ответить «сколько
инвентаря выбрасывает полный обход» задним числом было нечем: логи за
17.08 уже ротировались — `docker logs --since 120h` не находит ни строки
«cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён
`partial_buckets`. Копится в атрибуте инстанса, а не аргументом
`on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру
ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется.
Замер, ради которого это делается (прод 21.08): месячный охват свипа
cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и
avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки,
10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994.
Двусторонне: против origin/main пять тестов красные, и краснота везде по
значению, а не по отсутствию символа — ни одного KeyError. Сообщения
перечисляют фактическое состояние («параметра нет в сигнатуре; параметры:
[...]», «поля нет в запросе; поля: [...]»).
Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо
включила бы сбор новостроек на проде — это отдельное решение с замером);
фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит
от флага.
pytest tradein-mvp/backend — 4644 passed, 23 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раньше listing_segment писался ТОЛЬКО при INSERT в save_listings — строка,
единожды родившаяся с NULL-сегментом (классификатор не сработал на первом
скрейпе), никогда не получала сегмент на повторных сборах, даже когда он
становился определим. Симптом лечила отдельная джоба деактивации
(deactivate_stale_{cian,yandex}_null_segment, миграция 266, PR #2908) — чистит
мусор, но не саму запись.
Добавлен listing_segment = COALESCE(EXCLUDED.listing_segment,
listings.listing_segment) в ON CONFLICT DO UPDATE, и тот же идиом в
reconcile-UPDATE (dedup_hash-drift fallback path) — иначе self-heal был бы
неполным для строк, прошедших через этот путь. COALESCE, а не голый EXCLUDED,
чтобы повторный скрейп без определённого сегмента не затирал уже известное
значение пустым (тот же паттерн, что уже применён для city/kitchen/ceiling).
Тесты: tests/test_listing_segment_upsert_selfheal.py — INSERT-путь,
ON CONFLICT COALESCE (обе стороны), reconcile-UPDATE COALESCE.
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой
yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод-
прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich
(processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы
практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало
бы реальные отказы под measured-N.
Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep
(42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у
yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42
прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS
(app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный
стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное
измерение вместо счётчика попыток.
Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72%
отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished
(mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их
в 'failed' через _failed_ratio_too_high — это не документировалось явно.
Три прод-факта, где status='done' врал о реальном исходе прогона:
- avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
-> 'done'. mark_backfill_finished звал mark_done, потому что produced=6 (>0);
ни _sweep_run_did_nothing (нет anchors_total/errors_count у backfill'ов), ни
_phase_totally_failed (голые "attempted"/"failed" без фазового префикса) эту
форму counters не ловили. Новый _failed_ratio_too_high внутри mark_done:
failed/attempted >= 0.5 -> 'failed', >= 0.15 -> тоже 'failed' (другая
формулировка причины в error-тексте) — 'partial' статусом не заведён: это
потребовало бы DROP+ADD CHECK constraint (051_scrape_runs_extend.sql) и
дообучения ещё 4 мест (Literal-фильтр admin API, статусы фронта, оба
IN-списка сторожей) — тот же класс проводки, что и у ban_kind (#2686/#2764),
который сознательно не стал новым статусом.
- yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
processed=5 succeeded=0 rows_inserted=0 failed_resolve=4-5 — сторож нулевого
результата (_alert_if_consecutive_zero_results) не видел ни один результатный
ключ этого sweep'а и молчал навсегда. _RESULT_COUNTER_KEYS дополнен
rows_inserted/processed (именно в этом порядке — rows_inserted это результат,
processed это попытки; иначе "5 обработано, 0 записано" замаскировалось бы
под measured-5).
- admin-витрина показывала new_count=0 у трёх подряд cian_full_load при реально
сохранённых saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count',
ни 'lots_inserted'. _column_counts дополнен saved_inserted/rows_inserted.
Правки продублированы в scraper_kit/orchestration/runs.py (byte-эквивалент
app.services.scrape_runs, см. докстринг модуля) для параллели: единственный
текущий писатель "attempted"/"failed" (mark_backfill_finished) живёт только в
app-копии, но приоритет ключей/константы держим синхронными на будущее.
Не тронуто: сознательно пустые sweep'ы (errors_count=0, honest empty) и малые
батчи (attempted < 3) — доля отказов на них не считается диагнозом.
Tests: tests/test_honest_run_status_failed_ratio.py (41 кейс, оба модуля,
включая точные прод-числа из трёх фактов выше) + regression-прогон 609 тестов
по всем файлам, трогающим scrape_runs/orchestration.runs — 0 регрессий.