Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.
- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
`sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.
Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.
1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
«расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
`publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
`sourceLabel()` с реальными именами живёт для админки. Backend: якорь
confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.
Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.
Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.
Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.
Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.
Closes#3332
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:
* YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
потерянный/выданный раздел в проде;
* YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
/trade-in/**), deny-списки совпадают.
Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:
* вверх: менеджер заводил сотрудника с именем, которое уже числится в
roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
(admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
* вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
СОБСТВЕННУЮ оценку.
Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.
Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
* rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
иначе employee/manager на legacy-пути получил бы 403 на всё;
* get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
* право на персональный `unlimited` осталось за roles.yaml (account_quota +
_batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
* `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
«Команды» снова стал бы N+1.
Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.
Замер прода 01.09.2026 из контейнера бота: канал до api.telegram.org рвётся
всплесками, доля отказов на попытку 15-38% (пять проб: 3/8, 15/40, 5/20, 3/20,
1/25), в логе long-polling'а 353 ConnectTimeout за сутки. Транспорт ни при чём —
httpx и сырой сокет отваливаются одинаково (25% против 35% в чередующемся
замере), и прокси не помогает, а мешает: через SCRAPER_PROXY_URL 0 из 20.
Ручка веб-поддержки ходила с max_retries=1, то есть двумя попытками. При 30%
отказов на попытку до пользователя доходило ~9% отказов — каждое одиннадцатое
сообщение возвращало 502 «сервис недоступен».
Два других числа из того же замера задают конструкцию. Успешный запрос отвечает
за 0.13с (максимум из 25 проб — 0.18с), а неудачный НИКОГДА не отваливается
быстро: все отказы упираются в таймаут целиком (10.02с при timeout=10.0). Значит
десятисекундный таймаут не покупал ничего, кроме цены за неудачу, — снижен до 5с,
это ~28-кратный запас к измеренному максимуму. И экспоненциальная пауза 2→4→8с
здесь бессмысленна: отказ — неустановленное соединение, а не троттлинг, пережидать
нечего; она лишь добавляла 14с к ожиданию.
Правка: бюджет ручки — 3 повтора, таймаут 5с, потолок паузы 1с. Худший случай
4 попытки × 5с + 3 паузы × 1с = 23с и требует четырёх отказов подряд; типичный
случай не меняется (0.13с). Расчётная потеря падает с ~9% до ~0.8%.
В TelegramClient добавлен необязательный max_backoff. Воркерная политика НЕ
меняется: без явного потолка откат прежний экспоненциальный до 30с, а retry_after
из 429 уважается целиком — эту границу держит отдельный тест, потому что первая
версия правки её сломала (капала 60с до 30с и для воркера тоже). Потолок на
retry_after применяется только когда его передали явно: интерактивному пути
нельзя ждать Telegram-овские 30-60с, за ним стоит открытый запрос от браузера.
Тесты: 8 новых (потолок на network/429/5xx, неизменность воркерного пути,
арифметика «max_retries=N → N+1 попыток», границы бюджета ручки).
compute_next_run_at держит суточную гранулярность (interval_days, минимум 1),
подчасовой такт делается хуком reschedule_after_minutes как post_claim. У
avito_detail_backfill он есть (180 мин), у yandex_detail_backfill и
cian_detail_backfill не было — отсюда один прогон в сутки.
Цена простоя по замеру прода 31.08:
Яндекс: 375-450 карточек за прогон, блоков НОЛЬ за неделю, очередь 11110
→ 25 суток при нынешнем такте
Циан: блоков ноль, очередь 20501
Такты разные, и это не произвол:
yandex — 180 мин (8 прогонов/сутки). Ходит через resolve_proxy_url: берёт
URL узла, но НЕ лизует его, поэтому чужие прогоны не блокирует.
cian — 360 мин (4 прогона/сутки). Ходит через BrowserFetcher и ДЕРЖИТ
lease весь прогон, то есть отнимает узел у Авито и Домклика. Пул
дефицитен (#2638), поэтому осторожнее.
Асимметрия зафиксирована комментарием у обоих хендлеров и в докстринге
миграции — иначе следующий читатель выровняет интервалы и сожжёт пул. Тест
test_cian_default_interval_is_360_minutes_not_180 ассертит именно неравенство,
чтобы выравнивание без замера покраснело.
283_scrape_schedules_cadence_yandex_cian_detail.sql — идемпотентный
UPDATE ... SET default_params = default_params || jsonb, остальные ключи
параметров не трогает.
Значения 180/360 — консервативная отправная точка по аналогии с Авито, а не
найденный оптимум: двигать вниз только по замеру нескольких суток, глядя и на
свипы тоже (тот же довод, что в комментарии у avito_detail_backfill).
Тесты (5): наличие post_claim у обоих, дефолтные интервалы, переопределение
через params. Прогон: 397 passed, 1 skipped, ruff чист.
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.
Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.
Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.
Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Правка #3286 разделила отказы площадки и сбои нашей стороны по природе
исключения: httpx-ошибка в цепочке причин = транспорт. Прод показал дыру
на первом же прогоне (5406):
СБОЙ #1/400 (подряд=1/10, http=401, kind=platform, не блок площадки):
DomClick detail: sidecar detected platform refusal
Заголовок «не блок площадки» стоит над записью, где kind=platform и код 401,
то есть над самым что ни на есть отказом площадки. Причина в иерархии:
SidecarBanPageError объявлен как подкласс httpx.HTTPStatusError, поэтому
проверка на httpx, стоявшая первой, забирала генуинный бан себе.
Порядок проверок перевёрнут: бан сайдкара отсекается ДО общей httpx-ветки.
Остальное поведение #3286 не тронуто — обычная 500 от сайдкара по-прежнему
транспортный сбой.
Тесты: +4, включая закрепление самой предпосылки (issubclass проверяется
явно, чтобы смена иерархии не отключила проверку молча) и страховку, что
обычная 500 осталась сбоем. Проверено мутацией: снятие порядка роняет тест
и печатает самопротиворечивый лог «10 сбоев подряд без единого отказа
площадки, диагнозы: {'platform': 10}». Набор целиком — 5210 passed.
Прогон 5399 умер по правилу «3 блока подряд», не обогатив ни одной карточки
из 400. Отказом Домклика не был ни один из трёх: два — HTTP 500 от сайдкара
(таймаут навигации), третий — пустой пул, при котором запрос к площадке не
отправлялся вовсе. Лог абортa сам это печатал: диагнозы {'unknown': 3}.
Различать по HTTP-статусу нельзя: настоящий QRATOR-челлендж приходит без
статуса и неотличим от таймаута — на этом и стояли прежние тесты. Различима
природа исключения, причём разделение уже проведено в fetch_detail: генуинный
бан приезжает через SidecarBanPageError или маркеры в parse_detail_html, а
транспорт — через с , то есть с
httpx-ошибкой в цепочке причин.
Три правки:
* пустой пул (NoProxyAvailableError в цепочке) — не блок, а остановка прогона:
продолжать бессмысленно, каждая следующая карточка упрётся в то же. Прогон
уходит в failed, а не banned — запись не должна утверждать про площадку то,
чего не было;
* транспортные сбои идут в failed, туда же, куда давно идёт DomClickParseError
(«schema drift, not a block»), и счётчик блоков не трогают;
* отдельный сторож на молчаливый отказ: max_consecutive_soft_failures=10, иначе
снятие хайртриггера оставило бы прогон без тормоза вовсе.
Тесты: 10 проверок, включая дословный сценарий 5399 и страховку «генуинные
блоки по-прежнему рвут прогон после трёх». Проверено мутациями: возврат пустого
пула в блоки роняет 2, возврат транспорта — 6, снятие сторожа — 2. Четыре
существовавших теста на генуинный блок продолжают проходить без правки — это и
показывает, что разделение проведено по верному признаку. Набор целиком —
5206 passed, 37 skipped.
Карточки Циана доставались побочным эффектом cian_history_backfill, а её
выборка ключуется по offer_price_history. Следствия на 30.08: карточка есть
у 4494 из 25222 объявлений (17.8% — последнее место при втором месте по
объёму), 19046 без истории при квоте 100/сутки (190 дней на остаток, тогда
как очередь растёт вдвадцатеро быстрее), и 1697 объявлений с историей и без
карточки, которые исторической выборке недостижимы в принципе.
Фетчер при этом исправен: прогоны 5154/5240/5328 дали 100/100, 99/100,
100/100. Чинить нечего — не выдана мощность.
Добавлен второй режим выборки (listings_pending="detail", по
detail_enriched_at, свежие первыми) и второе расписание поверх ТОГО ЖЕ тела:
машинерия работает, дублировать её новым модулем незачем. Историческая
выборка оставлена побайтово — по ней живёт суточный прогон.
batch_size=400 не на глаз: замеренный темп ~28с на объявление, порог
reap_zombies 6ч по heartbeat, бюджетного сторожа у задачи нет — 400×28с≈3.1ч
проходит, 800 как у Яндекса (≈6.2ч) убивало бы жнецом.
Расписание засеяно enabled=false, как domclick_detail_backfill в миграции
175: это третий круглосуточный добор на общий пул из четырёх узлов, влияние
на соседей надо посмотреть, а не предположить.
Тесты: 13 проверок, ключ выборки / порядок / неизменность прежнего режима /
проводка параметров через посредника / регистрация обоих source. Проверено
мутациями: снятие ORDER BY, молчаливый дефолт вместо ValueError и потеря
listings_pending в посреднике роняют по 2-3 теста каждая. Набор целиком —
5196 passed, 37 skipped.
Прод отдавал «г Екатеринбург, ул Фролова , д. 29, корп.»: реестровый
readable_address (ЕГРН) приходит УЖЕ склеенным вместе с пустыми частями —
маркер без значения печатается, лишний пробел перед запятой остаётся, и все
тиры /suggest (cadastral / geoportal / houses / DaData / Nominatim) пропускали
строку насквозь.
Чиним склейку, а не конкретный случай: `tidy_address()` — один проход по
частям (схлопнуть пробелы, выбросить пустые и маркер-без-номера:
корп./стр./лит./кв./оф./пом. и пр.), склейка обратно. Применяется в
`GeocodeSuggestion.__post_init__` — единственной точке, через которую проходят
все тиры, включая локальные, которые питают и `geocode()`.
Тест на функцию склейки, фальсифицирован снятием фильтра пустых: краснеет по
значению строки (10 из 14), а не исключением.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ключ шифрования кук и сами куки уезжали в GlitchTip: сервисы сессий передают
их bind-параметрами в pgp_sym_encrypt(:cookies_json, :key), а SQLAlchemy при
ошибке печатает ВСЕ параметры в тексте StatementError.
Правка на уровне движка (backend + tradein-mvp: db.py, auth_db.py,
alembic/env.py) кроет все сайты вызова разом, включая четвёртую копию в
scraper-kit и всё будущее.
scheduler_main.py был единственным из трёх sentry_sdk.init без
include_local_variables=False — процесс скрейпера, в кадрах лежат прокси-креды.
НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) и
сырые psycopg-подключения мимо движков — отдельный класс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пройденный QRATOR proof-of-work выбрасывался после каждой карточки: `reuse_context`
во всём репозитории передавал ЕДИНСТВЕННЫЙ вызов — domclick_detail_backfill.py:346.
Значит каждый /fetch Авито шёл через sidecar'овский browser.new_page(), то есть
новый изолированный context с пустой банкой кук. В логе прод-сайдкара это видно
прямо: «PoW-челлендж снят за ~1000мс» печатается на КАЖДОЙ успешной карточке —
челлендж решается заново каждый раз, а не один раз на прогон.
Эффект той же правки у Домклика измерен и записан в domclick_detail_backfill.py:331
— 26 последовательных фетчей сайдкара дали 100% блоков, те же карточки в тёплом
контексте 5/5 за ~2с.
Второй холод — сам заход: providers/avito/detail.py звал fetch(full_url) голым,
без origin и без Referer, тогда как Домклик (#3247) идёт fetch(card_url,
origin=SERP, referer=origin). Существующий для Авито прогрев (warm_up_session)
живёт только в curl-пути и с 22.08 в проде мёртв — AVITO_DETAIL_BACKFILL_USE_CURL
выставлен в false.
Что сделано:
- avito_detail_backfill: reuse_context=True + request_context_reset() РОВНО один
раз за прогон и только на AvitoBlockedError. Не на AvitoSidecarUnavailableError
(подтип AvitoRateLimitedError — отказ нашего тракта, не бан площадки) и не на
AvitoListingGoneError. Зеркалит #3212: сброс на каждый блок сам себя
поддерживает — пропуск живёт в context'е, сброс его выбрасывает, повторная
проверка с того же IP снова блокируется, одна осечка даёт каскад.
- fetch_detail: необязательные origin/browser_referer (имя referer уже занято под
Referer curl-пути, это разные фетчеры и разные поля). Дефолт None → payload и
поведение city_sweep/pipeline/admin не меняются.
- _serp_origin_for: городская SERP из URL карточки, хост берётся из самого url.
Попутно — дефект якорной вкладки сайдкара, найденный при переносе. _ensure_anchor_page
отдавала True на ЛЮБУЮ живую вкладку, не сверяя её с запрошенным origin. А origin у
обоих caller'ов выводится ИЗ URL карточки и меняется вместе с городом (Авито —
сегмент пути, Домклик — поддомен). После первой же карточки другого города Referer
называл выдачу, которую этот контекст никогда не открывал: ни куки её, ни тайминга,
площадка видит заявленный переход без единого следа. Ровно то, что #3258 запретил
делать фолбэкам якорного поиска. Добавлен _anchor_origins: origin сменился — вкладка
переоткрывается. Чинит и Домклик тоже.
Заход через поиск Яндекса (BROWSER_ANCHOR_VIA_SEARCH) для Авито НЕ включается —
расширять этот список без отдельного замера запрещает комментарий у самой константы.
Хранилище авторизованных сессий Авито (#3179/#3180) этой правкой не заменяется.
База для сравнения снята ДО выката и записана в #3251: доля блоков от попыток
46.9% / 74.2% / 96.4% / 69.8% / 52.9% / 62.3% по суткам 25-30.08. Сравнивать после
деплоя по доле блоков и обогащению за сутки, а НЕ по статусу прогона: ratio-критерий
обрывает КАЖДЫЙ прогон, и статус banned про площадку ничего не говорит.
Тесты: backend 5150 passed / 37 skipped, сайдкар 211 passed (было 206 + 5 новых на
переезд якоря), ruff чист.
Refs #3251, #3180, #3118, #3212, #3247, #3258
serp.py не передавал куки сессии вообще — слова cookie в файле не было.
Свип приходил к QRATOR с чистым браузером и был обязан решать
proof-of-work с нуля на каждый запрос. Прод, прогон 5330 (30.08
03:49-03:55): 9 запросов к bff-search-web.domclick.ru, все 9 зависли на
челлендже (перезагрузка 1/2, 2/2, отказ), status=failed, 0 лотов. Прокси
при этом ротировался (узел 9 → 10) — узел тут ни при чём.
Добор с тем же сайдкаром и тем же пулом в ту же ночь взял 37 карточек из
37 без единого блока. Разница ровно в куках.
Чинить это стало возможно только сейчас: свип ходит не на
ekaterinburg.domclick.ru, а на отдельный хост bff-search-web.domclick.ru,
и до правки _cookie_domain (PR #3262) куки легли бы на
.bff-search-web.domclick.ru, не совпав с сессией площадки. Теперь оба
хоста схлопываются в общий .domclick.ru.
Снимок приходит параметром снаружи, а не читается внутри kit: kit не
импортирует app.* (strangler-инвариант #2133). Поэтому джоба
domclick_city_sweep переопределена продуктовым Handler'ом — build_registry
это прямо допускает («последнее слово за продуктом»), а БД читает только
app-сторона.
Отсутствие сессии не авария: load_session вернул None → свип идёт как
раньше, без инъекции, факт логируется один раз.
Цена решения — override повторяет вызов kit-джобы целиком и может тихо с
ней разойтись. Добавлен тест, который зовёт оба джоба одинаково и
сравнивает наборы kwargs, допуская расхождение ровно в cookies. Проверен
мутацией: с искусственно добавленным в kit-версию аргументом краснеет,
без него зелёный.
Тесты: 245 passed, 1 skipped (domclick + parity).
Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с
{"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд
дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном
месте, обе воспроизведены локально до правки.
1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт
inf переполнением. Pydantic отбивает такое поле по границам и кладёт
значение в input ошибки, а ответ об ошибке сериализуется
json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не
поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и
обработчик один (app/core/http_errors.py), а не валидатор на lon.
2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI
валидирует тело позже зависимостей, но раньше тела — до проверки просто не
доходило. Та же поправка места, что уже сделана сегодня у
_require_public_estimate_enabled: перенос в dependencies. Сделано для всех
ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся
первой зависимостью — 429 на выключенной ручке подтверждал бы её
существование.
Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера
в тело роняет проверку бюджета (проверено).
Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub
лежит в той же строке и не использовалась. price_per_m2 в базе integer,
поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо
4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.).
Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как
есть; err_pct считается от той же величины. Строка без price_rub НЕ
показывается — подставлять реконструкцию в одну строку из двадцати значило бы
спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок
выборки витрины).
Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция
занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex
(creationDate) и avito (дата карточки выдачи) — это дата публикации у
источника. Там, где заполнены и listing_date, и publish_date, они совпадают:
yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у
аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк
(yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой
17 дней и 87% avito с медианой 19).
Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из
3061 активной строки), метрика считалась по 83.6% активных объявлений, и
подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2%
(29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Коммит 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, порядок совпадает).
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у 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.
Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки
витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит.
«0-к» читается как ошибка выгрузки, а не как тип квартиры.
Студией её называет и наш собственный бэктест (per_rooms.label в
scripts/backtest_estimator.py), и рынок.
Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по
значению, обратная правка — снова зелено.
Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты,
площадь и квартал в `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>
Докстринг обещал: «Отвечаем 404, а не 403: выключенная ручка не должна
подтверждать, что она существует». Замер на проде 29.08.2026 (флаг выключен):
POST /api/public/mera/estimate {} → 422 + address/area_m2/rooms/consent
POST /api/public/mera/estimate {валидное} → 404
POST /api/public/mera/nosuchthing → 401 (rbac)
422 отличается и от 404, и от 401 — то есть подтверждает, что ручка есть, и
заодно выдаёт её схему.
Причина не в логике гейта, а в его МЕСТЕ: проверка стояла первой строкой тела
хендлера, а FastAPI валидирует тело раньше, чем доходит до кода. Гейт перенесён
в dependencies=[Depends(...)] обеих ручек — зависимости решаются до разбора тела,
и выключенная ручка неотличима от отсутствующей при любом входе.
Тест двусторонний и фальсифицирован: возврат вызова в тело красит три теста
(включая уже существовавший про 404), обратная правка — снова зелено.
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.
Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.
Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том,
что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два
параллельных запроса, два INSERT, два Init и два холда на карте покупателя.
- миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам
+ ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт
ссылку соперника, если та уже готова, иначе 409;
- граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут
переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему
может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую
PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные
статусы — там деньги уже в игре, разгребать их — работа реконсиляции;
- IDOR: checkout читал оценку без _assert_estimate_access. По чужому
estimate_id возвращался order_id чужого живого платежа, а order_id — право
доступа для /payments/status/<order_id>, отдающего capability-ссылку на
отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
идентичности нет, правом там работает сам estimate_id.
Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.
Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.
Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.
Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт
RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access
отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил
ровно в теле POST-ответа и не переживал перезагрузку страницы.
POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии
логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только
бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe.
Цены, прогнозы и списки аналогов остаются в БД для платного контура.
Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ
происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД.
POST /api/public/mera/estimate/read читает бесплатную часть по токену
(secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278).
Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути
легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан
/suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS.
Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение
открывает запись ПДн и требует решения владельца вместе с правкой политики.
Ревью MAJOR по честности, два пункта.
1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.
Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».
2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.
Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts.
Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные
через тот же спайн оценщика, что и боевой расчёт (backtest_estimator).
Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на
величину ошибки: отбор по малой ошибке дал бы формально работающий код и
врущую витрину — показанные строки перестали бы быть выборкой из работы
оценщика. Свойство закреплено двусторонним тестом.
Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает
дня сделки (deal_date — первое число квартала). Каждая строка несёт note о
том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по
|отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но
дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей
истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер
вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались
зелёными: текстовые пины держали только span_days и max_abs_pct.
Новый пин держит обе половины: однострочные попадают в moved веткой CASE со
значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в
WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён
намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест
по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не
гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и
называет причину.
Второе: метрика, у которой пропал вход, больше не доживает в таблице со
старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне
сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка
не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти
одновременных «данных больше нет». Оба поведения покрыты тестами, оба
проверены на сломанном коде.
proxy_rotate_attempts / proxy_rotate_attempt_timeout_s тюнили ретраи changeip-GET.
Сам changeip снят в #2616 шаг 2 (аккаунт mobileproxy закрыт, ссылки нет), и с тех
пор ручки живут пламбингом: Settings -> property адаптера -> поле протокола
ScraperConfig -> и всё. Ни одного потребителя, только четыре теста, которые
заполняют их при сборке конфига.
Комментарий над ними в contracts.py оправдывал их сохранение так:
«оставлены как budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for»
— но wait_for там берёт СОСЕДНЕЕ поле, avito_proxy_rotate_settle_s
(avito_detail_backfill.py:249). То есть комментарий приписывал этим двум полям
работу третьего и тем самым прикрывал их мёртвость.
Соседние ручки проверены и ОСТАВЛЕНЫ, они действительно читаются:
* avito/cian/yandex_proxy_max_rotations — pipeline._max_rotations;
* avito_proxy_rotate_settle_s — asyncio.wait_for в avito_detail_backfill.
Заодно сжат комментарий в config.py: перечисление истории changeip заменено на
то, что нужно знать сейчас — кто читает оставшиеся две ручки и где живая ротация
(ASOCKS_API_TOKEN / proxy_rotation, #2611).
ruff clean, 4974 passed / 37 skipped.
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.
Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.
Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.
Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.
«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
Найдено сверкой двух независимых источников: подсчёт упоминаний по всем
python-файлам (включая тесты) дал имена, встречающиеся ровно один раз — в
собственном объявлении; каждое затем подтверждено serena find_referencing_symbols
(LSP, видит и косвенные ссылки) и проверено grep'ом по yml/sql/ts/md на случай
ссылки строкой.
Удалено:
* _qr_code_data_url (trade_in_pdf) — QR как SVG data URL, не вызывался
ниоткуда; вместе с ним ушёл осиротевший import segno. `io` ОСТАВЛЕН —
io.BytesIO используется дальше по файлу (строка 774);
* _local_name (gar_flats_loader) — снятие namespace с имени тега;
* AvitoParseError — класс никто не поднимает и не ловит;
* IMVCityMismatchError — то же.
Что НЕ тронуто, хотя фильтр их показал:
* ~200 обработчиков FastAPI и celery-задач — их поднимает декоратор, по имени
их действительно никто не зовёт;
* refresh_ddu_price_indicator — точка ручного обслуживания, задокументирована
в комментарии к матвью (data/sql/152_mv_ddu_price_indicator.sql: «Refresh:
... (не в beat)»);
* schemas/parcel.py::MarketPrice — половина контракта, фронт использует
(frontend/src/types/site-finder.ts: market_price?: MarketPrice);
* JobSetting — SQLAlchemy-модель, живёт через metadata Base.
segno остался в backend/pyproject.toml и больше нигде не используется — снятие
зависимости требует пересборки лока, поэтому отдельным PR.
ruff clean, 4974 passed / 37 skipped.