Compare commits

..

19 commits

Author SHA1 Message Date
66660bd366 fix(mera/b2c): на мобильном липкий хром съедал 42% экрана
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m38s
CI / openapi-codegen-check (pull_request) Successful in 2m26s
CI Trade-In / backend-tests (pull_request) Successful in 5m28s
CI / backend-tests (pull_request) Successful in 17m44s
Жалоба «много всплывашек, экрана не видно» — это не всплывашки, а постоянный
хром: на 375×812 липкая шапка (235 px) и фиксированная нижняя панель (109 px)
вместе занимали 42% экрана и отъедали их при любой прокрутке. Замер тем же
способом (getBoundingClientRect по элементам с position fixed/sticky): было
366 px = 45,1% высоты экрана, стало 119,5 px = 14,7%. На 1280 — 135,5 px до и
после, без изменений.

Шапка на узком экране сведена к одной строке (лого + «Для бизнеса»):

  - навигация по секциям скрыта — четыре пункта переносились в две строки, а
    на мобильном к секциям всё равно доезжают прокруткой; «Статьи» и «МЕРА для
    бизнеса» остаются ссылками в подвале, «Продажа под ключ» и на десктопе
    неактивная заглушка;

  - CTA скрыт — он вёл туда же, куда нижняя панель, и два одинаковых призыва
    на одном экране это и есть жалоба; панель осталась, шапка уступила.

Селектор города (CityPicker) удалён совсем, а не спрятан: он не владел своим
выбором — состояние жило внутри компонента и никуда не отправлялось, — тогда
как гео-гейт расчёта стоит на городе из формы (FreeCheckCard, #2576). Два
места, задающих одно значение, из которых работает одно, — дефект сам по себе;
то же правило уже записано в InnerHeader. Граница покрытия, ради которой
выпадашку открывали, стоит текстом в герое.

Нижняя панель на узком экране — только кнопка: её строка переносилась на две-
три и делала панель втрое выше кнопки. То же обещание остаётся словами героя.

Шаг медиазапроса — 1000 px, а не общий для файла 720: на 720 шапка макета всё
ещё переносится в две строки (замерено: 109 px хрома), то есть возвращать её
там значило бы вернуть тот же дефект планшету.
2026-08-29 23:25:44 +05:00
2390eec740 fix(mera/b2c): вернуть координаты сделки, снесённые моим же коммитом про студию
Коммит 555cce44 обещал в сообщении одно — подписать студию вместо «0-к», — а
сделал ещё и другое: удалил миграцию 280_landing_showcase_deals_coords.sql и
откатил правки mera.py, landing_showcase_deals.py и двух тестов из коммита
cd2a7671. Заметил не я: об этом написал агент, которому потом достался номер
281 и который увидел дыру на 280.

ПРИЧИНА. `git commit` фиксирует ИНДЕКС ЦЕЛИКОМ, а не то, что было добавлено
последним `git add`. В индексе главного рабочего дерева лежали staged-удаления,
оставшиеся от параллельных агентов (они работают в своих worktree, но индекс
основного дерева переживает переключения веток). Я добавил два файла, а
закоммитил вместе с ними чужие удаления.

Что восстановлено: миграция 280 целиком, поля lat/lon в ShowcaseDeal, перенос
координат в пересчёте витрины, оба теста.

Обе величины нужны и не заменяют друг друга: схема улицы (281) закрывает 92%
сделок, координата (280) — карту района для остальных 8%. Конфликты сведены
вручную: механическое «оставить обе стороны» задвоило список колонок в INSERT
и в SELECT, что тесты бы пропустили, а прод — нет.

Проверено: 5085 passed, 35 skipped; миграции 275-281 без дыры; список колонок
INSERT сверен со списком значений программно (15 и 15, порядок совпадает).
2026-08-29 23:25:44 +05:00
a5ca2a326a feat(mera/b2c): в карточке игры схема улицы сделки, а не пятно района
Карта показывала ПОЛИГОН РАЙОНА — десятки квадратных километров, на которых
одинаково выглядят сделка у парка и сделка у ТЭЦ. Схема улиц уже приезжает
строкой витрины (`street_scheme`), рисовать её было нечем.

`StreetMapV3` — серверный компонент, инлайновый <svg>, НИ ОДНОГО внешнего
запроса: тайлы отдали бы провайдеру IP посетителя и то, что он смотрит, на
странице, которая обещает «без звонков и регистрации» (правило шапки
layout.tsx, по нему же в дереве self-hosted шрифты). Слои снизу вверх: плашка
и приглушённая сетка (уже были), вода, фоновые дороги толщиной по классу OSM,
целевая улица акцентом поверх мягкого ореола, подписи соседних улиц
моноширинным.

DealMapV3 ОСТАЁТСЯ и не тронут. Улица матчится у 92.1% строк витрины, у
остальных схемы нет — там по-прежнему район. Ветвление стоит в GuessGameV3,
чтобы обе карты остались тупыми: каждая рисует то, что ей дали, и ни одна не
подставляет вместо отсутствующих данных правдоподобное.

ЧТО КАРТА ГОВОРИТ О СЕБЕ. Подпись — «<УЛИЦА> · ДОМ НЕ ИЗВЕСТЕН». Адрес сделки
уровня улицы, номер дома известен у 2.7% сделок, координата окна — центроид
улицы. Слов «объект» и «адрес» в карточке нет; тест держит и то и другое.
Точку дома поставить нечем и по построению: в `street_scheme` нет ни констант
проекции, ни координат окна.

ЧЕГО НЕ РИСУЕМ. Зданий: `cad_buildings` — 18 307 контуров на город, в плотном
центре 51 здание на радиус 450 м, где их в разы больше; нарисованная застройка
заявляла бы полноту, которой нет. Улицы — фильтрованная выгрузка «источников
шума», дворовых и служебных проездов в ней нет, поэтому фоновые дороги
приглушены: это контекст, а не план квартала.

КАДР — `slice`, а не `meet`: окно квадратное, карточка нет, и `meet` оставил бы
поля по бокам. Обрезка безопасна ровно потому, что целевая улица лежит в ЦЕНТРЕ
окна по построению; обрезаются края с частью подписей соседей.

ДОСТУПНОСТЬ — как у DealMapV3, `aria-hidden`: всё, что схема сообщает, стоит
рядом текстом. `role="img"` заставил бы прочитать то же самое дважды, а
названия соседних улиц без их взаимного расположения ничего не значат.

ЦВЕТА — токенами: `--b2c-water` (вода отдельным оттенком, иначе склеивается с
дорогами) и `--b2c-muted-dark` (подписи на тёмной плашке: `muted` даёт там
3.2:1). Литералов в CSS не добавлено.

Подпись объекта лежит ПОВЕРХ карты, и карта теперь доходит до краёв — добавлено
затемнение под ней и плашка под подписью карты; без них белый текст пересекался
с подсвеченной улицей. Псевдоэлемент позиционирован, поэтому детям
`.gameMapBottom` задан `position: relative` — иначе затемнение красится поверх
текста и стирает его (поймано скриншотом, а не рассуждением).

Тесты (каждый сломан вручную и покраснел): кадр из данных и `slice`; ореол шире
линии и по одному на путь; иерархия дорог и незнакомый класс не дают нулевой
толщины; вода отделена; подпись называет улицу и говорит про дом; `aria-hidden`;
ветвление карточки в обе стороны.

Проверено глазами на dev-сервере, стаб — реальные строки прода плюс схемы,
собранные той же проекцией по геометрии gendesign: улица сматчилась; не
сматчилась (виден район); полей схемы нет вовсе (тот же район, без падения).
2026-08-29 23:25:44 +05:00
ca3f073b0e feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
2026-08-29 23:25:44 +05:00
4e3279d448 fix(mera/b2c): студия подписывалась как «0-к» на витрине сделок
Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки
витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит.
«0-к» читается как ошибка выгрузки, а не как тип квартиры.

Студией её называет и наш собственный бэктест (per_rooms.label в
scripts/backtest_estimator.py), и рынок.

Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по
значению, обратная правка — снова зелено.
2026-08-29 23:25:44 +05:00
272d807f55 feat(mera/b2c): в карточке игры настоящая карта вместо декорации — и подпись, которую данные выдерживают
Блок `gameMap` рисовал CSS-сетку и два прямоугольника-«дороги», а подпись
говорила «ОБЪЕКТ · РАЙОН» — то есть заявляла местоположение объекта. Географии
там не было вовсе.

Что сделано: `DealMapV3` — инлайновый <svg> по статике `ekb-districts.ts`, без
единого внешнего запроса (то же правило, по которому в этом дереве self-hosted
шрифты). Район сделки — заливка и обводка акцентом, соседние — контур; кадр по
bbox района с запасом, не по городу: в городском кадре район занимает несколько
процентов площади. Точка — по `project(lon, lat)` координаты витрины.

ПОДПИСЬ. Координата в данных — ЦЕНТРОИД УЛИЦЫ, не дом: 34 021 сделка, 34 017 с
координатой, различных точек 991, номер дома известен у 2.7% (прод, 29.08.2026).
Поэтому карта подписана «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · РАЙОН» — она произносит
границу вслух, а не оставляет читателю догадываться по размеру маркера. Слова
«объект» и «адрес» в карточке не появляются.

ТОЧКИ НЕТ — ДВА СЛУЧАЯ, оба дают карту с подсвеченным районом и без прочерка:
координаты нет; координата есть, но лежит вне кадра. Второе не теоретическое —
2 132 строки из 34 021 (6.3%) в той же выборке имеют координату за пределами
города, встречаются точки за сотни километров. Прижать такую к краю значило бы
показать место, которого в данных нет; подпись в обоих случаях — «КООРДИНАТЫ
НЕТ».

Доступность: <svg> aria-hidden, всё его содержание (район) стоит рядом текстом
дважды — в подписи карты и в мете сделки.

Гейт витринных чисел: из скана исключён сгенерированный `ekb-districts.ts` —
координаты полигонов не утверждают ничего о рынке. Исключение не бланковое:
добавлена проверка, что исключённый файл несёт маркер генератора.

Проверено глазами на dev-сервере с заглушкой бэкенда: три состояния (Кировский с
точкой, Чкаловский — кадр едет, сделка без координаты).
2026-08-29 23:25:44 +05:00
f3401badd0 feat(mera/b2c): координата сделки доезжает до витрины — с записанной границей честности
Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты,
площадь и квартал в `landing_showcase_deals` есть, координаты не было.

Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE),
задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как
Optional[float].

ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой
строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а
не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается
витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка,
34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке,
при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и
неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому
следующий, кто возьмётся зумить карту, об этом споткнётся.

NULLABLE и без отбраковки: строка без координаты остаётся на витрине с
lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не
связанному с качеством оценки, то есть та же порча витрины, которую здесь уже
чинили (порог по величине ошибки).

Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ:
  * перестановка lat/lon в build_row  → 60.6055 == 56.8386
  * `if lat is None: return None`     → None is not None
  * перестановка lat/lon в ручке      → 60.6055 == 56.8386
  * фильтр строк без координат в ручке → len([]) == 1
Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому
в фикстурах намеренно непохожие величины: 56.8386 против 60.6055.

Фронтенд не тронут — его делает следующий шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:25:44 +05:00
7b12b07935 feat(mera/b2c): границы районов ЕКБ статикой — основа настоящей карты в игре
Карточка игры «Сыграйте против МЕРЫ» показывает декоративный прямоугольник:
сетка и две «дороги», нарисованные CSS. Заменяем настоящей геометрией.

ПОЧЕМУ СТАТИКА, А НЕ ТАЙЛЫ. Публичное дерево mera-public держится без единого
внешнего запроса — шрифты self-hosted, Leaflet с unpkg сюда намеренно не тянули
(шапка layout.tsx). Тайловый провайдер получал бы IP посетителя и то, что он
смотрит, на странице, которая обещает «без звонков и регистрации». Границы
районов не меняются годами, раздавать их в рантайме незачем.

Что в коммите:
· backend/scripts/export_ekb_districts_svg.py — генератор. У данных обязано быть
  проверяемое происхождение: источник — боевая gendesign_ekb_districts_geom
  (OSM через FDW, читается с 29.08 — грант #3227), допуск упрощения и проекция
  названы в докстринге вместе с причиной выбора.
· _components/v3/ekb-districts.ts — сгенерированное, руками не править.
  8 районов, 7826 символов путей, плюс project() для точки (lon,lat).

Упрощение 0.0012° (~70-130 м) выбрано под масштаб карточки — она показывает
район целиком, где такая ошибка меньше толщины линии.
2026-08-29 23:25:44 +05:00
8f0c2ce1f4 Merge pull request 'feat(mera/b2c): витрина лэндинга переезжает с выдуманных чисел на замеренные' (#3243) from feat/b2c-landing-honest-copy into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m10s
Deploy Trade-In / deploy (push) Successful in 1m5s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 14s
2026-08-29 18:25:20 +00:00
6dd8d131ae feat(mera/estimate): характеристики дома из справочника, а не только из формы (#3242)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-29 18:06:17 +00:00
3d51c08e44 Merge pull request 'fix(tradein/domclick): подтверждённый отказ площадки уехал в ветку «сбой транспорта» и перестал банить узел' (#3241) from fix/3239-sidecar-ban-page-reaches-ban-path into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 3m0s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 8m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-29 16:18:50 +00:00
8f3912cdf6 Merge pull request 'docs(tradein/domclick): два комментария описывали пул, которого нет с миграции 253' (#3240) from docs/3189-stale-pool-comments into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m6s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-08-29 16:13:53 +00:00
bot-backend
c5784e85bc fix(tradein/domclick): подтверждённый отказ площадки уехал в ветку «сбой транспорта» и перестал банить узел
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 1m17s
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
#3237 научил сайдкар опознавать статический отказ Домклика самостоятельно —
это правильно и работает, но вместе с распознаванием ban-сигнал переехал не
туда. Отказ стал приезжать обычной 500-кой: browser_fetcher обнуляет на ней
last_response_status, и в detail.py срабатывает ветка except Exception,
которая по построению НЕ зовёт report_ban («не подтверждённый маркер-бан, а
сбой транспорта», #2600 п.4).

Итог на проде (прогон 5287): ban_kinds сменился с platform на unknown, и при
шести «статический отказ площадки» подряд в логах сайдкара в
scrape_proxy_source_bans не появилось НИ ОДНОЙ записи. Это не косметика
счётчиков — platform единственный диагноз, запускающий ротацию IP, поэтому мы
продолжали бы долбиться в отказавший узел вместо перехода на свободный.

Правка возвращает отказ на ban-путь, сохраняя разделение, ради которого
#3237 и делался:

- сайдкар кладёт в тело ошибки структурный признак ban_page и апстрим-статус.
  HTTP-код НЕ меняем: на 500 завязана classify_browser_probe;
- SidecarBanPageError — подкласс httpx.HTTPStatusError, поэтому ловля у
  прочих поставщиков и retry-политика fetch() не замечают нового типа;
- detail.py различает две ветки: подтверждённый отказ → report_ban + статус
  из исключения, транспортный сбой — как раньше.

Статус несём отдельным полем, а не через last_response_status: на error-пути
fetch() его обнуляет, а у Домклика отказ приходит с 401, без которого
классификатор ставит unknown. Подстрокой в тексте исключения признак искать
нельзя — _raise_for_sidecar_status обрезает тело до 300 символов, и
формулировка отказа менялась дважды за месяц.

Тесты держат обе ветки раздельно на всех трёх уровнях: сайдкар (признак есть
у бан-страницы, отсутствует у транспортной ошибки), фетчер (тип и
upstream_status, включая ловушку bool-как-int из #3196), detail.py
(report_ban зовётся / не зовётся, статус доезжает).

Closes #3239
2026-08-29 19:12:36 +03:00
bot-backend
3059f09e53 docs(tradein/domclick): два комментария описывали пул, которого нет с миграции 253
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 4m48s
Оба места утверждали, что у Домклика один выделенный резидентный прокси и
пула нет. Это перестало быть правдой ещё в #2800 (миграция 253 сняла
резервацию узла), но текст остался — и именно на него опирался тикет #3189,
поставленный под «калибровку» ограничения, которого не существует.
Свип к тому же ходит через пул давно (serp.py:359), а бэкфилл подключён к
нему в PR #3222.

Заодно докстринг называл не тот ограничитель: свип кладёт не счётчик
провалов lease, а break по первому DomClickBlockedError (#2854) — до
ротации дело не доходит ни при каком счётчике, отсюда buckets_completed=0.

Только комментарии, поведение не меняется. Миграция 175 уже применена, а
_schema_migrations трекает по имени файла без checksum — правка текста
её не перезапустит.
2026-08-29 18:58:51 +03:00
9e243fd643 Merge pull request 'fix(smoke): периметр требовал 401 от вебхука, который PR-D3 открыл намеренно' (#3238) from fix/smoke-payments-notify-after-d3 into main
All checks were successful
perimeter-smoke-mera / smoke (push) Successful in 10s
2026-08-29 15:51:07 +00:00
bot-backend
e13374e319 fix(smoke): периметр требовал 401 от вебхука, который PR-D3 открыл намеренно
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Деплой main после #3237 покраснел на perimeter-smoke:
  FAIL: trade-in payments/notify — 401 anonymous -> got '405', expected 401

Это не регрессия, а сработавшая канарейка. PR-D3 (#3231) внёс notify в
`_PUBLIC_PATHS` осознанно: это вебхук банка, он обязан быть достижим без наших
заголовков. В теле того PR прямо сказано, что 401 здесь стал бы признаком
поломки («вебхук банка получил бы отказ»). Ожидание в смоуке обновить забыли —
ровно то, о чём предупреждал комментарий над блоком в PR-D2.

Замена не ослабляет проверку, а усиливает: вместо GET→401 теперь POST→503,
что утверждает сразу два факта — маршрут существует (404 означал бы старый
образ) И приём платежей выключен (`payments_enabled=False`). 200 здесь поймает
включение флага, сделанное мимо этого смоука. Плюс GET→405 закрепляет, что путь
принимает только POST.

Проверено на живом проде: POST notify 503, GET notify 405, checkout 401,
meraocenka 404 на обоих путях; payments/payment_notifications/
payment_entitlements пусты. Полный прогон скрипта — ALL CHECKS PASSED.

Ожидания под meraocenka.ru не тронуты: PR-D4 не смержен, 404 там остаётся
канарейкой.
2026-08-29 18:49:43 +03:00
84505b1e0b chore(site-finder): удалить мёртвый код — 14 неиспользуемых символов (#3235)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m39s
Deploy / deploy (push) Successful in 1m18s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 12s
2026-08-29 15:39:10 +00:00
d43d8b0c60 Merge pull request 'fix(tradein/domclick): мы считали блоком собственное недоделанное рукопожатие' (#3237) from fix/domclick-challenge-not-a-block into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Successful in 2m59s
Deploy Trade-In / build-frontend (push) Successful in 3m27s
Deploy Trade-In / test (push) Successful in 4m25s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 2m35s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Failing after 10s
2026-08-29 15:38:04 +00:00
bot-backend
5391a36880 fix(tradein/domclick): недосчитанная QRATOR-страница уходила наверх как контент
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 1m15s
Домклик отдаёт рукопожатие без единого стабильного маркера (в отличие от
Авито), поэтому _CHALLENGE_MARKERS (сняты с Авито, #3045) на нём никогда не
матчились и ветка ожидания не включалась — недосчитанная страница уезжала
наверх, парсер не находил __SSR_STATE__ и поднимал ложный блок. Это и был
двухнедельный attempted=3, blocked=3, enriched=0 у domclick_detail_backfill.

Для provider=="domclick" логика инвертирована: положительно опознаём только
два крайних состояния — успех (__SSR_STATE__) и статический отказ площадки
(«403 | Домклик» / «похоже, ваш запрос выглядит необычно»); всё остальное
(загрузчик рукопожатия, нерендеренная PoW-страница без каких-либо маркеров)
трактуется как «рукопожатие ещё идёт» и уходит в существующий
_wait_out_pow_challenge с кастомным is_pending. HTTP-статус для DomClick не
используется как сигнал (401 приходит и у отказа, и у успеха, и у здорового
рукопожатия) — решает только тело. Avito и прочие провайдеры идут по старой
elif-ветке без изменений.

_wait_out_pow_challenge получил опциональный параметр is_pending (дефолт
_is_pow_challenge) — golden-parity для всех, кроме domclick.
2026-08-29 18:31:35 +03:00
23 changed files with 1145 additions and 281 deletions

View file

@ -150,13 +150,6 @@ NOISE_L_BASE: dict[str, float] = {
}
def _wind_label(deg: float) -> str:
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
idx = round(deg / 45) % 8
return rose[idx]
# Координаты центра ЕКБ — Площадь 1905 года
EKB_CENTER_LAT: float = 56.838011
EKB_CENTER_LON: float = 60.597474

View file

@ -6,29 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
class RecentPermit(BaseModel):
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
permit_type: str
permit_number: str
issue_date: str | None
developer_name: str | None
developer_inn: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
total_area_sqm: float | None
class PermitsSummary(BaseModel):
"""Агрегированная сводка по разрешениям в квартале."""
rns_count: int
rve_count: int
rns_total_area_sqm: float
by_developer: list[dict[str, Any]]
# ── Connection points schemas (issue #115) ────────────────────────────────────
@ -564,16 +541,6 @@ class DeveloperAttributionResult(BaseModel):
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
class LayoutSignature(BaseModel):
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
"""
room_bucket: Literal["studio", "1", "2", "3", "4+"]
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
class BestLayoutsRequest(BaseModel):
"""Параметры запроса top-планировок в радиусе вокруг участка."""

View file

@ -181,13 +181,3 @@ def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tu
# ── Stub for future Celery download task ──────────────────────────────────────
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
"""Placeholder для будущей Celery-задачи download_domrf_documents.
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
обновляет domrf_kn_documents.local_path + downloaded_at.
Реализация отдельный PR (future task).
"""
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")

View file

@ -1807,55 +1807,6 @@ async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict
return _flatten_table(payload)
async def probe_endpoint(
url_or_path: str,
region_code: int = 66,
headed: bool = False,
load_state: str | None = None,
) -> dict[str, Any]:
"""Quick one-shot probe through a real Playwright session for debugging.
Returns {status, content_type, body_preview, full_url}. Use to verify that
a URL works in our session before/after a failing scraper run.
"""
if url_or_path.startswith("http"):
# split into path+query for BrowserSession.get_json
from urllib.parse import parse_qsl, urlsplit
s = urlsplit(url_or_path)
path = s.path
params = dict(parse_qsl(s.query, keep_blank_values=True))
else:
path = url_or_path.split("?", 1)[0]
params = {}
if "?" in url_or_path:
from urllib.parse import parse_qsl
params = dict(parse_qsl(url_or_path.split("?", 1)[1], keep_blank_values=True))
async with BrowserSession(
region_code=region_code, headed=headed, load_state=load_state
) as sess:
try:
payload = await sess.get_json(path, params)
body = json.dumps(payload, ensure_ascii=False)[:2000]
qs = "&".join(f"{k}={v}" for k, v in params.items())
full_url = f"{BASE_URL}{path}" + (f"?{qs}" if qs else "")
return {
"status": 200,
"content_type": "application/json",
"body_preview": body,
"full_url": full_url,
}
except Exception as e:
return {
"status": _http_status_of(e),
"content_type": "error",
"body_preview": str(e)[:2000],
"full_url": f"{BASE_URL}{path}",
}
def log_progress(
db: Session,
run_id: int,

View file

@ -133,21 +133,6 @@ def fetch_geoportal(
raise NspdLiteError(f"Network error: {e}") from e
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный кадастровый квартал (typed wrapper)."""
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный участок ЗУ (typed wrapper)."""
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретное здание ОКС (typed wrapper)."""
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
# ── Bulk через rosreestr2coord library ──────────────────────────────────────

View file

@ -28,7 +28,7 @@ import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date, datetime
from datetime import date
from typing import Any
import httpx
@ -557,36 +557,3 @@ class ObjectiveClient:
# ── удобный one-shot helper ─────────────────────────────────────────────────
def fetch_with_raw_log(
fn_name: str,
*,
save_dir: str | None = None,
**kwargs: Any,
) -> tuple[Any, str | None]:
"""Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
from pathlib import Path
client = ObjectiveClient()
try:
method = getattr(client, fn_name)
data = method(**kwargs)
finally:
client.close()
file_path: str | None = None
if save_dir:
Path(save_dir).mkdir(parents=True, exist_ok=True)
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
slug = (
"_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
or "all"
)
slug = slug.replace(" ", "_").replace("/", "_")
file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
Path(file_path).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return data, file_path

View file

@ -61,18 +61,6 @@ _KRT_AT_SQL = text("""
""")
def _functional_zone_at(
client: EKBGeoportalClient, lon: float, lat: float
) -> dict[str, Any] | None:
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
try:
feats = client.features_at_point("functional_zone", lon, lat)
except Exception as exc: # внешний геопортал, graceful
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
return None
return feats[0].properties if feats else None
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.

View file

@ -15,7 +15,6 @@ Graceful: нет таблицы / пустой WKT / исключение БД
from __future__ import annotations
import logging
import re
from typing import Any
from sqlalchemy import text
@ -98,12 +97,6 @@ _PLANNING_NO_MATCH_SQL = text(
)
def _extract_paga_num(full_name: str) -> str | None:
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
return m.group(1) if m else None
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.

View file

@ -472,18 +472,6 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
return result_dict
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
(как noise/poi sync) Overpass-fetch async, запись в БД sync. Возвращает
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
"""
elements = await fetch_overpass_utility()
return _upsert_elements(elements)
# Junk-значения OSM tags, которые не несут смысла для попапа.
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})

View file

@ -150,69 +150,6 @@ def _build_beat_schedule_from_db() -> dict:
return schedule
def _build_beat_schedule_fallback() -> dict:
"""Fallback-расписание из env vars — используется если БД недоступна на старте."""
logger.warning("beat_schedule: строим fallback из env vars")
schedule: dict = {}
# scrape_kn
try:
for rc in _default_regions():
schedule[f"kn-region-{rc}"] = {
"task": "tasks.scrape_kn.scrape_kn_region",
"schedule": _parse_cron(settings.scrape_kn_cron),
"args": [rc, None],
}
except Exception as e:
logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)
# objective_sync
try:
from app.core.db import SessionLocal
from app.services.objective_sync_config import get_cron_schedule_safe
cron_str = get_cron_schedule_safe(SessionLocal)
except Exception:
cron_str = settings.objective_sync_cron
try:
schedule["objective-sync"] = {
"task": "tasks.scrape_objective.sync_all_groups",
"schedule": _parse_cron(cron_str),
"kwargs": {"triggered_by": "beat"},
}
except Exception as e:
logger.warning("beat_schedule fallback: objective_sync failed: %s", e)
# refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
try:
schedule["refresh-ekb-districts-medians"] = {
"task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
"schedule": _parse_cron("0 4 5 * *"),
"kwargs": {"window_months": 24, "min_deals": 50},
}
except Exception as e:
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule
def build_beat_schedule() -> dict:
"""Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.

View file

@ -174,25 +174,38 @@ check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301
check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301
check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
# СТОРОН прямо сейчас:
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3
# (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание
# здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с
# ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним.
#
# Состояние на 29.08, чего ждём сейчас:
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
# платёжные пути и требует X-Authenticated-User → 401 анониму.
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ
# канарейка: PR-D4 не смержен, 404 должен держаться;
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401
# анониму (он не публичный и не станет им);
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см.
# комментарий у самой проверки ниже.
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и
# потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401
# здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты.
#
# Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"

View file

@ -2405,6 +2405,112 @@ def _fetch_anchor_comps(
return [], None
@dataclass(frozen=True)
class _HouseFacts:
"""Характеристики дома из справочника `houses` (#3234).
total_floors / year_built / house_type fallback для пропущенных
payload-полей ПЕРЕД OSM Overpass (houses на два порядка полнее и не тратит
внешний вызов). material_walls читается на будущее потребителя ниже по
estimator пока нет, но данные есть: 7838 домов из 10221 (77%) на проде после
наполнения из ГИС ЖКХ + DOM.РФ 2026-08-29.
"""
house_id: int
total_floors: int | None
year_built: int | None
house_type: str | None
material_walls: str | None
def _lookup_house_facts(
db: Session,
*,
target_house_id: int | None,
lat: float | None,
lon: float | None,
) -> _HouseFacts | None:
"""Читает total_floors/year_built/house_type/material_walls из `houses`.
Ключ canonical target_house_id (уже резолвлен match_house_readonly выше
по estimate-функции); если он None фолбэк на ближайший дом в радиусе 60м
от subject lat/lon (ST_DWithin + сортировка по расстоянию, LIMIT 1).
Best-effort: любая ошибка БД None + WARNING, оценка не должна падать
(тот же паттерн try/except + db.rollback(), см. _fetch_anchor_comps выше).
"""
try:
if target_house_id is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE id = CAST(:hid AS bigint)
"""
),
{"hid": target_house_id},
)
.mappings()
.first()
)
elif lat is not None and lon is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
60
)
ORDER BY geom::geography <-> ST_MakePoint(:lon, :lat)::geography
LIMIT 1
"""
),
{"lon": lon, "lat": lat},
)
.mappings()
.first()
)
else:
return None
except Exception as exc: # pragma: no cover — defensive
logger.warning("house facts lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception: # pragma: no cover — defensive
pass
return None
if row is None:
return None
# Guard типов строки: houses.id — NOT NULL bigint, любая другая форма (в т.ч.
# тестовый db-double без явного мока запроса) трактуется как «строки нет» —
# тот же best-effort, что и явная ошибка выше. Остальные поля best-effort
# каждое по отдельности — неожиданный тип поля не должен ронять facts целиком.
house_id = row["id"]
if not isinstance(house_id, int):
return None
def _as_int(value: Any) -> int | None:
return value if isinstance(value, int) else None
def _as_str(value: Any) -> str | None:
return value if isinstance(value, str) else None
return _HouseFacts(
house_id=house_id,
total_floors=_as_int(row["total_floors"]),
year_built=_as_int(row["year_built"]),
house_type=_as_str(row["house_type"]),
material_walls=_as_str(row["material_walls"]),
)
def _band_haircut(anchor_ppm2: float) -> float:
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
@ -4040,7 +4146,39 @@ async def estimate_quality(
# gateway-таймауту; деградируем в None при превышении budget.
target_year = payload.year_built
target_house_type = payload.house_type
if target_year is None or target_house_type is None:
target_total_floors = payload.total_floors
# 2a. #3234: houses — наш справочник, приоритет ПЕРЕД OSM Overpass (year
# 86.8% / floors 98.3% покрытия на проде против 68 строк в
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
# заполняет только то, что пользователь не указал.
if target_year is None or target_house_type is None or target_total_floors is None:
house_facts = await asyncio.to_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
lat=geo.lat,
lon=geo.lon,
)
if house_facts is not None:
filled: list[str] = []
if target_year is None and house_facts.year_built is not None:
target_year = house_facts.year_built
filled.append(f"year_built={target_year}")
if target_house_type is None and house_facts.house_type is not None:
target_house_type = house_facts.house_type
filled.append(f"house_type={target_house_type}")
if target_total_floors is None and house_facts.total_floors is not None:
target_total_floors = house_facts.total_floors
filled.append(f"total_floors={target_total_floors}")
if filled:
logger.info(
"estimate: house facts fallback house_id=%s%s",
house_facts.house_id,
", ".join(filled),
)
if target_year is None or target_house_type is None or target_total_floors is None:
house_meta = await _with_budget(
get_house_metadata(geo.lat, geo.lon, db),
settings.estimate_house_meta_timeout_s,
@ -4051,6 +4189,8 @@ async def estimate_quality(
target_year = house_meta.year_built
if target_house_type is None:
target_house_type = house_meta.house_type
if target_total_floors is None:
target_total_floors = house_meta.total_floors
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
@ -4097,7 +4237,7 @@ async def estimate_quality(
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=payload.total_floors,
total_floors=target_total_floors,
cohort_year_min=cy_min,
cohort_year_max=cy_max,
)
@ -4122,7 +4262,7 @@ async def estimate_quality(
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=payload.total_floors,
total_floors=target_total_floors,
)
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
# surfaced later as the "снят фильтр по году постройки" relaxation label.
@ -4142,7 +4282,7 @@ async def estimate_quality(
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=payload.total_floors,
total_floors=target_total_floors,
)
if len(listings_wide) > len(listings):
listings = listings_wide
@ -4166,7 +4306,7 @@ async def estimate_quality(
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=payload.total_floors,
total_floors=target_total_floors,
)
if len(listings_widearea) > len(listings):
listings = listings_widearea
@ -4225,7 +4365,7 @@ async def estimate_quality(
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=payload.total_floors,
total_floors=target_total_floors,
)
if len(candidate) > len(listings):
return candidate, tier

View file

@ -38,10 +38,13 @@
-- same-site SERP-origin navigation cadence from PR #2430 -- QRATOR is
-- reputation-based, not purely rate-based, but a more human cadence
-- reduces the odds of tripping it in the first place.
-- max_consecutive_blocks -- 3 (vs Avito's 5): DomClick has no IP-rotation recovery
-- on block (one dedicated residential proxy, not a pool) -- aborting
-- sooner avoids hammering an already-reputation-damaged proxy/session
-- and preserves it for the next scheduled window.
-- max_consecutive_blocks -- 3 (vs Avito's 5): aborting sooner avoids hammering an
-- already-reputation-damaged proxy/session and preserves it for the
-- next scheduled window. NB the original rationale here ("one
-- dedicated residential proxy, not a pool") is obsolete: migration
-- 253 (#2800) dropped the reservation, and the backfill was wired
-- into the shared pool in PR #3222 -- all four nodes carry
-- provider_affinity='any'.
--
-- Seeded with enabled=false: activation is a deliberate, separate manual step after
-- this migration deploys and the orchestrator is smoke-tested against a live cookie

View file

@ -36,7 +36,9 @@ import json
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers.domclick.detail import (
@ -438,6 +440,61 @@ async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
bf.report_ban.assert_not_called()
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
#
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
# банит, голый транспортный сбой — нет (тест выше).
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
return SidecarBanPageError(
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
upstream_status=upstream_status,
)
@pytest.mark.asyncio
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock
если бы код читал его, ассерт ниже упал бы.
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status == 401
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status is None
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────

View file

@ -0,0 +1,294 @@
"""#3234: `houses` как fallback-источник total_floors/year_built/house_type в estimate.
Приоритет: payload (пользователь) houses (наш справочник, локальный, ~87%
покрытия года / ~98% этажности на проде) OSM Overpass (get_house_metadata,
внешний вызов с таймаутом, house_metadata-кэш всего 68 строк на базу).
payload НИКОГДА не переопределяется: houses/OSM заполняют только то, что
payload не задал. get_house_metadata должен вызываться ТОЛЬКО если после
payload+houses что-то всё ещё неизвестно (экономия внешнего вызова).
year_built из houses обязан пройти через тот же _sanitize_build_year, что и
payload/OSM (Mera-audit 2026-08-02, прод-инцидент year_built=1829 см.
test_estimator_implausible_year.py).
Два уровня тестов:
1. _lookup_house_facts() изолированно маршрутизация SQL (house_id vs
геофолбэк) + best-effort на ошибке БД (без сети).
2. estimate_quality() целиком, offline зеркалит паттерн
test_estimator_client_coords.py: все внешние вызовы замоканы,
_fetch_analogs захватывает kwargs, чтобы проверить что именно уехало
вниз по пайплайну.
"""
from __future__ import annotations
import contextlib
import os
# Settings требует DATABASE_URL при импорте app.core.config — до любого app-импорта.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# ── Level 1: _lookup_house_facts() изолированно (без estimate_quality) ─────
def test_lookup_house_facts_uses_house_id_when_present() -> None:
"""target_house_id задан → SQL идёт по id, геофолбэк (ST_DWithin) не участвует."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 42,
"total_floors": 9,
"year_built": 1975,
"house_type": "panel",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is not None
assert (facts.house_id, facts.total_floors, facts.year_built, facts.house_type) == (
42,
9,
1975,
"panel",
)
sql_text = str(db.execute.call_args.args[0])
assert "WHERE id = " in sql_text
assert "ST_DWithin" not in sql_text
def test_lookup_house_facts_falls_back_to_geo_radius_when_no_house_id() -> None:
"""target_house_id=None → SQL идёт по ST_DWithin(60м) + ближайший к lat/lon."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"id": 7,
"total_floors": 5,
"year_built": 1968,
"house_type": "brick",
"material_walls": None,
}
facts = _lookup_house_facts(db, target_house_id=None, lat=56.838, lon=60.595)
assert facts is not None
assert facts.house_id == 7
sql_text = str(db.execute.call_args.args[0])
assert "ST_DWithin" in sql_text
assert "60" in sql_text
def test_lookup_house_facts_no_key_returns_none_without_query() -> None:
"""Ни house_id, ни lat/lon — запроса к БД нет вообще, сразу None."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
facts = _lookup_house_facts(db, target_house_id=None, lat=None, lon=None)
assert facts is None
db.execute.assert_not_called()
def test_lookup_house_facts_db_error_is_graceful() -> None:
"""Любая ошибка БД → None + rollback, estimate не должен падать (best-effort)."""
from app.services.estimator import _lookup_house_facts
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
facts = _lookup_house_facts(db, target_house_id=42, lat=None, lon=None)
assert facts is None
db.rollback.assert_called_once()
# ── Level 2: estimate_quality() целиком, offline ────────────────────────────
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
def _make_payload(*, year_built=None, house_type=None, total_floors=None):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=38.8,
rooms=1,
floor=4,
lat=56.838,
lon=60.595,
year_built=year_built,
house_type=house_type,
total_floors=total_floors,
)
def _house_facts(*, house_id=777, total_floors=9, year_built=1975, house_type="panel"):
from app.services.estimator import _HouseFacts
return _HouseFacts(
house_id=house_id,
total_floors=total_floors,
year_built=year_built,
house_type=house_type,
material_walls=None,
)
def _house_meta(*, total_floors, year_built, house_type):
from app.services.house_metadata import HouseMetadata
return HouseMetadata(
lat=56.838,
lon=60.595,
year_built=year_built,
total_floors=total_floors,
house_type=house_type,
total_units=None,
source="osm",
)
def _run_estimate(payload, *, house_facts, house_meta):
"""Прогоняет estimate_quality offline. Возвращает (result, _fetch_analogs mock,
get_house_metadata mock) mocks выжившие после await для инспекции call_args."""
from app.services.estimator import estimate_quality
db = MagicMock()
geocode_mock = AsyncMock(return_value=_make_fake_geo())
house_facts_mock = MagicMock(return_value=house_facts)
house_meta_mock = AsyncMock(return_value=house_meta)
analogs_mock = MagicMock(return_value=([], False, "W"))
async def _go():
with contextlib.ExitStack() as stack:
for cm in (
patch("app.services.estimator.geocode", new=geocode_mock),
patch("app.services.estimator._lookup_house_facts", new=house_facts_mock),
patch("app.services.estimator.get_house_metadata", new=house_meta_mock),
patch("app.services.estimator._fetch_analogs", new=analogs_mock),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_go)
return result, analogs_mock, house_meta_mock
def _floors_seen(mock) -> set:
return {c.kwargs["total_floors"] for c in mock.call_args_list}
def _years_seen(mock) -> set:
return {c.kwargs["year_built"] for c in mock.call_args_list}
def _house_types_seen(mock) -> set:
return {c.kwargs["house_type"] for c in mock.call_args_list}
def test_empty_payload_uses_houses_facts() -> None:
"""payload пустой, houses знает всё → его значения уезжают в _fetch_analogs."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert analogs_mock.call_args_list, "estimate должен вызвать _fetch_analogs хотя бы раз"
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {1975}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_payload_wins_over_conflicting_houses_facts() -> None:
"""payload задан, houses знает ДРУГОЕ → побеждает payload (user главнее houses)."""
payload = _make_payload(year_built=2015, house_type="brick", total_floors=5)
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {2015}
assert _house_types_seen(analogs_mock) == {"brick"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None
def test_neither_payload_nor_houses_falls_back_to_osm() -> None:
"""Ни payload, ни houses ничего не знают → OSM get_house_metadata вызывается
(старое поведение сохранено, houses тут ничем не помогает)."""
payload = _make_payload()
meta = _house_meta(total_floors=5, year_built=1968, house_type="brick")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=None, house_meta=meta
)
house_meta_mock.assert_awaited()
assert _floors_seen(analogs_mock) == {5}
assert _years_seen(analogs_mock) == {1968}
assert _house_types_seen(analogs_mock) == {"brick"}
assert result.estimate_id is not None
def test_payload_and_houses_cover_everything_skips_osm() -> None:
"""payload частично + houses добивает остальное → OSM НЕ вызывается (экономия
внешнего бюджетируемого вызова, п.5 задачи)."""
payload = _make_payload(year_built=2015) # house_type/total_floors придут из houses
facts = _house_facts(total_floors=9, year_built=1975, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
house_meta_mock.assert_not_awaited()
assert _floors_seen(analogs_mock) == {9}
assert _years_seen(analogs_mock) == {2015} # payload побеждает для года
assert _house_types_seen(analogs_mock) == {"panel"}
assert result.estimate_id is not None
def test_implausible_year_from_houses_is_sanitized() -> None:
"""houses отдал неправдоподобный год (1829, прод-паттерн) → _sanitize_build_year
отбивает его в None; total_floors/house_type из houses при этом всё равно
применяются (год независимый гейт, не блокирует остальные поля)."""
payload = _make_payload()
facts = _house_facts(total_floors=9, year_built=1829, house_type="panel")
result, analogs_mock, house_meta_mock = _run_estimate(
payload, house_facts=facts, house_meta=None
)
assert _years_seen(analogs_mock) == {None}
assert _floors_seen(analogs_mock) == {9}
assert _house_types_seen(analogs_mock) == {"panel"}
house_meta_mock.assert_not_awaited()
assert result.estimate_id is not None

View file

@ -27,8 +27,14 @@ from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.browser_fetcher import (
BrowserFetcher,
SidecarBanPageError,
_raise_for_sidecar_status,
ban_kind_from_status,
)
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
@ -158,3 +164,73 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None:
allowed = {"platform", "infra", "unknown", None}
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
assert ban_kind_from_status(status) in allowed
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
#
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
# 'platform', то есть ротация IP не запустилась бы вовсе.
def _sidecar_response(body: Any) -> httpx.Response:
return httpx.Response(
500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
def test_ban_page_error_is_httpx_status_error() -> None:
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
with pytest.raises(httpx.HTTPStatusError):
_raise_for_sidecar_status(resp)
def test_plain_500_stays_plain_status_error() -> None:
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"})
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_ban_page_without_status_gives_none() -> None:
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_ban_page_true_as_bool_status_is_rejected() -> None:
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_non_json_error_body_does_not_break() -> None:
resp = httpx.Response(
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_success_response_raises_nothing() -> None:
resp = httpx.Response(
200, json={"html": "<html/>", "status": 200},
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
)
_raise_for_sidecar_status(resp)

View file

@ -122,7 +122,7 @@ import asyncio
import base64
import logging
import os
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from urllib.parse import urlparse
from aiohttp import web
@ -961,7 +961,17 @@ async def fetch_handler(request: web.Request) -> web.Response:
type(exc).__name__,
exc,
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
error_body: dict = {"error": f"{type(exc).__name__}: {exc}"}
if isinstance(exc, BanPageDetectedError):
# #3239: бан-страница — ПОДТВЕРЖДЁННЫЙ маркер-детект, а не сбой
# транспорта, и только за первым стоит report_ban на клиенте.
# До этой правки оба случая приезжали одинаковой 500-кой, клиент
# различить их не мог и настоящий отказ площадки переставал
# ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500
# завязана classify_browser_probe, признак несёт тело.
error_body["ban_page"] = True
error_body["status"] = _last_response_status.get(provider)
return web.json_response(error_body, status=500)
# Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий
# только его, ничего не заметит. "status" может быть null (goto вернул None).
@ -1230,6 +1240,55 @@ def _is_ban_page(html: str) -> bool:
return any(marker in lower for marker in _BAN_MARKERS)
# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ─────────────────
# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ
# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа
# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого
# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор
# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене
# вёрстки, а до тех пор будет ловить призрак несуществующего состояния.
#
# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния —
# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279
# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт»
# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру —
# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ),
# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен.
#
# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт
# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой
# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном
# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но
# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище:
# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая
# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой
# маркер успеха — единственный вариант, который не размывается на промежуточных
# состояниях.
_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__"
# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик»
# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см.
# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело
# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных
# страниц в замере). Ждать тут нечего — статическая страница сама себя не
# перезагрузит.
_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = (
"похоже, ваш запрос выглядит необычно",
"<title>403 | домклик</title>",
)
def _is_domclick_success(html: str) -> bool:
"""True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER)."""
return _DOMCLICK_SUCCESS_MARKER in html
def _is_domclick_refusal(html: str) -> bool:
"""True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS)."""
lower = html.lower()
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
@ -1261,8 +1320,14 @@ async def _content_during_navigation(page: object) -> str | None:
raise
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
async def _wait_out_pow_challenge(
page: object,
provider: str,
url: str,
*,
is_pending: Callable[[str], bool] = _is_pow_challenge,
) -> str:
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
решения PoW URL не меняется, поэтому page.wait_for_url тут не годится;
@ -1270,18 +1335,24 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача
challenge-страницы как будто это валидный контент.
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
провайдеров не меняется) предикат «страница ещё не устоялась, опроси
снова». DomClick передаёт свой (см. _fetch_once) там нет отдельного
маркера самого челленджа, есть только «точно успех» / «точно отказ»
(#3196), поэтому pending = «ни то, ни другое».
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
каким ждём обычную навигацию (второй таймаут не изобретаем).
"""
poll_interval_ms = 1000
elapsed_ms = 0
html: str | None = await _content_during_navigation(page)
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
elapsed_ms += poll_interval_ms
html = await _content_during_navigation(page)
if html is None or _is_pow_challenge(html):
if html is None or is_pending(html):
raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
@ -1432,9 +1503,37 @@ async def _fetch_once(
raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
)
if provider == "domclick":
# DomClick — своя ветка (#3196): нет отдельного маркера самого
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
# статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401
# приходит и у здорового рукопожатия, и у отказа, и у успешной
# страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу.
if _is_domclick_refusal(html):
raise BanPageDetectedError(
f"tradein-browser[{provider}]: статический отказ площадки "
f"url={url!r}"
)
if not _is_domclick_success(html):
html = await _wait_out_pow_challenge(
page,
provider,
url,
is_pending=lambda h: not _is_domclick_success(h)
and not _is_domclick_refusal(h),
)
if _is_domclick_refusal(html):
# Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса —
# is_pending вернул False (страница устоялась), но устоялась
# она в состояние «отказ», а не «успех». Наверх идёт то же
# исключение, что и для мгновенного отказа выше.
raise BanPageDetectedError(
f"tradein-browser[{provider}]: отказ площадки после "
f"ожидания рукопожатия url={url!r}"
)
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
if _is_pow_challenge(html):
elif _is_pow_challenge(html):
status = _last_response_status.get(provider)
if status in _REFUSAL_STATUSES:
# Маркеры челленджа при 403/429 — это отказ, а не проверка:
@ -1443,7 +1542,9 @@ async def _fetch_once(
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
# BanPageDetectedError как блок.
# BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
# выше по телу, а не по коду, см. ветку provider == "domclick".
raise BanPageDetectedError(
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
f"— отказ площадки, ждать нечего url={url!r}"

View file

@ -0,0 +1,284 @@
"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196).
Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от
Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного
стабильного маркера вообще ни `__qrator`, ни `startpow`, ни текста отказа.
`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не
матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась
недосчитанная страница уезжала наверх как валидный контент, парсер не находил
`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный
`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`).
Логика для DomClick инвертирована: опознаём положительно ДВА крайних
состояния «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль
`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая
страница «403 | Домклик» ~26.6 КБ) а всё остальное (загрузчик рукопожатия
279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё
идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным
`is_pending`.
Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py),
camoufox не запускается фейковые browser/page возвращают заранее заданную
последовательность content(). wait_for_timeout на фейковой page no-op.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_domclick_challenge.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_retry_tasks", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг
# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие
# опознаваемых строк.
_HANDSHAKE_LOADER_HTML = (
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
"<body></body></html>"
)
_RENDERED_POW_HTML = (
"<html><body><script>window.location = location.href;</script>"
"<div>ожидание...</div></body></html>"
)
_REFUSAL_HTML = (
"<html><head><title>403 | Домклик</title></head>"
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
)
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></html>'
class _DomclickPage:
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
После исчерпания списка повторяет последний элемент (имитирует «страница
осталась в этом состоянии»). Зеркалит _ChallengePage из
test_server_pow_challenge.py.
"""
def __init__(self, html_sequence: list[str]) -> None:
self._html_sequence = html_sequence
self._call_count = 0
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._call_count, len(self._html_sequence) - 1)
item = self._html_sequence[idx]
self._call_count += 1
return item
async def close(self) -> None:
self.closed += 1
class _DomclickBrowser:
def __init__(self, page: _DomclickPage) -> None:
self._page = page
async def new_page(self) -> _DomclickPage:
return self._page
def _install(
monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick"
) -> None:
server._browsers[provider] = _DomclickBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
# ── детекторы: чистые функции ────────────────────────────────────────────────────
def test_is_domclick_success_true_on_ssr_state_marker() -> None:
assert server._is_domclick_success(_SUCCESS_HTML) is True
def test_is_domclick_success_false_on_handshake_loader() -> None:
assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False
def test_is_domclick_success_false_on_rendered_pow_page() -> None:
"""Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров
вообще детектор успеха не должен ложно сработать на ней."""
assert server._is_domclick_success(_RENDERED_POW_HTML) is False
def test_is_domclick_success_false_on_refusal_page() -> None:
assert server._is_domclick_success(_REFUSAL_HTML) is False
def test_is_domclick_refusal_true_on_title_and_text_markers() -> None:
assert server._is_domclick_refusal(_REFUSAL_HTML) is True
def test_is_domclick_refusal_false_on_handshake_loader() -> None:
assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False
def test_is_domclick_refusal_false_on_rendered_pow_page() -> None:
assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False
def test_is_domclick_refusal_false_on_success_page() -> None:
assert server._is_domclick_refusal(_SUCCESS_HTML) is False
# ── _fetch_once: сценарии DomClick ───────────────────────────────────────────────
def test_fetch_once_refusal_page_raises_immediately_without_waiting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1)."""
page = _DomclickPage([_REFUSAL_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
# Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп.
# паузы по 1000мс) не было — бюджет ожидания не потрачен.
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_page_without_markers_waits_then_returns_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в
ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2)."""
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
assert page.closed == 1
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился
def test_fetch_once_success_page_returned_immediately_without_waiting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3)."""
page = _DomclickPage([_SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
assert page.closed == 1
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError,
а не отдача отказной страницы как валидного контента."""
page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1"))
assert page.closed == 1
def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен
трактоваться как отказ: только тело страницы решает."""
class _Response:
status = 401
class _StatusPage(_DomclickPage):
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
self.goto_urls.append(url)
return _Response()
page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML])
_install(monkeypatch, page)
html = asyncio.run(
server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")
)
assert html == _SUCCESS_HTML
# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ────────────────────
def test_fetch_once_non_domclick_provider_ignores_domclick_markers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal,
не должен уходить ни в какую специальную ветку DomClick-логика провайдер-
скопирована строго под `provider == "domclick"`."""
page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер
_install(monkeypatch, page, provider="avito")
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]

View file

@ -293,3 +293,62 @@ def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPa
body = _json_body(response)
assert body["html"] == _REAL_HTML
assert body["status"] is None
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
def _fetch_handler_error_body(
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
) -> tuple[int, dict]:
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = upstream_status
raise exc
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
return response.status, _json_body(response)
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""Бан-страница несёт ban_page + апстрим-статус.
Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел
регрессия, которую #3237 внёс, а #3239 чинит.
"""
status, body = _fetch_handler_error_body(
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
)
assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe
assert body["ban_page"] is True
assert body["status"] == 401
assert "BanPageDetectedError" in body["error"]
def test_fetch_handler_transport_error_has_no_ban_page_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
status, body = _fetch_handler_error_body(
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
)
assert status == 500
assert "ban_page" not in body
def test_fetch_handler_ban_page_without_status_stays_null(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
_status, body = _fetch_handler_error_body(
monkeypatch, server.BanPageDetectedError("бан-страница"), None
)
assert body["ban_page"] is True
assert body["status"] is None

View file

@ -73,7 +73,12 @@ class _FakePage:
return None
async def content(self) -> str:
return "<html>ok</html>"
# Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный
# признак "успешной" страницы (см. server._is_domclick_success) — без него
# generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по
# ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы
# ищут другие маркеры), так что для них поведение не меняется.
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
async def close(self) -> None:
self.closed += 1
@ -205,7 +210,7 @@ def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
)
assert html == "<html>ok</html>"
assert html == "<html>ok<script>window.__SSR_STATE__={}</script></html>"
assert browser.new_page_calls == 1
assert browser.contexts_created == []

View file

@ -130,6 +130,51 @@ _PROXY_FAIL_MARKERS: tuple[str, ...] = (
_LEASE_ROTATE_AFTER_FAILS: int = 3
class SidecarBanPageError(httpx.HTTPStatusError):
"""Сайдкар подтвердил бан-страницу по маркерам её тела (#3239).
Подкласс ``HTTPStatusError``, а не самостоятельный тип: ловля по
``httpx.HTTPError`` у всех прочих поставщиков и retry-политика
``fetch()`` продолжают работать не зная о нём. Отличать его нужно ровно
там, где решается судьба узла: за подтверждённым маркером стоит
``report_ban`` (площадка отказала этому IP), за обычной 500-кой нет
(сбой транспорта, #2600 п.4).
``upstream_status`` HTTP-код САМОЙ целевой навигации, а не ответа
сайдкара. На error-пути ``fetch()`` обнуляет ``last_response_status``,
поэтому иначе диагноз узнать неоткуда: у DomClick отказ приходит с 401
и без него классификатор ставит 'unknown' вместо 'platform'.
"""
def __init__(
self,
message: str,
*,
request: httpx.Request,
response: httpx.Response,
upstream_status: int | None,
) -> None:
super().__init__(message, request=request, response=response)
self.upstream_status = upstream_status
def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]:
"""(это бан-страница?, апстрим-статус) из тела ошибки сайдкара (#3239).
Тело ``{"error": ..., "ban_page": true, "status": <int|null>}``. Сайдкар
старой сборки ключей не отдаёт (False, None), поведение как до правки.
``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом.
"""
try:
body = resp.json()
except Exception:
return False, None
if not isinstance(body, dict) or body.get("ban_page") is not True:
return False, None
raw = body.get("status")
return True, raw if isinstance(raw, int) and not isinstance(raw, bool) else None
def _raise_for_sidecar_status(resp: httpx.Response) -> None:
"""`raise_for_status()`, но с ПРИЧИНОЙ отказа из тела ответа сайдкара в тексте ошибки.
@ -153,6 +198,17 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
# Тело не прочиталось/не декодируется — причина не обязана быть; отдаём
# исходную ошибку, а не роняем вызывающего на разборе тела.
raise exc from None
is_ban_page, upstream_status = _sidecar_ban_page_status(resp)
if is_ban_page:
# #3239: тип несёт диагноз наверх — подстрокой в тексте его искать
# нельзя, detail обрезан до 300 символов и формулировка отказа
# менялась дважды за месяц.
raise SidecarBanPageError(
f"{exc} | tradein-browser: {detail or 'ban page'}",
request=exc.request,
response=exc.response,
upstream_status=upstream_status,
) from exc
if not detail:
raise
raise httpx.HTTPStatusError(

View file

@ -61,6 +61,7 @@ from scraper_kit.domclick_exceptions import (
DomClickBlockedError,
DomClickParseError,
)
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text,
@ -544,6 +545,20 @@ async def fetch_detail(
# но НЕ сообщаем пулу здесь: неизвестно, был ли это реальный маркер-бан или
# обёртка сетевой ошибки — не смешиваем "сеть" с "бан" (issue #2600 п.4).
raise
except SidecarBanPageError as exc:
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
# Статус берём из исключения: на error-пути fetch() обнулил
# last_response_status, а без 401 классификатор поставил бы 'unknown'
# вместо 'platform' и ротация IP не запустилась бы вовсе.
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
raise DomClickBlockedError(
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
status=exc.upstream_status,
) from exc
except Exception as exc:
# Сетевая/инфраструктурная ошибка самого fetch() (timeout/5xx/transport) —
# НЕ репортим mark_banned: это не подтверждённый маркер-бан площадки, а

View file

@ -327,10 +327,12 @@ class DomClickScraper(BaseScraper):
комнатности 2+ не собирались никогда. Замер 19.08 по активным
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
площадок 2+ комнат около двух третей выдачи.
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
всем корзинам вместо одной. Работает при единственном свободном узле
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
включённых узла, 2 забанены Домкликом.
Сдвиг НЕ лечит обрыв, он распределяет то, что успевает собраться,
по всем корзинам вместо одной, и работает даже когда свободен один
узел. Ротация lease тут ни при чём: свип кладёт не счётчик провалов,
а break ниже по первому же DomClickBlockedError (#2854). В пуле
четыре включённых узла, у всех provider_affinity='any'
выделенного домклик-прокси нет с миграции 253 (#2800).
skip_buckets: корзины, уже собранные предыдущим оборванным прогоном
(#3118, чекпоинт done_buckets) — по ним ни одного запроса. Вместе
со сдвигом #2854 это превращает случайную ротацию в систематический