При reuse_context=true сайдкар на каждый fetch делал goto(origin), а потом
goto(url) — то есть перед каждой карточкой заново грузил страницу выдачи в той
же единственной вкладке. Смысл origin-прогрева (получить пропуск QRATOR в
контексте) при этом достигался ровно один раз, на первой карточке: дальше
контекст уже прогрет, а повторная навигация — чистая трата рукопожатия.
Ручная проверка 29.08 показала, как ходит человек: вкладка с выдачей открыта
всю сессию, объявления открываются из неё в новых вкладках. 91 карточка подряд,
0 отказов. Здесь то же самое: origin поднимается в отдельной долгоживущей
вкладке (_anchor_pages), карточки идут своими вкладками, выдача не
перезагружается.
Замер на тестовом сайдкаре (узел 11, 12 карточек): якорь поднялся ровно один
раз, 12/12 успех, медиана ~15 с против ~24 с и без роста времени к концу
прогона (раньше последние карточки уходили в 34-52 с).
Откат безопасный: не поднялась якорная вкладка — молча возвращаемся к прежнему
поведению (goto(origin) перед карточкой). Без reuse_context поведение не
меняется вовсе. Сброс контекста роняет и якорь.
#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
Оба места утверждали, что у Домклика один выделенный резидентный прокси и
пула нет. Это перестало быть правдой ещё в #2800 (миграция 253 сняла
резервацию узла), но текст остался — и именно на него опирался тикет #3189,
поставленный под «калибровку» ограничения, которого не существует.
Свип к тому же ходит через пул давно (serp.py:359), а бэкфилл подключён к
нему в PR #3222.
Заодно докстринг называл не тот ограничитель: свип кладёт не счётчик
провалов lease, а break по первому DomClickBlockedError (#2854) — до
ротации дело не доходит ни при каком счётчике, отсюда buckets_completed=0.
Только комментарии, поведение не меняется. Миграция 175 уже применена, а
_schema_migrations трекает по имени файла без checksum — правка текста
её не перезапустит.
Деплой 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 там остаётся
канарейкой.
Домклик отдаёт рукопожатие без единого стабильного маркера (в отличие от
Авито), поэтому _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.
Докстринг обещал: «Отвечаем 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,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
Правки по трём ревью ветки «v3 становится корнем».
FAQ. Из шести вопросов `content.ts` возражения v3 брали два, остальные
четыре (`how-accurate`, `vs-marketplace`, `personal-data`, `bank-report`)
не рендерились нигде: `Faq.tsx`, который выводил все шесть, удалён вместе
с лэндингом v1. Макетные возражения их тематически закрывали, но
пересказом — а оригиналы проходили юр-ревью. Содержательно пропали два
утверждения: «Имя, паспорт и документы на квартиру мы не спрашиваем» с
отсылкой к странице ПДн (152-ФЗ) и «показываем диапазон, а не одно число»
как прямой ответ про точность (рамка диапазона осталась только в
`AccuracyV3`, рядом с плейсхолдерными числами).
Теперь в секции один порядок чтения: все шесть вопросов из `FAQ` по id
плюс три макетных возражения, которым в `FAQ` пары нет («факт сделки»,
«почему так дёшево», «подписка»). Три макетных пересказа удалены в пользу
оригиналов; у «почему так дёшево» убран второй абзац — почти дословный
повтор второго абзаца `how-accurate`, который теперь стоит следующим.
Новых формулировок не добавлено. Отсылка к странице ПДн стала живой
ссылкой через `PublicLink` (обычный `<a>`, basePath не течёт); текст
ссылки — слова самого `content.ts`, найденные по вхождению.
Гейт стал двусторонним: тест перебирает `FAQ` и требует, чтобы КАЖДЫЙ id
был отрисован. Прежняя версия перечисляла два id руками и потому молчала
ровно в тот момент, когда четыре ответа исчезли со страницы. Проверено
мутацией: снятие вопроса, переименование id и переформулировка фразы про
ПДн красят тест по отдельности.
CADDY. `/trade-in/mera-public/v3` выбыл из @meraLongPages вместе со
страницей и никуда не попал — длинный адрес превью падал в catch-all 404,
хотя раньше вёл на страницу. Обе его формы добавлены в @meraV3Gone. Там же
редирект перестал терять query: было `redir * /`, стало `redir * {uri}`
со срезанием пути — как у @meraLongPages, где перенос UTM и обоснован.
Проверено на живом Caddy 2.11 по настоящему site-блоку: все четыре формы
дают 301 на `/` с сохранённой query, без параметров — чистый `/` без
хвоста `?`, соседние матчеры не задеты, лишний сегмент по-прежнему 404.
КОММЕНТАРИИ, ОПИСЫВАЮЩИЕ НЕСУЩЕСТВУЮЩЕЕ. Строка `page.tsx` в гейте
плейсхолдеров убрана: комментарий над ней утверждал, что устаревший путь
«перестал бы что-либо сканировать», но ни в корневом `page.tsx`, ни в
прежнем `v3/page.tsx` нет ни одного имени из `PLACEHOLDER_EXPORTS` —
страница только собирает секции. Замер подтверждает: множество найденных
плейсхолдеров со строкой и без неё совпадает, а сама по себе она не даёт
ничего. Держать её значило держать ложное ощущение охвата.
Дескриптор «Оценка вторичного жилья по рыночным данным · <регион>» на
внутренние страницы не возвращается — причина записана в докстринге
`InnerHeader`: эту шапку носят и `/estimate`, `/articles`, `/docs`, где
дескриптора не было никогда; требование юриста было про первый экран
лэндинга и там выполняется `HeroV3`; на самих юр-страницах то же сказано
сильнее — в тексте оферты (п. 1.3, 6.2) и в `FooterV3` на каждой странице.
278 делает ALTER TABLE trade_in_estimates ADD COLUMN + CREATE UNIQUE INDEX на
ЖИВОЙ таблице (1123 строки на проде). ALTER берёт ACCESS EXCLUSIVE: без
lock_timeout он встал бы в очередь за запросами приложения и утащил их за собой.
Обёрнуто в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql.
Весь анти-абузный контур публичной ручки (анонимная квота cookie+IP, семафор,
503 вместо 502, consent-гейт) держится на одном аргументе: в
app.api.v1.trade_in.estimate уходит x_authenticated_user=None. Проверял это
ноль тестов: estimate везде замокан AsyncMock, который принимает любую
сигнатуру, — подмена None на чтение заголовка запроса оставляла все 14 тестов
зелёными, а публичная форма начинала считать от чужого имени мимо квоты.
Новый тест шлёт запрос С заголовком X-Authenticated-User: admin и сверяет
фактические await_args.kwargs; заодно требует, чтобы аргументы ехали по имени
(позиционный вызов обесценивает сверку) и чтобы аргумент вообще присутствовал
(дефолт эстиматора — чужая гарантия, не наша). Проверено падением: подмена на
request.headers.get даёт «пришло: 'admin'».
Там же UPDATE токена: параметры сверялись только по хэшу, id строки — нет.
Теперь пинится result.estimate_id: токен обязан вешаться на только что
посчитанную оценку. Проверено подменой параметра — красный по значению.
test_read_filters_by_expiry_and_hash оставлен текстовым: живого Postgres с
миграцией 278 здесь нет, а поведенческий тест, ни разу не прогнанный, — это
ещё один зелёный по построению. Вместо этого в самом тесте написано, что он
проверяет (предикат есть в тексте SQL, в параметрах хэш) и чего НЕ проверяет
(сессия — MagicMock, запрос не исполняется, протухший токен не отсекается), и
чем его заменить, когда БД появится.
Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт
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): включение
открывает запись ПДн и требует решения владельца вместе с правкой политики.
Обе миграции создавали индекс без транзакции и без SET LOCAL lock_timeout.
Гейт scripts/check-migration-lock-timeout.py это и поймал:
::error 276_landing_showcase_deals.sql:: блокирующий DDL без lock_timeout
(CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at ...)
Обе обёрнуты в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql. Локально гейт зелёный: «проверено новых миграций: 31».
Ревью 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/v3 переехало в корень
целиком, вместе с блоками, перенесёнными из v1 предыдущим шагом. Каталог
v3/ и компоненты v1 (Hero / HowItWorks / WhatYouGet / DataSources / Faq /
AddressForm / SiteHeader / SiteFooter) удалены: их данные живут в
content.ts и уже читаются секциями v3, поэтому ушла только вёрстка.
Юридические страницы (оферта, возврат, ПДн) держались на SiteHeader/
SiteFooter — единственные, кто их ещё импортировал. Переведены на
InnerHeader + FooterV3, тем же способом, что /estimate, /articles и /docs.
ПЕРИМЕТР. /v3 убран из @meraPages, @meraShortSlash и @meraLongPages, но
не выброшен: адрес две недели был открыт наружу и разошёлся по закладкам,
поэтому вместо тишины белого списка — 301 на корень (@meraV3Gone, обе
формы). На это заведена проверка в public-perimeter.test.ts по образцу
соседних; PREVIEW_ONLY стал пустым, а не удалённым — он держит проверку
«наружу не открыто ничего сверх известных страниц» двусторонней.
Из PUBLIC_SHORT_PATHS гварда /v3 убран: редирект отрабатывает в Caddy, до
приложения запрос не доходит, а недостижимая строка в списке публичных
путей сделала бы публичной будущую страницу с тем же адресом. Гейт
Caddy→гвард односторонний и сам бы этого не поймал — обоснование в коде.
NOINDEX ОСТАВЛЕН. Он держится за плейсхолдерные числа marketing-v3.ts
(#2904), а не за то, какой вариант дизайна стоит в корне. Путь в гейте
обновлён на page.tsx: placeholdersInUse() молча пропускает ненайденный
файл, поэтому устаревшая строка не покраснела бы, а перестала бы
сканировать.
foreign table tradein.gendesign_ekb_districts_geom падала с «permission denied for
view ekb_districts_geom». Замер: has_table_privilege('tradein_fdw_reader',
'public.ekb_districts_geom','SELECT') = false, при том что все четыре соседних
объекта того же FDW-сервера (rosreestr_deals, mv_quarter_price_index,
v_tradein_cad_buildings, v_tradein_osm_poi_ekb) грант имеют.
Грант не «потерялся» — его не выдавали никогда: 74_dedupe_unified_views.sql
меняет тип объекта (DROP TABLE + CREATE OR REPLACE VIEW), то есть создаёт его
заново, а строки GRANT рядом не было. Тот же класс, что #2583.
Приёмка на проде после деплоя: тот же has_table_privilege обязан вернуть true,
а SELECT count(*) FROM gendesign_ekb_districts_geom — 8 вместо ошибки.
Корень meraocenka.ru отдаёт v1, новый дизайн живёт на /v3 и скоро станет
корнем. Шесть вещей из v1 в v3 отсутствовали — при переключении они бы
просто исчезли с публичного сайта:
- реестра источников не было вовсе (SourcesV3, из SOURCE_GROUPS);
- «что вы получите» и дисклеймера 135-ФЗ не было (WhatYouGetV3): о том,
что это не отчёт аккредитованного оценщика, узнавал только тот, кто сам
догадался открыть нужное возражение;
- герой не называл границу покрытия (полное — Екатеринбург, частично —
остальные города области, по другим регионам не считаем) и не говорил,
что мы не покупаем квартиры и не берём их на продажу;
- два вопроса FAQ («откуда вы знаете, сколько стоит именно моя квартира»,
«почему только Свердловская область») в возражениях v3 не звучали —
теперь берутся из FAQ по id, а не копией строк;
- карточка проверки не спрашивала город: гео-гейт #2576 на новом лэндинге
был сломан (без города «Ленина, 1» уходит в одноимённую улицу ЕКБ);
- она же спрашивала состояние обязательным полем и выбрасывала ответ на
переходе — теперь он доезжает в черновике.
Тексты перенесены дословно (проходили юр-ревью), новых чисел нет.
Новые секции встали после «Точности»: сверяем прогноз с фактом → на каких
данных → что получаете на руки, всё до первого разговора про деньги.
Гейт: __tests__/v3-ported-copy.test.ts — проверен мутацией (снятие секции
со страницы и переименование id вопроса красят его).
Числа на публичном лэндинге лежали литералами во фронте
(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.
Последняя часть макета Макса «B2C модуль для МЕРА»: /articles, /articles/kak-ocenit-kvartiru,
/docs. Проводка ссылок доведена до конца — «Статьи» и «Проверьте себя» в шапке и подвале
перестали быть заглушками, EstimateHeader переименован в InnerHeader (он теперь стоит на
трёх страницах, а не только на проверке).
ЧИСЛА СТАТЬИ — НЕ ИЗ МАКЕТА. Макет утверждал «по нашим данным, средний разрыв между
первой ценой в объявлении и ценой сделки — 4,1%, а у провисевших дольше трёх месяцев
доходит до 9%». Прод-замер (poincare, 29.08) показал, что величины такого рода у нас нет
вовсе: связки листинга со сделкой в данных не существует (cadastral_number пуст у всех
108 623 сделок и 111 693 объявлений). Заменено на то, что измеряется парно и честно:
по 6202 объявлениям Домклик в ЕКБ (срез 18.07.2026) цену снижали 44%, а среди провисевших
дольше трёх месяцев — 62% в медиане на 4,9%. Границы замера названы в самом тексте:
это движение цены В ОБЪЯВЛЕНИИ, а не скидка на сделке, и объявлений моложе трёх недель
в выборке нет (прогон обогащения их не захватил), поэтому по всему живому рынку доля ниже.
Заодно сняты остальные непроверяемые утверждения: «расхождение с кадастровой до 40%»
(кадастровой стоимости ПОМЕЩЕНИЙ нет ни в одной базе — проверить нечем), поправки §3
помечены как рыночная практика, а не наш замер, пороги фильтра §2 приведены к тем, что
реально стоят в оценщике (радиус 1 км, площадь ±15%, минимум 5 аналогов + каскад
послаблений). Плейсхолдеров marketing-v3 в статье не осталось.
Периметр закрыт во всех трёх местах: @meraPages / @meraShortSlash / @meraLongPages в
apps.caddy, PUBLIC_ROUTES и PUBLIC_SHORT_PATHS в гварде. @meraShortSlash до сих пор не
проверялся ничем — добавлен гейт в public-perimeter.test.ts (фальсифицирован: снятие
/docs из альтернации красит тест).
Гейт #2904 теперь видит и статью (articles-content.ts в V3_SOURCES) — фальсифицировано
отдельно: с одним этим файлом в списке снятие noindex красит гейт.
Честные отличия от макета — в докстрингах страниц: масок реквизитов и макетной редакции
оферты нет (источник правды — /oferta, /privacy, /refund от 13.08), регламент ответа
поддержки «9:00-19:00» снят (никто его не устанавливал), фильтр рубрик и обещание
«новые статьи каждую неделю» не рендерятся.
Проверено: tsc, eslint, vitest 69/69, изоляция mera-public, скриншоты трёх страниц
на 1180/375/320px без горизонтального переполнения.
CI Trade-In / backend-tests упал на tests/tasks/test_domclick_detail_backfill.py:165 —
там тот же хрупкий assert_called_once_with, что уже был поправлен в
tests/test_3118_domclick_warm_context.py: он фиксирует ТОЧНУЮ сигнатуру вызова
BrowserFetcher и ломается на любом новом kwarg.
Лечение то же самое: assert_called_once() + точечная проверка source/endpoint/
reuse_context. Полная проводка пула покрыта отдельным
tests/test_3197_domclick_proxy_pool_wiring.py.
Причина пропуска: локально прогонялась выборка из трёх файлов, а не весь набор.
Теперь прогнан весь: 4974 passed, 37 skipped, 0 failed.
BrowserFetcher(source="domclick", reuse_context=True) конструировался без
proxy_provider/use_pool/environment -- тела POST /fetch не несли "proxy",
сайдкар брал свой env-прокси, и прогон шёл мимо пула целиком: ни выбора узла
по affinity, ни scrape_proxy_source_bans, ни ротации при блоке. Тот же дефект
уже чинили на avito_detail_backfill/house_imv_backfill (#2698) -- этот call
site оставался последним непочиненным. environment обязателен: без него
отказ «пул пуст» на этом пути мёртв (#2616 шаг 1). reuse_context=True
сохранён без изменений.
Заодно поправлен устаревший комментарий над конструктором: ссылался на
scrape_proxies.provider_affinity='domclick' и миграцию 173 -- на проде
такого больше нет (миграция 253 сняла резервацию узла, #2800), все четыре
включённых узла (id 1/9/10/11) имеют provider_affinity='any'.
test_3118_domclick_warm_context.py обновлён под новую сигнатуру вызова
(assert_called_once_with -> точечная проверка нужных kwargs).
Редизайн результата /estimate по макету отчёта: шапка объекта (адрес + чипы
параметров + «Изменить данные»), две большие бесплатные карточки, тизер
платной части с shimmer-строками, честные состояния «мало данных» и «город
не покрыт» в стилистике «ЧЕСТНЫЙ ОТВЕТ».
Сознательные расхождения с макетом (задокументированы в шапке компонента):
- «✓ расчёт готов и ждёт вас» не воспроизводится — платного расчёта для
анонима не существует (анонимный /estimate закрыт, платёжного контура
нет, #2896); тизер честно называется «что будет в полном отчёте», вместо
кнопки оплаты — прежняя формулировка «приём оплаты подключается»;
- бейджи МИР/СБП/VISA — за тем же TODO платёжного контура, что и в FooterV3;
- email-подписка «сообщим когда добавим» — нет хранения и согласия (#2895),
вместо неё живой канал поддержки;
- «Расширить радиус до 3 км» — /coverage радиус не параметризует;
- подписи бесплатных плиток — запиненные честные из coverage-copy.
Возврат 150 ₽ — строкой из макета со ссылкой на /refund. Старый
CoverageResult удалён (один рендер результата, не два). Фикстурное превью
всех трёх тонов — /mera-public/ui-preview/report.
Проверено: tsc чисто, lint чисто, vitest 27 passed, скрин трёх состояний.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>