BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно
одно — page.goto(url), — и навигацией браузера на API-хост мы делали то,
чего настоящий клиент не делает никогда. Перехват сети на живой выдаче
30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят
XHR'ы за гео, районами и метро.
Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout:
прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0
лотов. Карточки через те же узлы в те же минуты шли.
Замер трёх режимов на одном узле, один и тот же ресурс:
BFF navigate ChallengeTimeout, 55с
BFF subresource HTTP 200, 52 байта, 6с
BFF page_fetch ошибка
Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно
6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение
— дрейф фонда за пару часов); список офферов отдаётся на всех четырёх
узлах пула, по 20 штук, 120 КБ.
Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение),
subresource (context.request.get из прогретого контекста) и page_fetch
(fetch из страницы). Третий оставлен, потому что теоретически он ближе
всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а
не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает
в куки контекста именно при заходе на страницу, поэтому свип теперь
передаёт origin и referer, которых раньше не передавал вовсе.
_decode_body распаковывает gzip по магическим байтам — карта офферов
приезжает Content-Type: application/gzip, и без этого вызывающий получил
бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не
берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против
62% у карты.
fetch_mode кладётся в payload только когда он не дефолтный — сайдкар
прежней версии не должен получать незнакомый ключ (тот же приём, что с
referer в #3247).
Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6
проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py
— 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру
— обновлены.
Добор карточек Домклика упирался в отказ на 11-й карточке: прогон 5298 дал
attempted=13, enriched=10, blocked=3. Ручные прогоны в живом браузере брали 91 и
40 карточек без единого отказа. Разбор нашёл два отличия, и оба оказались нашими,
а не площадки.
1. Referer не отправлялся НИКОГДА. playwright'овский goto() по умолчанию этот
заголовок не шлёт, а параметр `referer`, который он принимает, мы не передавали.
Площадка видела десяток появлений подряд прямо на URL карточки, без источника
перехода — так не ходит ни один человек. Комментарии в коде при этом уверяли
про «органическую навигацию с реальным Referer»; они врали, теперь исправлены.
Сайдкар принимает `referer` в теле /fetch и ставит его ТОЛЬКО на целевую
навигацию; на origin и якорную вкладку не ставит — туда приходят «сами».
Добор Домклика передаёт страницу выдачи, чем переход и является по смыслу.
2. Зависшее рукопожатие не перезагружалось. _wait_out_pow_challenge построен на
допущении «страница перезагрузит себя сама после решения PoW»; ручная сессия
29.08 через узел 10 это опровергла — выдача осталась на 401, и пропуск
qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных руками:
102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, следом кука-пропуск, и
карточка за 6 секунд. Пока мы только опрашивали content(), такая страница жила
до таймаута, а бэкфилл засчитывал это в блоки. Теперь после
BROWSER_CHALLENGE_RELOAD_AFTER_MS (8с) сайдкар перезагружает сам, не больше
BROWSER_CHALLENGE_MAX_RELOADS (2) раз за фетч.
Оба пути безопасны на откат: без поля `referer` в теле поведение прежнее,
BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает прежний опрос без навигаций,
упавшая перезагрузка не роняет фетч — опрос продолжается в том же бюджете.
Тесты: 191 passed в сайдкаре (было 182) — 4 на Referer, 5 на перезагрузку, в том
числе «страница ожила сама → лишней навигации нет» и «висит вечно → не больше
лимита». Точечные backend-тесты домклика и scraper_kit — 144 passed.
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси,
идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1
даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на
26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха,
один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси
неразличимы (1 из 8 в обоих условиях), то есть дело не в IP.
Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась
через browser.new_page(), в новом изолированном контексте, — и всё равно отказ
со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при
запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу
не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный).
- PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в
generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге
перезапуска 1 это было бы неверно.
- BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages +
BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER}
из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор
другой и своего замера под него нет.
Отдельно починены ~24 холостых охранника в тестах. Они делали
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить
перезапуск браузера; после перехода на словарь этот патч перестал на что-либо
влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15
страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная
константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1
для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют,
остальные 155 удерживаются — значит охранники работают.
#3185 выключил блокировку картинок для avito по гипотезе, что она сама по себе
сигналит QRATOR'у. Гипотеза шла от camoufox'ского LeakWarning, а не от замера.
Что показали замеры после выкатки:
- прямой A/B на сайдкаре — 6/8 успехов с блокировкой против 7/8 без, разница в
пределах шума;
- прод стал хуже: прогон 5200 (картинки блокировались) — 43/63 карточки при 32%
блоков; прогоны 5206 и 5207 (не блокировались) — 2/22 и 2/13 при 91% и 77%.
Причинность НЕ доказана: между прогонами через тот же пул прокси прошло ~40 моих
диагностических запросов, репутация пула могла просесть от них. Но выгоды правка
не показала ни разу, поэтому дефолт возвращается к прежнему поведению.
Ручка из #3185 остаётся целиком: BROWSER_BLOCK_IMAGES и
BROWSER_BLOCK_IMAGES_{PROVIDER} работают как работали, меняется только код-дефолт
(_BLOCK_IMAGES_DEFAULT_BY_PROVIDER теперь пуст). Эффект картинок надо мерить
отдельно и на чистом пуле.
Тест per-provider-override развёрнут в направление, которое ОТЛИЧАЕТСЯ от дефолта
(env=false снимает блокировку): со всеми дефолтами True прежний тест с env=true
проходил бы и у функции, всегда возвращающей True.
Refs #3185
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.
ПОДКЛЮЧЕНО
Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).
filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.
has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.
УДАЛЕНО
Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.
Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.
Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.
v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».
ЗАДОКУМЕНТИРОВАНО
BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.
v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.
house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.
Refs #2674