Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
811b9ecc8f Планировщик кита передаёт регион из строки расписания в обход (#3515)
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 1m58s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
2026-09-13 11:45:55 +00:00
d33c97521d Merge pull request 'Две статьи на собственном замере вместо карточек «СКОРО»' (#3521) from feat/mera-articles-prices-waiting 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 Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Has been skipped
Deploy / deploy-caddy (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) Has been cancelled
Deploy / build-worker (push) Successful in 42s
Deploy / build-frontend (push) Successful in 43s
Deploy / build-backend (push) Successful in 45s
Deploy / deploy (push) Successful in 1m10s
Deploy / deploy-status (push) Successful in 3s
Deploy / perimeter-smoke (push) Successful in 1m46s
2026-09-13 11:45:33 +00:00
bot-backend
89d095f486 feat(mera): две статьи на собственном замере вместо карточек «СКОРО»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m27s
Раздел /articles с 29.08 стоял с одной живой статьёй и двумя карточками без
ссылок. Обе дописаны — но не по темам из макета: те темы данными не
обеспечены.

«Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
24 535 договоров Росреестра за июль 2025 — июнь 2026, регион 66. Медиана
123 626 ₽/м². Несущий факт статьи контринтуитивен и проверен: кривая цены
метра по комнатности не падает, а прогибается — дешевле всего метр в
двухкомнатной (116 910 ₽), дороже всего в студии (134 507 ₽). Плюс
поквартальная динамика за все десять доступных кварталов (+20,6%) и восемь
городов области с порогом публикации в сто сделок.

«Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
49 620 объявлений области с историей цены площадок. Цена двигалась у 43,5%,
из них вниз у 60,2%, вверх у 38,0%. Медиана снижения −3,85% и −291 000 ₽.

ПОЧЕМУ НЕ ТЕ ТЕМЫ, ЧТО В МАКЕТЕ. Карточка «Сколько продаётся квартира»
требовала срока экспозиции, а он не измеряется: `days_on_market` пуст у всех
435 тысяч сделок Росреестра, а снятие объявления и продажа в наших данных
неразличимы — 41% «снятий» приходится на шесть дат, совпадающих с провалами
собственного обхода. Мерили бы мы свой сборщик, а не рынок. Вторая карточка
называлась «Завысить и торговаться»; запрос «завысить цену на квартиру»
уведён юридическим смыслом (завышение суммы в ДКП ради ипотеки), и статья
попала бы в чужую выдачу.

ДИСЦИПЛИНА ЧИСЕЛ. Замер прошёл адверсариальную проверку: из десяти
первоначальных величин воспроизвелись две, остальные отброшены как
непригодные к печати. Обе статьи пересчитаны заново с нуля. У каждого блока
стоит своё поле source/footnote с датой, выборкой и границей — чем это число
НЕ является. Неизмеримое названо неизмеримым прямо в тексте, а не обойдено
молчанием: первый раздел второй статьи объясняет читателю, почему средний
срок продажи не назовёт никто честный.

Обвязка: два ключа в PUBLIC_ROUTES, две страницы, sitemap, три матчера в
apps.caddy на каждый адрес, PUBLIC_SHORT_PATHS у RouteGuard,
PUBLIC_INDEXED_FILES в гейте noindex. Блок «Читайте дальше» перестал быть
статичным «СКОРО» и делает карточку ссылкой, как только у неё появился
route. Витрина и лэндинг обновились сами — обе перебирают ARTICLE_CARDS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-13 14:41:10 +03:00
0af80b4395 Коэффициент перехода от цены предложения к цене сделки считается по каждому региону (#3518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 1m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
2026-09-13 11:38:26 +00:00
94d34e7dcb chore(tests): POSIX-only кейсы пропускаются на Windows и зарегистрированы в allowlist (#3520)
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 40s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m11s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Failing after 1m43s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-13 11:17:13 +00:00
76a2963edc Локальный индекс считает медианы по своему региону, а статус точек интереса не врёт (#3517)
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) Has been skipped
Deploy Trade-In / test (push) Successful in 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-13 11:14:44 +00:00
66a8c39ee4 Бэктест оценщика умеет любой регион, а не только Свердловскую область (#3516)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 1m19s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m48s
2026-09-13 11:03:31 +00:00
76bf9f1cf7 chore(caddy): заголовки безопасности для публичного домена Меры, листинг каталога превью выключен (#3519)
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Has been skipped
Deploy / deploy-caddy (push) Failing after 4s
Deploy / perimeter-smoke (push) Has been skipped
Deploy / deploy-status (push) Failing after 2s
2026-09-13 11:03:15 +00:00
ed76ad8d43 fix(tradein): токен DaData не утекает в лог, скраббер больше не ломает access-log (#3514)
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
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 4m6s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-13 10:52:02 +00:00
3218190a89 fix(tradein): стартовая проверка темы реально валидирует message_thread_id (#3513)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 1m58s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m48s
Deploy Trade-In / changes (push) Successful in 14s
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 4m0s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-13 10:41:16 +00:00
8f06373e2f Merge pull request 'fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469)' (#3509) from fix/3469-showcase-schedule into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 3m47s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 2m37s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-12 17:12:30 +00:00
5ee208087d Импорт msk_raw умеет областной ДомКлик, а не падает на нём (#3512)
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m39s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
2026-09-12 17:00:30 +00:00
6ba04d845e Merge pull request 'МЕРА: у запросов к БД появился потолок по времени и по ожиданию блокировки (#3463)' (#3508) from fix/3463-db-statement-timeout into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 3m40s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-12 16:38:34 +00:00
8863781589 Коллектор ДомКлика умеет Московскую область, а не только Москву (#3510)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 59s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-09-12 16:34:32 +00:00
e6e7a8db1c fix(mera): живой тест витрины гоняется на полной схеме, а не только на пустой (#3469)
All checks were successful
CI / changes (pull_request) Successful in 12s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:

  psycopg.errors.UndefinedColumn: column "returning_count" of relation
  "scrape_runs" does not exist

Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.

Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.

Заодно закрыты три дыры, которые находились мутациями:

- окно расписания и повторное применение проверяются на живой БД (до
  этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
  идемпотентность меряется created_at строки, а не числом строк — замена
  «удалить и вставить» тоже оставляет ровно одну строку, но стирает
  last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
  проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
  литерал конструктора Handler, и чужое тело под верным ключом
  (_job_landing_stats) проходило проверку по имени.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:29:45 +05:00
68495907d5 Merge pull request 'fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)' (#3506) from fix/3443-caddy-selfdowntime into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 39s
Deploy / build-backend (push) Successful in 41s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m40s
2026-09-12 16:16:03 +00:00
d5f0557ca8 fix(deploy): сверка маунтов Caddy не может провалиться втихую (#3443, ревью)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m40s
Две дыры из deep-ревью PR #3506 — обе про «отказ выглядит как успех».

M1. `stale=$(docker inspect … | while …)` под `set -eu` без `pipefail`
(в POSIX-sh его нет) отдаёт статус `while`, то есть всегда 0. Провал
`docker inspect` или пустой вывод давали пустой список → ветка «всё
доехало» → `caddy reload` → зелёная джоба с надписью «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу. Ровно тот беззвучный
отказ, ради которого написан скрипт. Теперь список читается отдельной
командой, провал и пустой вывод считаются расхождением (fail-safe в
прежнее поведение), число сверенных файлов печатается — «сверили пять» и
«сверили ноль» в логе больше не выглядят одинаково. Ноль пофайловых
маунтов (например, если Caddyfile переведут на именованный том) — тоже
расхождение, а не тавтологически успешная сверка.

M2. В фильтре `backend` (ci.yml) не было `ops/**`, а все содержательные
регрессии живут в самом ops/caddy-apply.sh: гейт его ИСПОЛНЯЕТ. PR,
правящий только скрипт, давал backend=false — джоба пропускается, гейт не
исполняется, «пересоздавать всегда» уезжает в main зелёным. Тот же класс,
что уже осуждён комментариями рядом (#2950/#3448/#3467).

Мелочи оттуда же:
* `[ -d "$src" ] && continue` вместо `[ -f "$src" ] || continue` — пропуск
  по `-f` склеивал «это каталог» (пропустить верно) и «файла на хосте
  нет», для которого в контейнере как раз живёт старый инод;
* сообщение об отказе `caddy validate` больше не называет причиной
  битый конфиг, когда упасть мог и сам запуск проверочного контейнера;
* в комментарии к проверке записана её граница: в полном деплое общий
  `up -d $UP_SERVICES` (deploy.yml:959) поднимает и caddy за ~110 строк
  до вызова скрипта, поэтому правка, которая одновременно ломает Caddyfile
  и меняет блок caddy в compose, пересоздаст контейнер раньше проверки.

Гейт дорос с 16 до 22 проверок: `docker inspect` не ответил → пересоздание,
ноль пофайловых маунтов → пересоздание, исчезнувший файл на хосте →
пересоздание, число сверенных маунтов печатается, `caddy reload`/вызов
скрипта не проглочены `|| true`, ci.yml-фильтр покрывает ops/**.
Все 11 мутантов (7 новых + 4 прежних) краснеют, контроль зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:49:34 +05:00
59482cae85 fix(mera): витрина сделок в расписании, дата прогона на странице (#3469)
Some checks failed
CI Trade-In / frontend-checks (pull_request) Successful in 1m57s
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
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
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.

Что сделано:

- Handler `landing_showcase_deals` в product_handlers: тело задачи
  писалось под `python -m` и про run_id не знает, поэтому done/failed
  ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
  interval_days=1. Такт суточный не из-за данных — сделки Росреестра
  квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
  что и боевой расчёт, и любой деплой меняет числа на витрине, не
  трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
  сводка просроченных источников (emit_stale_digest, #2670) ходит по
  включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
  молчит дольше 3× своего такта. Своего монитора не заводим: витрина
  была невидима не потому, что сводка не умеет про неё говорить, а
  потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
  «Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
  и раньше, фронт его не показывал; даты нет — предложения нет.

Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).

Closes #3469

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:05:19 +05:00
5d46e59bdc Московская область считается по своему ряду Сбериндекса (#3507)
All checks were successful
Deploy Trade-In / test (push) Successful in 3m51s
Deploy Trade-In / build-backend (push) Successful in 1m21s
Deploy Trade-In / deploy (push) Successful in 1m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-12 14:50:01 +00:00
a8ba2d5d3c Импорт сырья msk_raw умеет Московскую область, а не только Москву (#3505)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-09-12 14:49:06 +00:00
9fedaa4582 fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
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) Successful in 17m31s
Полный деплой ПТИЦЫ каждый раз делал `up -d --force-recreate --no-deps caddy`
и сносил единственный процесс, слушающий 80/443: замер 05.09 (#3274) — 67 с
`code=000` на ВСЕХ доменах хоста, включая публичный лендинг МЕРЫ. Заглушка
окна деплоя здесь бессильна по построению: её отдаёт тот же Caddy.

Что установлено, а не принято на веру:

* Безусловный флаг появился 17.05 (11e78d73) ради нового bind-маунта
  `./preview` — «иначе новые volume mounts не появляются». Довод неверен:
  `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам при
  смене описания сервиса или образа. Проверено на живом демоне (docker 28.4):
  добавлен volume → новый id; тег переставлен на другой образ → новый id;
  не менялось ничего → `Container … Running`, id тот же.
* Compose не видит только одного: СОДЕРЖИМОГО пофайлового bind-маунта.
  `git reset --hard` пишет новый инод, контейнер держит прежний, и `caddy
  reload` перечитывает старый текст (тот же механизм — Alertmanager 27.08 и
  Alloy #3380). У Caddy так смонтированы пять путей: Caddyfile и четыре
  сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают —
  самый частый случай, caddy/sites/apps.caddy, пересоздания НЕ требует.
* Отсюда же второй, беззвучный дефект: быстрый путь `deploy-caddy` делал голый
  `exec caddy reload` после `git reset --hard`, то есть правка Caddyfile или
  сниппета до контейнера не доезжала вовсе, а джоба уходила зелёной.

Оба пути деплоя теперь зовут ops/caddy-apply.sh: `caddy validate` одноразовым
контейнером по файлам С ХОСТА (битый конфиг не применяется и прокси не
трогает) → `up -d` без `--force-recreate` → если контейнер тот же, сверка
sha256 каждого пофайлового маунта с тем, что видит контейнер → пересоздание
ТОЛЬКО при расхождении, иначе `caddy reload` без разрыва соединений.
Не прочиталось — считаем расхождением: fail-safe в сторону прежнего поведения.

Гейт backend/tests/ops/test_3443_caddy_reload_not_recreate.py исполняет скрипт
с подставным `docker` и смотрит на совершённые действия, а не на его текст:
ничего не менялось → reload без пересоздания; правлен Caddyfile или любой из
сниппетов → пересоздание; правка в каталоге → без пересоздания; битый конфиг →
не тронуто ничего; compose пересоздал сам → второго пересоздания нет.
Отдельно — проводка в deploy.yml и запрет безусловного `--force-recreate` для
Caddy в полном деплое.

Приёмка (#3443) снимается ПОСЛЕ мержа, на живом деплое: непрерывная проба
`scripts/probe-deploy-window.sh` с хоста — максимальная серия `000` меньше 2 с
против нынешних 67 с.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:38:48 +05:00
c265e1f769 Расписание импорта сделок Росреестра по Московской области (#3504)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
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 3m40s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m12s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-12 14:18:57 +00:00
162e101636 perf(tradein-tests): убрать реальные паузы из тестов, волна 2 (#3503)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
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 / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-12 14:15:21 +00:00
98a582e242 perf(tests): убрать фиксированные ожидания из analyze-тестов Site Finder (#3502)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 39s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m41s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-12 14:09:23 +00:00
54 changed files with 4172 additions and 298 deletions

View file

@ -233,6 +233,16 @@ jobs:
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше. # уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml' - '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml' - 'docker-compose.metrics.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
# все содержательные регрессии живут в самом скрипте, а не в
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
# backend=false: джоба пропускается, гейт не исполняется, и
# «пересоздавать всегда» (окно 67 с на всех доменах) или
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
# уезжает в main зелёным.
- 'ops/**'
frontend: frontend:
- 'frontend/**' - 'frontend/**'
- '.forgejo/workflows/ci.yml' - '.forgejo/workflows/ci.yml'

View file

@ -1058,12 +1058,17 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \ docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES --force-recreate --no-deps $WORKER_SERVICES
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile # Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
# И в особенности новые volume mounts из docker-compose.prod.yml # Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
# (`reload` не пересоздаёт container, поэтому новые binds не появляются — # то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix). # 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
docker compose -p gendesign -f docker-compose.prod.yml up -d \ # Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
--force-recreate --no-deps caddy # появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Forwarder: force-recreate чтобы новый image / новые env подхватывались. # Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild # Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1290,14 +1295,13 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая # Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а. # страница = новая строка allowlist'а.
# #
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы # пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
# принять битый конфиг и оставил бы работать старый — на общем деплое это # `caddy reload` её просто не принимает: job краснеет, домены продолжают
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ # обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой # полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
# код → job краснеет, а домены продолжают обслуживаться старым конфигом. # конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop # не доедет (пофайловый bind-маунт держит инод).
# и роняет ВСЕ домены сразу.
# #
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг — # Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`. # вторая, уже против боевого файла после `git reset`.
@ -1400,11 +1404,16 @@ jobs:
exit 1 exit 1
fi fi
git reset --hard origin/main git reset --hard origin/main
# Конфиг примонтирован read-only с хоста, пересборка не нужна — # #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
# контейнер читает тот же файл, что только что обновил git. # reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ # caddy/local/**). Caddyfile и четыре сниппета смонтированы
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile # ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
echo "✓ конфиг прокси перезагружен без пересборки и без миграций" # остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
# #

View file

@ -17,12 +17,22 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим (``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
что их ожидаемые значения резолвера сохраняются. что их ожидаемые значения резолвера сохраняются.
Perf-fix (2026-09-12): в конце ``analyze_parcel`` безусловный best-effort
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
В песочнице тестов Celery-брокер (Redis) недоступен ``.delay()`` синхронно ждёт
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
тестов каталога (как и с резолвером выше) тесты самого enqueue
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -37,3 +47,34 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
""" """
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None): with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield yield
@pytest.fixture(autouse=True)
def _stub_forecast_enqueue() -> Iterator[None]:
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
``.delay(...)`` в проде fire-and-forget (best-effort, обёрнут в try/except в
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
"""
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
yield
@pytest.fixture(autouse=True)
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
значение). В тестах фетч замокан и геометрия не появится никогда каждый такой
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
``test_recent_permits_invalid_cad_no_regression``).
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
``test_run_history_and_response_contract.py``) переопределяют это поверх.
"""
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)

View file

@ -0,0 +1,463 @@
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
заглушка окна деплоя бессильна по построению её отдаёт тот же Caddy.
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
bind-маунта `./preview`, который «не появлялся в running container». Довод
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
сам, как только меняется описание сервиса или образ (проверено на живом демоне
docker 28.4). Единственное, чего compose не видит, СОДЕРЖИМОЕ пофайлового
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
«на всякий случай» одна строка, все деплои останутся зелёными, а окно в минуту
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
Отдельно проверяется проводка в deploy.yml что оба пути деплоя зовут именно
его.
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
CID = "caddy-cid-0001"
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
FILE_MOUNTS = {
"Caddyfile": "/etc/caddy/Caddyfile",
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
}
DIR_MOUNTS = {
"caddy/sites": "/etc/caddy/caddy/sites",
"caddy/local": "/etc/caddy/caddy/local",
"preview": "/srv/preview",
}
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
inspect)
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
echo "Error: No such object" >&2
exit "$FAKE_INSPECT_RC"
fi
cat "$FAKE_MOUNTS"
;;
exec)
dst="$3"
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
[ -f "$view" ] || exit 1
sha256sum "$view"
;;
compose)
case " $* " in
*" ps "*) cat "$FAKE_CID_FILE" ;;
*--force-recreate*) echo "recreated caddy (forced)" ;;
*" up "*)
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
echo "Container gendesign-caddy-1 Started"
else
echo "Container gendesign-caddy-1 Running"
fi
;;
*) echo "(compose $*)" ;;
esac
;;
esac
exit 0
"""
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
# локально вовсе.
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@pytest.fixture
def prod_tree(tmp_path: Path) -> Path:
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
path = tree / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
(tree / "preview").mkdir()
view = tmp_path / "view"
view.mkdir()
# По умолчанию контейнер видит ровно то же, что лежит на диске.
for rel, dst in FILE_MOUNTS.items():
(view / dst.replace("/", "_")).write_text(
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
)
mounts = tmp_path / "mounts"
mounts.write_text(
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
if shutil.which("sha256sum") is None:
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_MOUNTS": str(root / "mounts"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
["sh", str(tree / "ops" / "caddy-apply.sh")],
cwd=str(tree),
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _stale(tree: Path, dst: str) -> None:
"""Контейнер остался на старом иноде этого маунта."""
view = tree.parent.parent / "view" / dst.replace("/", "_")
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
def _recreated(calls: list[str]) -> bool:
return any("--force-recreate" in c for c in calls)
def _reloaded(calls: list[str]) -> bool:
return any("caddy reload" in c for c in calls)
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), (
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
)
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
_stale(prod_tree, "/etc/caddy/Caddyfile")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), (
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
)
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
_stale(prod_tree, dst)
_, _out, calls = _run(prod_tree)
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
ровно на тех правках, ради которых заведён быстрый путь #2916.
"""
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
"""
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
Второй `--force-recreate` поверх ещё одно окно недоступности на ровном
месте, а новый контейнер и так читает свежие файлы.
"""
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
assert rc == 0, out
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
беззвучно не применённая правка конфига прокси.
"""
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
Статус `$(docker inspect | while )` это статус `while`, то есть всегда
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу тот самый беззвучный отказ,
ради которого написан скрипт.
"""
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
assert rc == 0, out
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
тавтологически успешной.
"""
root = prod_tree.parent.parent
(root / "mounts").write_text(
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
)
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
(пропустить верно инод он не держит) и исчезнувший/нечитаемый файл, для
которого в контейнере как раз живёт старый инод со старым текстом. Файл
удаляется после проверки конфига (в тесте она подставная) проверяется
именно ветка сверки.
"""
(prod_tree / "Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
«перезагружен без пересоздания» одинаково означает и проверку, и её
отсутствие.
"""
_, out, _ = _run(prod_tree)
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
)
def test_validation_precedes_any_action(prod_tree: Path) -> None:
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
_, out, calls = _run(prod_tree)
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
def _ssh_script(job: str) -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
script = steps[0]["with"]["script"]
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
return script
def _commands(script: str) -> str:
"""Только команды: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта живёт в тех же файлах и содержит
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
а не от кода. Склейка `\\` потому что `--force-recreate` и имя сервиса
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
запись не увидел бы (зелено по построению).
"""
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _forced_caddy_recreates(commands: str) -> list[str]:
"""Строки, которые пересоздают именно сервис caddy."""
return [
ln
for ln in commands.splitlines()
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
]
def test_script_exists_and_is_the_one_under_test() -> None:
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
"который на пофайловом маунте читает старый инод)"
)
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
возвращает 67-секундное окно `code=000` на всех доменах и не краснит
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
проба, запущенная ровно в эту минуту.
"""
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
assert not bad, (
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
+ "\n ".join(bad)
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
"вручную это нужно только когда до контейнера не доехал пофайловый "
"bind-маунт (#3443)."
)
def test_failures_are_not_swallowed() -> None:
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
Строка `caddy reload || true` в репозитории уже живёт
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
строка reload внутри самого скрипта.
"""
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
offenders = []
for where, text in [
("deploy", _commands(_ssh_script("deploy"))),
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
]:
for line in text.splitlines():
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
offenders.append(f"{where}: {line.strip()}")
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
def test_gate_runs_on_changes_to_the_script_itself() -> None:
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
backend=false джоба пропускается, гейт не исполняется, и «пересоздавать
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
"""
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
steps = [
s
for job in spec["jobs"].values()
for s in job.get("steps") or []
if str(s.get("uses", "")).startswith("dorny/paths-filter")
]
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
assert "ops/**" in patterns, (
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
"которой стережёт"
)
def test_gate_would_notice_the_regression() -> None:
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
"""
regressed = _commands(
" # безусловное пересоздание caddy вернулось сюда\n"
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
" --force-recreate --no-deps caddy\n"
)
assert _forced_caddy_recreates(regressed), (
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
)

View file

@ -39,6 +39,7 @@ from __future__ import annotations
import re import re
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -72,6 +73,16 @@ GIT_ENV = {
"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
} }
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
# помечены и обязаны выполняться локально.
posix_only = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
)
def _detect_script() -> str: def _detect_script() -> str:
"""Тело шага, который считает изменённые файлы в job `changes`.""" """Тело шага, который считает изменённые файлы в job `changes`."""
@ -181,6 +192,7 @@ def _run(
return _exec(repo, base_sha if before is None else before, event) return _exec(repo, base_sha if before is None else before, event)
@posix_only
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None: def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/.""" """Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",)) outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
@ -193,6 +205,7 @@ def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
assert outputs["frontend"] == "false" assert outputs["frontend"] == "false"
@posix_only
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None: def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой.""" """Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py")) outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
@ -203,6 +216,7 @@ def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
assert outputs["backend"] == "true" assert outputs["backend"] == "true"
@posix_only
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None: def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
"""База не разрешилась → полный деплой, а не пустой список. """База не разрешилась → полный деплой, а не пустой список.
@ -223,6 +237,7 @@ def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
assert "деплой полный" in log assert "деплой полный" in log
@posix_only
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None: def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
"""Решение печатается: и список файлов, и итоговые флаги. """Решение печатается: и список файлов, и итоговые флаги.
@ -291,11 +306,13 @@ def _run_guard(repo: Path) -> subprocess.CompletedProcess:
) )
@posix_only
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None: def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",))) proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}" assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
@posix_only
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None: def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена.""" """Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy"))) proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
@ -322,6 +339,7 @@ def test_fast_path_takes_the_same_host_lock() -> None:
assert "flock -w 900 9" in script, "лок открывается, но не захватывается" assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
@posix_only
@pytest.mark.parametrize( @pytest.mark.parametrize(
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)] "changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
) )
@ -338,6 +356,7 @@ def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}" assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
@posix_only
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None: def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy». """Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
@ -350,6 +369,7 @@ def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
assert "изменённых файлов: 0" in log assert "изменённых файлов: 0" in log
@posix_only
def test_data_sql_counts_as_backend(tmp_path: Path) -> None: def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
"""`data/sql/**` собирает backend-образ: миграции едут в нём.""" """`data/sql/**` собирает backend-образ: миграции едут в нём."""
outputs, _ = _run(tmp_path, ("data/sql/002.sql",)) outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
@ -357,6 +377,7 @@ def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
assert outputs["caddy_only"] == "false", outputs assert outputs["caddy_only"] == "false", outputs
@posix_only
def test_non_ascii_path_is_classified(tmp_path: Path) -> None: def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
"""Кириллица в пути не должна прятать файл от классификации. """Кириллица в пути не должна прятать файл от классификации.
@ -370,6 +391,7 @@ def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}" assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
@posix_only
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None: def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
"""Push из нескольких коммитов разбирается целиком, а не по последнему. """Push из нескольких коммитов разбирается целиком, а не по последнему.

View file

@ -176,3 +176,29 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
# локального прогона на Windows; чисто-логические проверки в тех же файлах
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
# исполняться локально.
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct

View file

@ -15,10 +15,20 @@ from __future__ import annotations
import os import os
import stat import stat
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
"POSIX-only, покрытие в CI (Linux)",
)
КОРЕНЬ = Path(__file__).resolve().parents[2] КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh" СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"

View file

@ -184,10 +184,13 @@ gendsgn.ru {
} }
# Static HTML mockups для review (audit alternatives). # Static HTML mockups для review (audit alternatives).
# Public access — без auth (по запросу 2026-05-17). # Public access — без auth (по запросу 2026-05-17) — это намеренно и
# менять НЕ надо. Но каталог не должен листингом отдавать список
# файлов случайному посетителю: без `browse` file_server продолжает
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
handle_path /preview/* { handle_path /preview/* {
root * /srv/preview root * /srv/preview
file_server browse file_server
} }
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру # Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
@ -400,6 +403,36 @@ www.gendsgn.ru {
meraocenka.ru { meraocenka.ru {
encode zstd gzip encode zstd gzip
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
# применяет директивы в фиксированном порядке (`header` идёт раньше
# `reverse_proxy`), не по строке в исходнике.
#
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
# выставляют ни один из этих заголовков — дублирования нет.
header {
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
# preload — оба флага практически необратимы (браузеры кэшируют
# preload-список надолго, includeSubDomains ломает любой будущий
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
# осознанное решение, не «улучшение по умолчанию».
Strict-Transport-Security "max-age=15552000"
# Запрет встраивания во фрейм: X-Frame-Options — для старых
# клиентов, которые не понимают CSP; frame-ancestors — современный
# механизм. Оба вместе, не один вместо другого.
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
# Не даём стеку светиться в ответе. Полноценный CSP
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
-Server
-X-Powered-By
}
log { log {
output file /var/log/caddy/meraocenka.ru.log output file /var/log/caddy/meraocenka.ru.log
} }
@ -485,7 +518,7 @@ meraocenka.ru {
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next — # Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта # robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него. # форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
handle @meraPages { handle @meraPages {
rewrite * /trade-in/mera-public{path} rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 { reverse_proxy tradein-frontend:3000 {
@ -516,7 +549,7 @@ meraocenka.ru {
# переносит query как есть. Обёртка `route` обязательна по той же # переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет # причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`. # Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$ @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
handle @meraShortSlash { handle @meraShortSlash {
route { route {
uri strip_suffix / uri strip_suffix /
@ -604,7 +637,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir` # порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде). # (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/ @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
handle @meraLongPages { handle @meraLongPages {
route { route {
uri strip_prefix /trade-in/mera-public uri strip_prefix /trade-in/mera-public

138
ops/caddy-apply.sh Executable file
View file

@ -0,0 +1,138 @@
#!/bin/sh
# Применить текущий конфиг прокси к работающему Caddy (#3443).
#
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
# бессильна по построению — её отдаёт тот же Caddy.
#
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
# который «не появлялся в running container». Довод неверен: `docker compose
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
# тег указан на др. образ → id новый;
# не менялось ничего → `Container … Running`, id тот же.
#
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
# новый инод; контейнер держит примонтированным прежний и продолжает читать
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
# пересоздания НЕ требует.
#
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
# не рвёт соединения.
#
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
# поведения, то есть пересоздания.
set -eu
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
cd "$(dirname "$0")/.."
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
#
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
echo "→ проверяю конфиг прокси одноразовым контейнером…"
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile; then
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
echo " Работающий Caddy не тронут, домены живы."
exit 1
fi
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
before=$(caddy_cid)
$COMPOSE up -d --no-deps caddy
after=$(caddy_cid)
if [ -z "$after" ]; then
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
exit 1
fi
if [ "$before" != "$after" ]; then
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}${after})."
exit 0
fi
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
mounts=$(docker inspect "$after" \
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|| mounts=''
if [ -z "$mounts" ]; then
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
verdicts=""
stale="(маунты не прочитаны)"
else
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
[ -n "${dst:-}" ] || continue
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
# контейнере в этот момент живёт старый инод со старым текстом.
if [ -d "$src" ]; then continue; fi
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕРОЧИТАНО}" ]; then
echo "OK $dst"
else
echo "STALE $dst"
fi
done)
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
if [ "$checked" -eq 0 ]; then
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
stale="(пофайловых маунтов не найдено)"
fi
fi
if [ -n "$stale" ]; then
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
$COMPOSE up -d --force-recreate --no-deps caddy
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
exit 0
fi
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."

View file

@ -41,17 +41,84 @@ def scrub_query_secrets(text: str) -> str:
class QuerySecretFilter(logging.Filter): class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.""" """Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности НЕ схлопывает
их в единую строку через `record.getMessage()` с последующим `args = ()`.
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
с `ValueError: not enough values to unpack (expected 5, got 0)` сама попытка
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
"""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage() args = record.args
scrubbed = scrub_query_secrets(message) if isinstance(args, tuple) and args:
if scrubbed != message: # Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
record.msg = scrubbed # `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
record.args = () # этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
# параметра прямо перед ним).
scrubbed_args = tuple(
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
)
if scrubbed_args != args:
record.args = scrubbed_args
elif isinstance(record.msg, str):
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
scrubbed_msg = scrub_query_secrets(record.msg)
if scrubbed_msg != record.msg:
record.msg = scrubbed_msg
return True return True
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
#
# DaData на HTTP 403 возвращает диагностику вида:
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
# она может измениться (см. generic-слой ниже).
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
def _mask_value(value: str) -> str:
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
if len(value) <= 4:
return "***"
return f"{value[:4]}…({len(value)})"
def scrub_body_secrets(text: str | None) -> str:
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
2) generic любая hex/base64-подобная последовательность 24+ символов, чтобы защита
не зависела от того, как вендор сформулирует сообщение завтра.
"""
if not text:
return text or ""
def _replace_quoted(m: re.Match[str]) -> str:
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
return scrubbed
def install_query_secret_filter(*logger_names: str) -> None: def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера. """Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.

View file

@ -29,6 +29,7 @@ from typing import Any
import httpx import httpx
from app.core.config import settings from app.core.config import settings
from app.core.log_scrub import scrub_body_secrets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -173,7 +174,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)") logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None return None
if status in (401, 403): if status in (401, 403):
body_preview = (response.text or "")[:200] body_preview = scrub_body_secrets(response.text)[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден, # 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
@ -213,7 +214,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP %d — transient server error", status) logger.warning("dadata: HTTP %d — transient server error", status)
return None return None
if status >= 400: if status >= 400:
body_preview = (response.text or "")[:200] body_preview = scrub_body_secrets(response.text)[:200]
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview) logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
return None return None
@ -436,7 +437,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP %d — transient server error", status) logger.warning("dadata suggest: HTTP %d — transient server error", status)
return [] return []
if status >= 400: if status >= 400:
body_preview = (response.text or "")[:200] body_preview = scrub_body_secrets(response.text)[:200]
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview) logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
return [] return []

View file

@ -328,6 +328,13 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
_SBER_REGION_SERIES: dict[int, str] = { _SBER_REGION_SERIES: dict[int, str] = {
66: SBER_TIME_ADJUST_REGION, 66: SBER_TIME_ADJUST_REGION,
77: "Москва", 77: "Москва",
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
# ошибка только внутрирегиональная.
50: "Московская область",
} }
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0): # Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
@ -661,8 +668,10 @@ def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | No
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное # within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь). # обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0 _ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002). # Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {} # (asking_to_sold_ratios ключуется по (region_code, rooms_bucket, district)); один
# бакет теперь несёт РАЗНЫЕ ratio для разных регионов, общий int-ключ смешал бы их.
_asking_sold_ratio_cache: dict[tuple[int, int], tuple[float | None, str | None, float]] = {}
def _get_asking_sold_ratio( def _get_asking_sold_ratio(
@ -670,6 +679,7 @@ def _get_asking_sold_ratio(
rooms: int | None, rooms: int | None,
area_m2: float | None = None, area_m2: float | None = None,
anchor_ppm2: float | None = None, anchor_ppm2: float | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
) -> tuple[float | None, str | None]: ) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры. """Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
@ -685,9 +695,24 @@ def _get_asking_sold_ratio(
rooms-ключе vs area-ключе. area_bucket() тот же Python-двойник, что и в rooms-ключе vs area-ключе. area_bucket() тот же Python-двойник, что и в
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh). asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка #3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см.
(WHERE rooms_bucket = bucket AND district = '') fallback на global -1 _ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО
(basis='global_fallback'). Ничего нет (None, None). по ЕКБ и применялся к любому адресу, включая Москву (region_code=77) коэффициент
рынка на порядок дешевле (медиана ДКП: 66 90 278 /м², 77 322 976 /м²,
замер 13.09.2026) домножал московскую asking-цену. Дефолт-параметр (66) сохранён
ради обратной совместимости позиционных вызовов (тот же идиом, что
`_is_plausible_deal(region_code: int = regions_mod.DEFAULT_REGION_CODE)`).
Запрос к asking_to_sold_ratios (migration 080 + 304): per-rooms строка СВОЕГО
региона (WHERE region_code = :region_code AND rooms_bucket = bucket AND
district = '') fallback на global -1 ТОГО ЖЕ региона (basis='global_fallback').
Ни одной строки для региона (регион ещё не накопил 30/30 сделок/listings, см.
app/tasks/asking_to_sold_ratio.py) явная деградация (None, None) + INFO-лог
та же идиома, что sber_region_series_name делает для отсутствующего в карте
региона, но БЕЗ подстановки чужого регионального ratio: значение другого рынка
здесь не нейтральный фолбэк (в отличие от общероссийского sber-ряда), а именно
та ошибка калибровки, которую #3512 устраняет. ratio_basis в ответе честно
остаётся None сайту не приписывается ЧУЖОЙ региональный коэффициент.
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций см. вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций см.
@ -695,11 +720,12 @@ def _get_asking_sold_ratio(
статистический артефакт (#2002, см. module-level комментарий выше). статистический артефакт (#2002, см. module-level комментарий выше).
Таблицы нет / любая ошибка (None, None), НЕ raise (graceful). Таблицы нет / любая ошибка (None, None), НЕ raise (graceful).
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S. Кэшируется на ключ (region_code, bucket) с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
""" """
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4) bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
cache_key = (region_code, bucket)
cached = _asking_sold_ratio_cache.get(bucket) cached = _asking_sold_ratio_cache.get(cache_key)
if cached is not None: if cached is not None:
ratio, basis, fetched = cached ratio, basis, fetched = cached
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S: if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
@ -723,24 +749,39 @@ def _get_asking_sold_ratio(
text( text(
""" """
SELECT ratio, basis FROM asking_to_sold_ratios SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = CAST(:b AS int) AND district = '' WHERE region_code = CAST(:region_code AS int)
AND rooms_bucket = CAST(:b AS int) AND district = ''
""" """
), ),
{"b": bucket}, {"region_code": region_code, "b": bucket},
).fetchone() ).fetchone()
if row is None: if row is None:
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1). # Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1)
# ТОГО ЖЕ региона (#3512 — никогда чужого региона).
row = db.execute( row = db.execute(
text( text(
""" """
SELECT ratio, basis FROM asking_to_sold_ratios SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = -1 AND district = '' WHERE region_code = CAST(:region_code AS int)
AND rooms_bucket = -1 AND district = ''
""" """
), ),
{"region_code": region_code},
).fetchone() ).fetchone()
if row is not None and row.ratio is not None: if row is not None and row.ratio is not None:
ratio = float(row.ratio) ratio = float(row.ratio)
basis = row.basis basis = row.basis
elif row is None:
# #3512: у региона нет НИ ОДНОЙ строки (ни per-rooms, ни global -1) —
# обычно потому, что регион ещё не набрал 30/30 сделок/listings
# (см. app/tasks/asking_to_sold_ratio.py). Явная деградация, не
# подмена чужим регионом.
logger.info(
"asking_to_sold_ratio: region_code=%d нет ни одной строки "
"(bucket=%d) — sold-коррекция не применяется для этого региона",
region_code,
bucket,
)
ok = True ok = True
break break
except Exception as exc: except Exception as exc:
@ -769,7 +810,7 @@ def _get_asking_sold_ratio(
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет — # Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше # стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
# уже вернулся ранним return и сюда не доходит. # уже вернулся ранним return и сюда не доходит.
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic()) _asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic())
return ratio, basis return ratio, basis
@ -5061,7 +5102,11 @@ async def estimate_quality(
appm2: float | None, appm2: float | None,
) -> tuple[float | None, str | None]: ) -> tuple[float | None, str | None]:
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади). # #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2) # #3512: region_code — регион ЦЕЛИ (target_region_code, резолвлен по geo выше,
# тот же код, что уже скоупит ДКП-коридор, #3051 PR-A) — НЕ дефолт 66 всегда.
return _get_asking_sold_ratio(
db, payload.rooms, payload.area_m2, anchor_ppm2=appm2, region_code=target_region_code
)
def _qi_lookup(q: str) -> tuple[float, int] | None: def _qi_lookup(q: str) -> tuple[float, int] | None:
return _lookup_quarter_index( return _lookup_quarter_index(

View file

@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel):
sample_size: int sample_size: int
radius_m: int radius_m: int
nearby_poi: list[NearbyPoi] nearby_poi: list[NearbyPoi]
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена) poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
@ -207,9 +208,27 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: # упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. # insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
# #
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
# явно ограничил владелец продукта. # location_index явно ограничил владелец продукта.
#
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
# 458 208) — примесь заниженная городскую медиану до 422 222 (7.9%). У областных строк
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
#
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
_MEDIAN_PPM2_LOCAL_SQL = text( _MEDIAN_PPM2_LOCAL_SQL = text(
""" """
SELECT SELECT
@ -223,6 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city') AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision) AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -248,6 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city') AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS integer)
AND lat BETWEEN CAST(:bbox_south AS double precision) AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -255,6 +276,17 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
""" """
) )
_POI_COUNT_IN_REGION_SQL = text(
"""
SELECT count(*) AS n
FROM osm_poi_ekb_local
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
"""
)
_NEAREST_POI_SQL = text( _NEAREST_POI_SQL = text(
""" """
SELECT SELECT
@ -293,6 +325,7 @@ def _local_median_ppm2(
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min, "bbox_south": lat_min,
"bbox_north": lat_max, "bbox_north": lat_max,
"bbox_west": lon_min, "bbox_west": lon_min,
@ -317,6 +350,7 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min, "bbox_south": lat_min,
"bbox_north": lat_max, "bbox_north": lat_max,
"bbox_west": lon_min, "bbox_west": lon_min,
@ -333,18 +367,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
def _fetch_nearby_poi( def _fetch_nearby_poi(
db: Any, lat: float, lon: float, radius_m: int, top_n: int db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
) -> tuple[list[NearbyPoi], str]: ) -> tuple[list[NearbyPoi], str]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель. """Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении) Graceful fallback ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
([], "unavailable") вместо 500 или сфабрикованного списка. (прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
строк, все екатеринбургские) для Москвы это count > 0 (таблица не пуста), и статус
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
региона точки), а не на размер таблицы целиком. Загрузка московских POI отдельная
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
не расширяется trade_in.py и так трактует его как «нет данных», см. #2131).
""" """
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0 lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
if total == 0: in_region = (
db.execute(
_POI_COUNT_IN_REGION_SQL,
{
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
"bbox_east": lon_max,
},
).scalar()
or 0
)
if in_region == 0:
logger.warning( logger.warning(
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this " "location_index: osm_poi_ekb_local has no rows in region=%s product core "
"environment) — nearby_poi unavailable, no fabricated factors" "(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
"no fabricated factors",
region.code,
) )
return [], "unavailable" return [], "unavailable"
@ -423,7 +477,7 @@ def compute_location_index(
poi_status="unavailable", poi_status="unavailable",
) )
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n) nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
city_median, city_n = _citywide_median_ppm2(db, region) city_median, city_n = _citywide_median_ppm2(db, region)
if city_median is None or city_n < MIN_SAMPLE_SIZE: if city_median is None or city_n < MIN_SAMPLE_SIZE:

View file

@ -39,6 +39,9 @@ from scraper_kit.orchestration.scheduler import (
from scraper_kit.orchestration.scheduler import ( from scraper_kit.orchestration.scheduler import (
_pick_resume as kit_pick_resume, _pick_resume as kit_pick_resume,
) )
from scraper_kit.orchestration.scheduler import (
_resolve_region_code as kit_resolve_region_code,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext from scraper_kit.orchestration.scheduler import SchedulerContext
@ -325,6 +328,34 @@ async def _job_landing_stats(
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params) await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
async def _job_landing_showcase_deals(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Пересчёт витрины сделок публичного лэндинга (#3469).
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
`run_id` ничего не знает тот же случай, что у `_job_refresh_search_matview`,
и решается так же: done/failed ставим здесь.
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
"""
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
# ── sber_freshness_monitor — sync DB-only freshness check в executor ────────── # ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor( async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -487,6 +518,7 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"), rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)), pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)), request_delay_sec=float(params.get("request_delay_sec", 6.0)),
region_code=kit_resolve_region_code(params),
resume_run_id=kit_pick_resume(db, run_id), resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies, cookies=cookies,
) )
@ -911,6 +943,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"), "deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"), "sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"), "landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"), "newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler( "yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep" _job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -233,9 +233,11 @@ REGIONS: dict[int, Region] = {
), ),
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный # Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст # индекс/кадастр/POI не заведены (проверено — frozenset() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области загружен в # намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
# источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50 # карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
# ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением. # становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
enrichment_tiers=frozenset(), enrichment_tiers=frozenset(),
# Источники по области несут настоящий city (Химки, Балашиха — не # Источники по области несут настоящий city (Химки, Балашиха — не
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать # муниципальный округ/поселение, в отличие от Москвы) — перезаписывать

View file

@ -67,6 +67,11 @@ _DEFAULT_MAX_RETRIES = 5
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18 _DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
_RATE_LIMIT_WINDOW_S = 60.0 _RATE_LIMIT_WINDOW_S = 60.0
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
class TelegramGroupRateLimiter: class TelegramGroupRateLimiter:
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу. """Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
@ -744,13 +749,13 @@ class TelegramClient:
) -> bool: ) -> bool:
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет. """sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
Используется НЕ по прямому назначению (индикация набора), а как способ НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
проверить существование `message_thread_id` (темы форума) см. (#3471) показал, что Telegram принимает и мгновенно подтверждает
`verify_chat_and_topic`. Это единственный метод Bot API, который `sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
принимает `message_thread_id` и не создаёт message-объект: если тема (`{"ok":true,"result":true}`) метод молча принимает любую тему,
удалена/переименована в другую с иным id, Telegram отвечает `Bad существующую или нет. Раньше здесь было обратное (неверное)
Request: message thread not found` мгновенно, а в истории чата не утверждение; см. `verify_chat_and_topic`, которая для проверки темы
остаётся ни строки (индикатор эфемерный и не персистится).""" использует `sendMessage`+`deleteMessage`."""
payload: dict[str, Any] = {"chat_id": chat_id, "action": action} payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
if message_thread_id: if message_thread_id:
payload["message_thread_id"] = message_thread_id payload["message_thread_id"] = message_thread_id
@ -759,6 +764,22 @@ class TelegramClient:
) )
return bool(result) return bool(result)
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
"""deleteMessage — удаляет сообщение бота.
Используется `verify_chat_and_topic` для зачистки зондирующего
`sendMessage`-пробника сразу после проверки темы: тема существует
тогда и только тогда, когда сообщение вообще удалось отправить
поэтому к моменту вызова `deleteMessage` id уже гарантированно
валиден."""
result = await self._request(
"deleteMessage",
{"chat_id": chat_id, "message_id": message_id},
max_retries=1,
max_backoff=5.0,
)
return bool(result)
async def verify_chat_and_topic( async def verify_chat_and_topic(
client: TelegramClient, client: TelegramClient,
@ -776,18 +797,23 @@ async def verify_chat_and_topic(
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
«через сутки тишины» на «в первую секунду после старта/рестарта». «через сутки тишины» на «в первую секунду после старта/рестарта».
Способ намеренно НЕ `sendMessage`+`deleteMessage`: Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не - `getChat(chat_id)` подтверждает валидность чата и то, что бот не
выгнан/не заблокирован чистый read, нулевой видимый след. выгнан/не заблокирован чистый read, нулевой видимый след.
- `send_chat_action` (typing-индикатор с `message_thread_id`) - Раньше здесь стоял `send_chat_action` (typing-индикатор с
единственный способ провалидировать САМУ тему без создания `message_thread_id`) в расчёте на то, что Telegram обязан знать про
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы `message_thread_id`, чтобы показать «печатает...» именно в нужном
показать «печатает...» именно в нужном треде, и явно отказывает, если треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но принимает и подтверждает ЗАВЕДОМО несуществующий
оставлял бы видимый (пусть на секунды) артефакт в истории треда при `message_thread_id` (`{"ok":true,"result":true}`) проверка была
КАЖДОМ рестарте контейнера на rolling-деплое это многократно в ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
сутки; typing-индикатор того же результата достигает без единого так же, как живая, а реальная отправка потом падала на каждом
сообщения. сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
отвечает `400 Bad Request: message thread not found` (эмпирически
подтверждено на том же проде), а с валидным id создаёт сообщение
которое здесь же стирается `deleteMessage`, так что в истории треда
не остаётся ничего, кроме микросекундного технического сообщения на
каждом старте/рестарте контейнера.
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
уровня error задача явно требует шума в логе, а не падения воркера уровня error задача явно требует шума в логе, а не падения воркера
@ -802,9 +828,16 @@ async def verify_chat_and_topic(
try: try:
await client.get_chat(chat_id) await client.get_chat(chat_id)
if topic_id: if topic_id:
await client.send_chat_action( probe = await client.send_message(
chat_id=chat_id, action="typing", message_thread_id=topic_id chat_id=chat_id,
text=_TOPIC_PROBE_TEXT,
message_thread_id=topic_id,
max_retries=1,
max_backoff=5.0,
) )
probe_message_id = probe.get("message_id")
if probe_message_id:
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
except TelegramError as exc: except TelegramError as exc:
logger.error( logger.error(
"tg topic check [%s]: чат/тема недоступны для отправки " "tg topic check [%s]: чат/тема недоступны для отправки "

View file

@ -51,6 +51,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, settings from app.core.config import LISTINGS_FRESH_DAYS, settings
from app.services import regions as regions_mod
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
@ -68,13 +69,29 @@ _PPM2_MIN: int = 30_000
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены # занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном # системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE # (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный # ниже) — симметрично deal-стороне.
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон. #
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
# несёт `region_code`, а не `district`.
_ASKING_CITY_PATTERN: str = "%Екатеринбург%" _ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
# автоматически включает его в пересчёт ratio, без правки этого файла.
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
sorted(code for code in regions_mod.REGIONS if code != 66)
)
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ: # #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms # tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы — # синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
@ -108,14 +125,26 @@ def area_bucket(area_m2: float) -> int:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── True-mirror cleanup: drop all #648 rows before re-derivation ────────────── # ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда ''). # district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
# только что вставленные строки другого региона на повторном прогоне того же transaction).
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались # Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT. # stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
_DELETE_SQL = text( _DELETE_SQL = text(
""" """
DELETE FROM asking_to_sold_ratios DELETE FROM asking_to_sold_ratios
WHERE district = '' WHERE region_code = 66 AND district = ''
"""
)
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
_DELETE_SQL_REGION = text(
"""
DELETE FROM asking_to_sold_ratios
WHERE region_code = CAST(:region_code AS int) AND district = ''
""" """
) )
@ -266,19 +295,133 @@ _REDERIVE_SQL = text(
) )
INSERT INTO asking_to_sold_ratios ( INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median, rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis n_deals, n_listings, window_months, basis, region_code
) )
SELECT rooms_bucket, district, ratio, sold_median, ask_median, SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM global_row n_deals, n_listings, window_months, basis, 66 FROM global_row
UNION ALL UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median, SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM per_bucket n_deals, n_listings, window_months, basis, 66 FROM per_bucket
"""
)
# ── Generic per-region derivation (#3512) ─────────────────────────────────────
# Байт-в-байт та же деривация, что _REDERIVE_SQL выше (тот же 12-мес трейлинг,
# ppm²-полоса, novostroyki/freshness/area_m2-гарды, area-бакет ask-стороны, порог
# 30/30, global -1 fallback) — ЕДИНСТВЕННОЕ отличие: городская квота ЕКБ (city ILIKE /
# city IS NULL OR city ILIKE) заменена на симметричный `region_code = :region_code`
# на ОБЕИХ сторонах. Городской квоты здесь не было и не нужно: она была костылём
# конкретно исторического asking-покрытия ЕКБ (#C2), а не общим правилом.
_REDERIVE_SQL_REGION = text(
f"""
WITH
deal_side AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND region_code = CAST(:region_code AS int)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
ask_side AS (
SELECT
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS int)
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
),
per_bucket AS (
SELECT
d.rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'per_rooms'::text AS basis
FROM deal_side d
JOIN ask_side a USING (rooms_bucket)
WHERE d.n_deals >= 30
AND a.n_listings >= 30
AND a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
),
deal_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND region_code = CAST(:region_code AS int)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND region_code = CAST(:region_code AS int)
),
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'global_fallback'::text AS basis
FROM deal_global d
CROSS JOIN ask_global a
WHERE a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
)
INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, region_code
)
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
""" """
) )
# ── Post-insert counters ────────────────────────────────────────────────────── # ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк # Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback. # записано всего, сколько per_rooms, был ли использован global -1 fallback.
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
_COUNTERS_SQL = text( _COUNTERS_SQL = text(
""" """
SELECT SELECT
@ -292,13 +435,22 @@ _COUNTERS_SQL = text(
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]: def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4). """Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh): В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh для уже
1. DELETE FROM asking_to_sold_ratios WHERE district = '' снести stale-строки. посчитанных регионов):
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1). 1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') 080-derivation
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080. INSERT...SELECT (byte-for-byte прежняя логика городская квота ЕКБ, #C2/#2583).
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
на sold- и asking-стороне (без городской квоты она не применима вне
ЕКБ, см. комментарий у generic-derivation SQL ниже).
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) это НЕ
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
(estimator._get_asking_sold_ratio) через отсутствие строки явная деградация.
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
@ -323,7 +475,8 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
} }
try: try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними — # DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
# таблица не должна остаться пустой, если INSERT упадёт). # таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
db.execute(_DELETE_SQL) db.execute(_DELETE_SQL)
db.execute( db.execute(
_REDERIVE_SQL, _REDERIVE_SQL,
@ -334,6 +487,17 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"fresh_days": LISTINGS_FRESH_DAYS, "fresh_days": LISTINGS_FRESH_DAYS,
}, },
) )
for region_code in _OTHER_REGION_CODES:
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
db.execute(
_REDERIVE_SQL_REGION,
{
"region_code": region_code,
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
row = db.execute(_COUNTERS_SQL).mappings().first() row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None: if row is not None:

View file

@ -1,22 +1,34 @@
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`. """Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`. `ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт карточек с адресом вида «Екатеринбург (Cian)» артефакт парсера, считаются
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва». своим счётчиком, чтобы не растворяться в «не целевой регион».
Область (регион 50) у Циана в адресе НЕ видна вовсе берём по ПОДДОМЕНУ
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` область. Замер по
`msk_raw.cian_latest` 12.09.2026: `www` 38 030 карточек, из них 36 569 с
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
mytishchi 1288, podolsk 756, odintsovo 738, ) 0 карточек с префиксом округа,
итого не-www 24 784. Поддомен и префикс округа нигде не противоречат друг
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`)
карточка НЕ область (консервативно, счётчик «не целевой регион»).
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335). карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр. Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится. Два сигнала, и оба нужны ни один по отдельности не годится.
@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это
qc_geo=0 у 97% найденных. qc_geo=0 у 97% найденных.
Что куда едет: Что куда едет:
* регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор * регион == `--region` в `listings`, С координатами (geom есть сразу,
аналогов работает без ожидания `geocode_missing`); radius-подбор аналогов работает без ожидания `geocode_missing`);
* регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в * адрес разрешился, но регион ответа не совпал с `--region` не пишется,
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим
регион появится, прогон по этой полке уже не потребует внешних вызовов; `--region` подхватит её из кэша без единого внешнего вызова;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») * адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется. свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет `--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает. целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`, есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
@ -71,28 +83,43 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы. одинаковы.
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны. заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине: Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне РАЗНЫХ прогона сборщика с разными GUID: московский (батч
сборщика, поэтому областных карточек в нём нет по построению. Регион читается `msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр `mo-serp-domclick-20260912`) оба пишут в один и тот же
оставлен второй линией на случай, если в ту же таблицу когда-нибудь приедет `msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
второй город. Замер по 5 024 карточкам первого прохода: единственное значение `msk_raw.domclick_latest` отдаёт обе партии вперемешку курсор по `id` не
первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное, различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты опциональная вторая линия, а единственный способ развести регионы.
заполнены у 100% карточек.
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
12.09.2026: московские карточки «Москва, Генерала Дорохова проспект, 49» и
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
областные карточки «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
Москва приходит как «Москва, пос. Птичное, » посёлок стоит вторым
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
заполнены у 100% карточек в обоих прогонах.
Запуск: Запуск:
python -m app.tasks.msk_raw_import --dry-run python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500 python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source yandex python -m app.tasks.msk_raw_import --source yandex
python -m app.tasks.msk_raw_import --source yandex --region 50
python -m app.tasks.msk_raw_import --source cian --region 50
python -m app.tasks.msk_raw_import --source domclick python -m app.tasks.msk_raw_import --source domclick
python -m app.tasks.msk_raw_import --source domclick --region 50
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000 python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
""" """
@ -102,6 +129,7 @@ import argparse
import asyncio import asyncio
import logging import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -113,15 +141,18 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.services import dadata from app.services import dadata
from app.services.geocoder import normalize_address from app.services.geocoder import normalize_address
from app.services.regions import REGIONS, is_within_bbox
from app.services.scraper_adapters import RealMatcherAdapter from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77 MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва" MOSCOW_CITY = "Москва"
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён — # Московская область в реестре `app.services.regions` заведена, но своего
# карточки области не пишутся, а откладываются (см. докстринг модуля). # единого города у неё нет (`canonical_city is None`) — в listings.city для
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
OBLAST_REGION_CODE = 50 OBLAST_REGION_CODE = 50
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500 DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`. # Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
@ -427,14 +458,16 @@ class ImportCounters:
read: int = 0 read: int = 0
skipped_artifact: int = 0 skipped_artifact: int = 0
skipped_not_moscow: int = 0 # Карточка сама говорит про другой регион (префикс округа / поддомен
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0 skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область — # Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не # регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск # совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше # бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
# домов». # не «в целевом регионе стало меньше домов».
skipped_oblast: int = 0 skipped_geo_other_region: int = 0
skipped_ungeocoded: int = 0 skipped_ungeocoded: int = 0
selected: int = 0 selected: int = 0
inserted: int = 0 inserted: int = 0
@ -461,8 +494,8 @@ class ImportCounters:
self.read self.read
== self.selected == self.selected
+ self.skipped_artifact + self.skipped_artifact
+ self.skipped_not_moscow + self.skipped_not_target_region
+ self.skipped_oblast + self.skipped_geo_other_region
+ self.skipped_ungeocoded + self.skipped_ungeocoded
+ self.skipped_invalid + self.skipped_invalid
) )
@ -480,6 +513,37 @@ def is_moscow_address(address: str | None) -> bool:
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def cian_subdomain(payload: dict) -> str | None:
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
область читается из URL. Поддомен и префикс округа не противоречат друг
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
`www` 38 030 карточек (36 569 с префиксом округа, это Москва); все
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
khimki, mytishchi, podolsk, odintsovo, ) 0 карточек с префиксом округа.
Хост не `*.cian.ru` или `source_url` отсутствует None (консервативно:
региону не сопоставляем).
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
# суффикса и уехал в «не целевой регион».
host = urlsplit(url).hostname or ""
if not host.endswith(".cian.ru"):
return None
sub = host[: -len(".cian.ru")]
return sub or None
def is_cian_oblast_payload(payload: dict) -> bool:
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
sub = cian_subdomain(payload)
return sub is not None and sub != "www"
def is_moscow_yandex_address(address: str | None) -> bool: def is_moscow_yandex_address(address: str | None) -> bool:
"""У Яндекса регион — второй компонент полного адреса. """У Яндекса регион — второй компонент полного адреса.
@ -497,6 +561,17 @@ def is_moscow_yandex_address(address: str | None) -> bool:
return len(parts) > 1 and parts[1] == "Москва" return len(parts) > 1 and parts[1] == "Москва"
def is_oblast_yandex_address(address: str | None) -> bool:
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
ровно два значения (см. докстринг модуля), третьего нет среди карточек
Яндекса «не Москва» и означает «область».
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Московская область"
def is_moscow_domclick_address(address: str | None) -> bool: def is_moscow_domclick_address(address: str | None) -> bool:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …». """У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
@ -515,12 +590,70 @@ def is_moscow_domclick_address(address: str | None) -> bool:
return bool(parts) and parts[0] == "Москва" return bool(parts) and parts[0] == "Москва"
# Источники, у которых город виден в самой карточке. Ключ отсутствует — def is_oblast_domclick_address(address: str | None) -> bool:
# источник про город молчит, и без пред-геокода писать его нельзя (avito). """Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
CITY_FILTERS = {
"cian": is_moscow_address, Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
"yandex": is_moscow_yandex_address, копит ДВА разных прогона сборщика (московский батч
"domclick": is_moscow_domclick_address, `msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
вперемешку фильтр по адресу обязателен, не опционален. Замер живьём на
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
Мельникова, 33», «Московская область, Одинцовский городской округ,
Звенигород, 3-й м-н, 28» и подобные первый компонент «Московская
область» у всех 140 карточек выборки с семи разных смещений выдачи.
"""
parts = [part.strip() for part in (address or "").split(",")]
return bool(parts) and parts[0] == "Московская область"
def _payload_point(payload: dict) -> tuple[float, float] | None:
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
try:
return float(payload["lat"]), float(payload["lon"])
except (KeyError, TypeError, ValueError):
return None
def is_oblast_domclick_payload(payload: dict) -> bool:
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
областного батча): «Московская область» стоит первым компонентом у 2 960, а
у одной «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
"""
address = payload.get("address")
if is_oblast_domclick_address(address):
return True
if is_moscow_domclick_address(address):
return False
point = _payload_point(payload)
if point is None:
return False
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
return lambda payload: fn(payload.get("address"))
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
# молчит. Ключа нет только для источника, который вообще не умеет отличать
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
# фильтр обязан быть в реестре явно.
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
} }
@ -569,6 +702,7 @@ def import_msk_raw(
db: Session, db: Session,
*, *,
source: str = "cian", source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE, batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None, limit: int | None = None,
dry_run: bool = False, dry_run: bool = False,
@ -577,32 +711,38 @@ def import_msk_raw(
geocode_limit: int = DEFAULT_GEOCODE_LIMIT, geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters: ) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче.""" """Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
if region not in SUPPORTED_REGIONS:
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
view = SOURCE_VIEWS[source] view = SOURCE_VIEWS[source]
counters = ImportCounters() counters = ImportCounters()
matcher = RealMatcherAdapter() matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit)) budget = GeocodeBudget(remaining=max(0, geocode_limit))
# Источник, который сам говорит про город: у Циана это префикс округа, у # Источник, который сам говорит про целевой регион: у Циана это префикс
# Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему # округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
# нужен пред-геокод, поэтому его в словаре нет. # первый. Авито не говорит ничего ни для какого региона, ему нужен
city_filter = CITY_FILTERS.get(source) # пред-геокод, поэтому в реестре его нет вовсе.
city_filter = REGION_FILTERS.get((source, region))
if geocode and city_filter is not None: if geocode and city_filter is not None:
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту # Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем. # незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito") raise SystemExit(f"source={source}: --geocode нужен только для avito")
if city_filter is None and not geocode: if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области: # Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
# ни города в адресе, ни координат. Пишем только по явному разрешению. # прочих: ни города в адресе, ни координат. Пишем только по явному
# разрешению.
if not (dry_run or allow_unfiltered): if not (dry_run or allow_unfiltered):
raise SystemExit( raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от " f"source={source}: адрес не содержит признака города, регион "
"области не отличить. Нужен --geocode (штатный путь), " f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run." "--allow-unfiltered (аварийный) или --dry-run."
) )
logger.warning( logger.warning(
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; " "source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
"строки лягут без geom и вперемешку с областью", "вовсе; строки лягут без geom и вперемешку с прочими регионами",
source, source,
region,
) )
if geocode and not dry_run: if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL)) db.execute(text(_GEO_CACHE_DDL))
@ -618,16 +758,16 @@ def import_msk_raw(
if is_artifact_address(address): if is_artifact_address(address):
counters.skipped_artifact += 1 counters.skipped_artifact += 1
continue continue
if city_filter is not None and not city_filter(address): if city_filter is not None and not city_filter(payload):
counters.skipped_not_moscow += 1 counters.skipped_not_target_region += 1
continue continue
if geocode: if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload))) point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None: if point is None:
counters.skipped_ungeocoded += 1 counters.skipped_ungeocoded += 1
continue continue
if point.region_code != MOSCOW_REGION_CODE: if point.region_code != region:
counters.skipped_oblast += 1 counters.skipped_geo_other_region += 1
continue continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не # Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется # трогаем, пересбор корпуса от этого не зависит. geom появляется
@ -649,19 +789,26 @@ def import_msk_raw(
if dry_run or not lots: if dry_run or not lots:
continue continue
# У региона 50 своего единого города нет (`canonical_city is None` в
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
# и существующее значение не затирает. Подбор аналогов не страдает:
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
# ключуется на `deals.city`, а не на `listings.city`.
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
inserted, updated = save_listings( inserted, updated = save_listings(
db, db,
lots, lots,
matcher=matcher, matcher=matcher,
region_code=MOSCOW_REGION_CODE, region_code=region,
city=MOSCOW_CITY, city=city,
) )
counters.inserted += inserted counters.inserted += inserted
counters.updated += updated counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info( logger.info(
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)", "msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source, source,
region,
counters.read, counters.read,
counters.selected, counters.selected,
counters.written, counters.written,
@ -679,11 +826,12 @@ def import_msk_raw(
geocode_limit, geocode_limit,
) )
logger.info( logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d " "msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d " "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | " "геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s", "геокод-вызовов=%d | сходится=%s",
source, source,
region,
" (dry-run)" if dry_run else "", " (dry-run)" if dry_run else "",
counters.read, counters.read,
counters.selected, counters.selected,
@ -691,8 +839,8 @@ def import_msk_raw(
counters.inserted, counters.inserted,
counters.updated, counters.updated,
counters.writer_skipped if not dry_run else 0, counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow, counters.skipped_not_target_region,
counters.skipped_oblast, counters.skipped_geo_other_region,
counters.skipped_ungeocoded, counters.skipped_ungeocoded,
counters.skipped_artifact, counters.skipped_artifact,
counters.skipped_invalid, counters.skipped_invalid,
@ -707,21 +855,30 @@ def main() -> None:
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
) )
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)") parser = argparse.ArgumentParser(
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
)
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian") parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
parser.add_argument(
"--region",
type=int,
choices=SUPPORTED_REGIONS,
default=MOSCOW_REGION_CODE,
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
)
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек") parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики") parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument( parser.add_argument(
"--allow-unfiltered", "--allow-unfiltered",
action="store_true", action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom", help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
) )
parser.add_argument( parser.add_argument(
"--geocode", "--geocode",
action="store_true", action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), " help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только регион 77, область откладывается", "в listings уходит только целевой регион (--region), прочее пропускается",
) )
parser.add_argument( parser.add_argument(
"--geocode-limit", "--geocode-limit",
@ -737,6 +894,7 @@ def main() -> None:
import_msk_raw( import_msk_raw(
db, db,
source=args.source, source=args.source,
region=args.region,
batch_size=args.batch_size, batch_size=args.batch_size,
limit=args.limit, limit=args.limit,
dry_run=args.dry_run, dry_run=args.dry_run,

View file

@ -0,0 +1,52 @@
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
--
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
-- формат source/default_params).
-- Apply after: 301_web_support_message_idempotency_key.sql
--
-- WHY:
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
-- одной строки расписания — новой логики не требуется.
--
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
--
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
--
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
-- заводит расписание, включение и первый прогон остаются отдельным решением
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
--
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
BEGIN;
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
default_params
)
VALUES (
'rosreestr_dkp_import_50',
false,
4,
6,
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,79 @@
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
--
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
-- него не помогла бы: обе половины регистрации задачи (Handler в
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
--
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
-- полоса 5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
-- ночью), так что цена суточного такта — та же, что у соседнего
-- landing_stats_refresh (миграция 275).
--
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
-- GlitchTip), когда источник не приносил данных дольше
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
--
-- ОКНО 06:0007:00 UTC (11:0012:00 по Екатеринбургу): после импорта сделок
-- Росреестра (rosreestr_dkp_import, окно 0406) и после landing_stats_refresh
-- (0506) — витрина считается по уже обновлённым за ночь данным; и за два часа
-- до deals_freshness_monitor (0809), так что утренний пересчёт успевает
-- сняться с просрочки до утренней же проверки.
--
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
-- #3469 на месте, просто под другой причиной.
--
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'landing_showcase_deals',
true,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,88 @@
-- 304_asking_to_sold_ratios_region.sql
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
--
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
--
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
-- случае).
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
--
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
-- делает estimator.py в этом же PR).
--
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
--
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE asking_to_sold_ratios
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
'Часть составного PK (region_code, rooms_bucket, district).';
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
DO $$
DECLARE
v_pk_def text;
BEGIN
SELECT pg_get_constraintdef(oid) INTO v_pk_def
FROM pg_constraint
WHERE conrelid = 'asking_to_sold_ratios'::regclass
AND contype = 'p';
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'asking_to_sold_ratios'::regclass
AND contype = 'p'
) THEN
ALTER TABLE asking_to_sold_ratios
ADD CONSTRAINT asking_to_sold_ratios_pkey
PRIMARY KEY (region_code, rooms_bucket, district);
END IF;
END $$;
COMMIT;

View file

@ -134,6 +134,10 @@ USAGE
# oblast D: per-city validation (exact deals.city name, not a slug): # oblast D: per-city validation (exact deals.city name, not a slug):
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300 python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
# #3520 любой регион (choices come from app.services.regions.REGIONS):
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
""" """
from __future__ import annotations from __future__ import annotations
@ -156,6 +160,15 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.regions import REGIONS, region_for_point
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
# (app/services/regions.py), а не отдельный литерал: регион появляется в
# реестре один раз и автоматически становится доступен и здесь, и в
# msk_raw_import, и в самом эстиматоре.
DEFAULT_BACKTEST_REGION_CODE = 66
def _import_estimator() -> tuple[Any, Any]: def _import_estimator() -> tuple[Any, Any]:
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile). """Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
@ -1153,6 +1166,7 @@ _SAMPLE_SQL = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1204,6 +1218,7 @@ _SAMPLE_SQL_SCATTERED = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1233,6 +1248,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1271,10 +1287,9 @@ _CANDIDATES_SQL = text(
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city) # _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see # since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _resolve_city_ppm2_band); the default (city=None) path never issues this # _resolve_city_ppm2_band); the default (city=None) path never issues this
# query. region_code defaults to 66 (Свердловская обл.) — this script has no # query. region_code (#3520 "любой регион") is threaded from the CLI --region
# region CLI flag yet, so every call is scoped to the oblast, matching every # flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
# existing --city invocation (byte-identical to the pre-298 unscoped lookup, # every pre-existing invocation, which never passed --region).
# which only ever saw region-66 rows since the table was oblast-only before).
_CITY_PPM2_BAND_SQL = text( _CITY_PPM2_BAND_SQL = text(
""" """
SELECT ppm2_min, ppm2_max SELECT ppm2_min, ppm2_max
@ -1288,10 +1303,13 @@ _CITY_PPM2_BAND_SQL = text(
def _sample_sql(city: str | None, scattered: bool = False) -> Any: def _sample_sql(city: str | None, scattered: bool = False) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D). """ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used ``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
before this change literal identity, not just equal text so the time literal identity, not just equal text (asserted by
default CLI invocation (and the frozen EKB regression gate, which never ``test_sample_sql_city_none_returns_original_object``) so callers that
calls this path at all) see byte-identical SQL. cache/compare the query object see a stable reference. All four SQL
variants (unscoped/scattered × city/no-city) carry a ``region_code``
predicate (#3520 "любой регион") bound from ``_load_sample``'s
``region_code`` param, default 66.
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
scopes the sample to that ``deals.city`` value populated oblast-wide by scopes the sample to that ``deals.city`` value populated oblast-wide by
@ -1321,6 +1339,7 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1348,9 +1367,9 @@ def _resolve_city_ppm2_band(
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
error the globals; read-only best-effort, never raises. error the globals; read-only best-effort, never raises.
``region_code`` defaults to 66 (Свердловская обл.) this script has no ``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
region CLI flag yet (out of scope, #3051 sub-PR B); every existing direct callers; ``_load_sample`` (#3520) always passes the actual
--city caller keeps its byte-identical lookup. ``--region`` value through explicitly.
""" """
if city is None: if city is None:
return float(PPM2_MIN), float(PPM2_MAX) return float(PPM2_MIN), float(PPM2_MAX)
@ -1376,22 +1395,32 @@ def _load_sample(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> list[DealSample]: ) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]. """Run the held-out ДКП deal sampling SELECT → list[DealSample].
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
scopes the sample to one ``deals.region_code`` value REQUIRED once
``deals`` carries more than one region (212 937 Москва + 113 351
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
query would silently mix regions together. Must be a key of
``app.services.regions.REGIONS`` validated by ``main()``/``_parse_args``
(CLI ``choices``) so this function only ever sees a supported code.
``city`` (oblast D, default None) scopes the sample to one ``deals.city`` ``city`` (oblast D, default None) scopes the sample to one ``deals.city``
value via ``_sample_sql`` and sources the PPM2 sanity band from value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``, ``deal_city_price_bands`` for that (region_code, city) pair
falls back to the module globals). Default None is byte-identical to the (``_resolve_city_ppm2_band``, falls back to the module globals). Default
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals, None is byte-identical to the pre-oblast-D behaviour: same SQL object,
no extra query. same PPM2_MIN/PPM2_MAX globals, no extra query.
""" """
if city is None: if city is None:
ppm2_min: float = PPM2_MIN ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX ppm2_max: float = PPM2_MAX
else: else:
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city) ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
params: dict[str, Any] = { params: dict[str, Any] = {
"region_code": region_code,
"ppm2_min": ppm2_min, "ppm2_min": ppm2_min,
"ppm2_max": ppm2_max, "ppm2_max": ppm2_max,
"since": since, "since": since,
@ -1947,8 +1976,21 @@ def _predict_full_spine(
# harness measures the SAME corridor prod actually computes today, not the # harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics. # pre-C2 unscoped behaviour — else the backtest validates stale semantics.
target_city = m._resolve_target_city(deal.address) target_city = m._resolve_target_city(deal.address)
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
# without this, _fetch_dkp_corridor's default (region 66) would scope a
# Москва/Московская обл. deal's corridor query to the WRONG region and
# silently return no rows (or, worse, an unrelated same-named street in 66).
target_region = region_for_point(deal.lat, deal.lon)
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
dkp_raw = m._fetch_dkp_corridor( dkp_raw = m._fetch_dkp_corridor(
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city db,
address=deal.address,
rooms=deal.rooms,
area=deal.area_m2,
city=target_city,
region_code=target_region_code,
) )
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like # #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality — no-area / no-address → ([], None) instead of an # estimate_quality — no-area / no-address → ([], None) instead of an
@ -2169,6 +2211,7 @@ def run_backtest(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict. """Drive the full read-only backtest and return a metrics dict.
@ -2184,12 +2227,29 @@ def run_backtest(
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True`` out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
to fit on even-id deals and evaluate on the odd-id half for an honest number. to fit on even-id deals and evaluate on the odd-id half for an honest number.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``.
``city`` (oblast D, default None) scopes the deal sample to one ``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped ``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). (byte-identical to the pre-oblast-D behaviour).
""" """
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) deals = _load_sample(
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city) db,
sample=sample,
since=since,
city=city,
scattered=scattered,
seed=seed,
region_code=region_code,
)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
len(deals),
since,
city,
region_code,
)
matched_rows: list[tuple[float, float, int]] = [] matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = [] matched_ids: list[int] = []
@ -2264,6 +2324,7 @@ def run_backtest_full(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966). """Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -2289,6 +2350,14 @@ def run_backtest_full(
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is ``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output. attached to the returned metrics. Default False byte-identical prior output.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``. Independently of this flag,
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
lat/lon via ``regions.region_for_point`` (mirroring prod's
``estimate_quality`` region resolution) before calling
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
sample somehow mixed regions.
``city`` (oblast D, default None) scopes the deal sample to one ``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped ``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). Independently of this flag, (byte-identical to the pre-oblast-D behaviour). Independently of this flag,
@ -2296,9 +2365,21 @@ def run_backtest_full(
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring. ``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
""" """
est = _import_estimator_full() est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) deals = _load_sample(
db,
sample=sample,
since=since,
city=city,
scattered=scattered,
seed=seed,
region_code=region_code,
)
logger.info( logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city "loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
len(deals),
since,
city,
region_code,
) )
predictions: list[Prediction] = [] predictions: list[Prediction] = []
@ -2482,6 +2563,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два " help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
"прогона подряд отличаются только правкой, а не составом выборки.", "прогона подряд отличаются только правкой, а не составом выборки.",
) )
p.add_argument(
"--region",
type=int,
default=DEFAULT_BACKTEST_REGION_CODE,
choices=sorted(REGIONS),
help="#3520 'любой регион': deals.region_code to backtest against (default "
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
"Supported values come from the app.services.regions.REGIONS registry — "
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
+ ". An unsupported code is rejected by argparse before any DB call.",
)
p.add_argument( p.add_argument(
"--city", "--city",
default=None, default=None,
@ -2599,9 +2691,10 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--calibrate-segments is only supported with --engine full") raise SystemExit("--calibrate-segments is only supported with --engine full")
logger.info( logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm " "backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s", "rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
args.engine, args.engine,
args.region,
args.sample, args.sample,
args.since, args.since,
args.radius, args.radius,
@ -2624,6 +2717,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city, city=args.city,
scattered=(args.spread == "scattered"), scattered=(args.spread == "scattered"),
seed=args.seed, seed=args.seed,
region_code=args.region,
) )
else: else:
metrics = run_backtest( metrics = run_backtest(
@ -2636,6 +2730,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city, city=args.city,
scattered=(args.spread == "scattered"), scattered=(args.spread == "scattered"),
seed=args.seed, seed=args.seed,
region_code=args.region,
) )
finally: finally:
db.close() db.close()

View file

@ -829,6 +829,46 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
assert dadata._clean_disabled_warned is True assert dadata._clean_disabled_warned is True
# Вымышленный токен ТОЙ ЖЕ формы, что настоящий DaData API-ключ (40 hex-символов) — НЕ
# реальный секрет. Прод-факт: DaData на 403 кладёт действующий токен в тело ответа
# открытым текстом, и body_preview копирует его в WARNING/ERROR лог как есть (#3471).
FAKE_LEAKED_TOKEN = "deadbeef1234deadbeef1234deadbeef12345678"
CLEAN_TOKEN_LEAK_BODY = {
"timestamp": "2026-09-13T10:00:00.000+00:00",
"status": 403,
"error": "Forbidden",
"message": (
f"Feature 'CLEAN' disabled for token '{FAKE_LEAKED_TOKEN}'. "
"See https://dadata.userecho.com/topics/7784 for help."
),
"path": "/api/v1/clean/address",
}
async def test_clean_address_masks_token_in_feature_disabled_log(caplog) -> None:
"""403 body в реалистичной прод-форме (40-символьный токен) — токен НЕ должен светиться в логе.
Отличие от test_clean_address_logs_feature_disabled_distinctly: тот тест использует
укороченный токен 'xxx' и не проверяет утечку. Здесь токен длины настоящего DaData-ключа,
именно такая форма ушла в docker logs на проде (#3471).
"""
from app.services import dadata
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
transport = _mock_transport_returning(403, CLEAN_TOKEN_LEAK_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
text = caplog.text
assert FAKE_LEAKED_TOKEN not in text, "токен утёк в лог-запись открытым текстом"
# Диагностика (какая услуга выключена) обязана сохраниться несмотря на маскирование.
assert "Стандартизация" in text or "выключена" in text
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None: async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
"""401 (или 403 без 'disabled') → сообщение про креды.""" """401 (или 403 без 'disabled') → сообщение про креды."""
from app.services import dadata from app.services import dadata

View file

@ -25,6 +25,12 @@ from app.services import location_index as lc
# A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area). # A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area).
_LAT_IN_EKB = 56.838 _LAT_IN_EKB = 56.838
_LON_IN_EKB = 60.605 _LON_IN_EKB = 60.605
_REGION_66 = lc.regions_mod.REGIONS[66]
# A point well inside Moscow's product core (within MKAD) — #3051 region 77.
_LAT_IN_MSK = 55.75
_LON_IN_MSK = 37.62
_REGION_77 = lc.regions_mod.REGIONS[77]
# ── Pure functions ────────────────────────────────────────────────────────── # ── Pure functions ──────────────────────────────────────────────────────────
@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_LOCAL_SQL,
lc._MEDIAN_PPM2_CITYWIDE_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL,
lc._NEAREST_POI_SQL, lc._NEAREST_POI_SQL,
lc._POI_COUNT_IN_REGION_SQL,
): ):
assert not re.search(r":\w+::", str(sql.text)) assert not re.search(r":\w+::", str(sql.text))
@ -142,6 +149,51 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None:
assert "bbox_west" in sql_text and "bbox_east" in sql_text assert "bbox_west" in sql_text and "bbox_east" in sql_text
# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ──
def test_median_queries_filter_by_region_code() -> None:
"""Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние
region_code=50 (Химки/Реутов/Котельники/Люберцы) без фильтра по региону городская
медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9%
(422 222 вместо 458 208 /м²). У областных строк city IS NULL фильтровать можно
только по region_code, не по имени города."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code = CAST(:region_code AS integer)" in sql_text
def test_median_queries_do_not_admit_null_region_code() -> None:
"""Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается
ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox
ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними
151 101 /м², без них 151 103 сдвиг 2 рубля на 151 тысяче (0.001%), для 66
статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам
дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox
Москвы (сегодня не проходят по свежести, но могут завтра) `OR NULL` тихо открыл бы
им дорогу обратно в московскую медиану."""
for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL):
sql_text = str(sql.text)
assert "region_code IS NULL" not in sql_text
assert "OR region_code" not in sql_text
def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None:
"""region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не
захардкожен иначе фикс работал бы только для 66 и молча ломался на следующем регионе."""
db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})])
lc._citywide_median_ppm2(db66, _REGION_66)
assert db66.executed[0][1]["region_code"] == 66
db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})])
lc._citywide_median_ppm2(db77, _REGION_77)
assert db77.executed[0][1]["region_code"] == 77
local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})])
lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66)
assert local66.executed[0][1]["region_code"] == 66
# ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ───────────── # ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ─────────────
@ -246,7 +298,9 @@ class _FakeDB:
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None: def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
db = _FakeDB([_FakeResult(scalar_value=0)]) db = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == [] assert poi == []
assert status == "unavailable" assert status == "unavailable"
assert len(db.executed) == 1 # only the count probe ran assert len(db.executed) == 1 # only the count probe ran
@ -254,7 +308,9 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None: def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None:
db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])]) db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) poi, status = lc._fetch_nearby_poi(
db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66
)
assert poi == [] assert poi == []
assert status == "ok" assert status == "ok"
@ -267,7 +323,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None:
{"name": "Аптека", "category": "pharmacy", "distance_m": 50.0}, {"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
] ]
db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)]) db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert status == "ok" assert status == "ok"
assert len(poi) == 4 assert len(poi) == 4
# metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not # metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not
@ -281,7 +337,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None:
for i in range(20) for i in range(20)
] ]
db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)]) db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66)
assert len(poi) == 7 assert len(poi) == 7
@ -407,3 +463,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
assert result.poi_status == "unavailable" assert result.poi_status == "unavailable"
assert result.nearby_poi == [] assert result.nearby_poi == []
assert result.location_index_pct == 15.0 assert result.location_index_pct == 15.0
# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ──
def test_covered_region_resolves_moscow_center_to_region_77() -> None:
"""МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у
regions._POINT_LOOKUP_ORDER компактный bbox_region проверяется раньше объемлющего)."""
region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK)
assert region is not None and region.code == 77
def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None:
"""Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local
(4 850 екатеринбургских строк, всегда > 0) Москва получала бы poi_status="ok" с
пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77
внутри его ядра POI нет (0), для 66 те же 4 850 строк, что и раньше."""
db_msk = _FakeDB([_FakeResult(scalar_value=0)])
poi, status = lc._fetch_nearby_poi(
db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77
)
assert poi == []
assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше
# bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса).
poi_count_params = db_msk.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None:
"""Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte,
просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице."""
db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])])
lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66)
poi_count_params = db.executed[0][1]
lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core
assert poi_count_params["bbox_south"] == lat_min
assert poi_count_params["bbox_north"] == lat_max
assert poi_count_params["bbox_west"] == lon_min
assert poi_count_params["bbox_east"] == lon_max
def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None:
"""End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66,
не отсутствие фильтра), а poi_status обязан быть честным "unavailable" НЕ "ok" с
пустым списком, как было до фикса дефекта 2."""
db = _FakeDB(
[
_FakeResult(scalar_value=0), # poi-count in Moscow core: 0
_FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet
]
)
result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK)
assert result.status == "insufficient_data" # честно: листингов Москвы пока нет
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
citywide_params = db.executed[1][1]
assert citywide_params["region_code"] == 77

View file

@ -14,6 +14,7 @@ NEVER calls real Telegram API — httpx.MockTransport only, тот же патт
from __future__ import annotations from __future__ import annotations
import json
import os import os
from unittest import mock from unittest import mock
@ -51,12 +52,19 @@ def _stop_patches():
# ── verify_chat_and_topic ──────────────────────────────────────────────────── # ── verify_chat_and_topic ────────────────────────────────────────────────────
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None: async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения.""" """Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
показал, что она подтверждает даже несуществующий `message_thread_id`
(`{"ok":true,"result":true}`) проверка была ложно-зелёной."""
calls: list[str] = [] calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path.rsplit("/", 1)[-1]) method = request.url.path.rsplit("/", 1)[-1]
calls.append(method)
if method == "sendMessage":
return httpx.Response(200, json={"ok": True, "result": {"message_id": 42}})
return httpx.Response(200, json={"ok": True, "result": True}) return httpx.Response(200, json={"ok": True, "result": True})
_install_transport(handler) _install_transport(handler)
@ -65,24 +73,31 @@ async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support") ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
assert ok is True assert ok is True
assert calls == ["getChat", "sendChatAction"] assert calls == ["getChat", "sendMessage", "deleteMessage"]
async def test_verify_chat_and_topic_no_visible_message_method_used() -> None: async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат.""" """Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
methods: list[str] = [] `message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
delete_payloads: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
methods.append(request.url.path.rsplit("/", 1)[-1]) method = request.url.path.rsplit("/", 1)[-1]
return httpx.Response(200, json={"ok": True, "result": True}) if method == "sendMessage":
return httpx.Response(200, json={"ok": True, "result": {"message_id": 986}})
if method == "deleteMessage":
delete_payloads.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True, "result": True})
return httpx.Response(200, json={"ok": True, "result": {}})
_install_transport(handler) _install_transport(handler)
client = TelegramClient(token="fake-token") client = TelegramClient(token="fake-token")
await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support") ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
assert "sendMessage" not in methods assert ok is True
assert "copyMessage" not in methods assert len(delete_payloads) == 1
assert delete_payloads[0]["message_id"] == 986
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None: async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
@ -135,6 +150,50 @@ async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> No
assert calls == [] assert calls == []
async def test_verify_chat_and_topic_is_red_when_topic_deleted_even_though_send_chat_action_lies(
caplog,
) -> None:
"""Регрессия #3471: удалённая/переименованная тема ДОЛЖНА красить проверку.
Handler воспроизводит РЕАЛЬНО замеренное на проде поведение Bot API:
`sendChatAction` подтверждает даже несуществующий `message_thread_id`
(`{"ok":true,"result":true}` живой прод-замер, #3471), а `sendMessage`
с тем же id честно отвечает `400 Bad Request: message thread not found`.
На коде ДО правки (`verify_chat_and_topic` звала `send_chat_action`) этот
тест падает: `ok` был бы `True` проверка молчаливо считала мёртвую тему
живой. Явно проверено при разработке (#3471): `git checkout HEAD --
.../client.py` (до-фиксная версия) + прогон только этого теста дал
`assert True is False`; после возврата фикса зелёный."""
def handler(request: httpx.Request) -> httpx.Response:
method = request.url.path.rsplit("/", 1)[-1]
if method == "getChat":
return httpx.Response(200, json={"ok": True, "result": {"id": -100123}})
if method == "sendChatAction":
# Реальный прод-баг: sendChatAction НЕ валидирует message_thread_id.
return httpx.Response(200, json={"ok": True, "result": True})
if method == "sendMessage":
return httpx.Response(
400,
json={
"ok": False,
"error_code": 400,
"description": "Bad Request: message thread not found",
},
)
return httpx.Response(200, json={"ok": True, "result": True})
_install_transport(handler)
client = TelegramClient(token="fake-token")
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=987654, label="support")
assert ok is False
assert any(record.levelname == "ERROR" for record in caplog.records)
# ── TelegramGroupRateLimiter ───────────────────────────────────────────────── # ── TelegramGroupRateLimiter ─────────────────────────────────────────────────

View file

@ -38,6 +38,7 @@ tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
tests/test_user_events.py::test_real_record_event_inserts_row tests/test_user_events.py::test_real_record_event_inserts_row
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
# Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main # Приватность/ретеншн (#2547) — тот же `_live_session()`. Приехали в main
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и # параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и

View file

@ -64,7 +64,14 @@ def test_uvicorn_access_line_masks_secret_query_param() -> None:
def test_app_logger_via_root_handler_masks_secret() -> None: def test_app_logger_via_root_handler_masks_secret() -> None:
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты.""" """Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты.
Секрет + query-контекст (`?token=...`) передаются ОДНИМ %s-аргументом как реально
устроены вызовы в этом кодбейзе (см. body_preview в app/services/dadata.py), а не
расщеплены между msg-шаблоном и голым значением в args. Фильтр (#3471) скрабит
ЭЛЕМЕНТЫ `record.args` по отдельности, не трогая arity/`record.msg`-шаблон поэтому
секрет обязан быть самодостаточным (с контекстом) внутри одного аргумента.
"""
stream = io.StringIO() stream = io.StringIO()
handler = logging.StreamHandler(stream) handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s")) handler.setFormatter(logging.Formatter("%(message)s"))
@ -73,7 +80,7 @@ def test_app_logger_via_root_handler_masks_secret() -> None:
root.addHandler(handler) root.addHandler(handler)
try: try:
logging.getLogger("app.some.module").warning( logging.getLogger("app.some.module").warning(
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET "retry callback %s", f"https://gendsgn.ru/hook?token={_SECRET}"
) )
finally: finally:
root.removeHandler(handler) root.removeHandler(handler)
@ -133,3 +140,46 @@ def test_install_is_idempotent() -> None:
install_query_secret_filter("uvicorn.access") install_query_secret_filter("uvicorn.access")
filters = logging.getLogger("uvicorn.access").filters filters = logging.getLogger("uvicorn.access").filters
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters
def test_uvicorn_access_formatter_survives_secret_scrub() -> None:
"""Прод-баг: скраб секрета в access-логе ронял сам процесс логирования (docker logs).
`uvicorn.logging.AccessFormatter.formatMessage()` распаковывает `record.args` НАПРЯМУЮ
как 5-tuple (`client_addr, method, full_path, http_version, status_code = record.args`),
в обход `record.getMessage()`. Старая реализация фильтра при найденном секрете обнуляла
`record.args = ()` `ValueError: not enough values to unpack (expected 5, got 0)`
`--- Logging error ---` в docker-логах ТОЛЬКО в момент, когда фильтр пытался защитить
секрет (см. трассу через starlette/middleware/errors.py `_send` это просто цепочка
вложенных ASGI `send()`, ведущая к access-log вызову внутри uvicorn).
"""
from uvicorn.logging import AccessFormatter
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(AccessFormatter("%(client_addr)s - %(request_line)s %(status_code)s"))
logger = logging.getLogger("uvicorn.access.formatter_test")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
install_query_secret_filter("uvicorn.access.formatter_test")
try:
# НЕ должно бросить ValueError при обработке handler'ом (падение уходит в
# handleError → stderr, а не наверх — поэтому проверяем результат, а не raises).
logger.info(
'%s - "%s %s HTTP/%s" %d',
"172.18.0.3:60322",
"POST",
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
"1.1",
200,
)
finally:
logger.removeHandler(handler)
logger.filters.clear()
out = stream.getvalue()
assert out, "форматтер не выдал ни строки — запись эмита провалилась (см. handleError)"
assert _SECRET not in out, out
assert "secret=***" in out, out
assert "200" in out and "POST" in out

View file

@ -342,6 +342,9 @@ def _yandex_with_pool(monkeypatch: pytest.MonkeyPatch, *, get: Any) -> _Counting
patch( patch(
"scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get) "scraper_kit.providers.yandex.valuation._CurlCffiSession", _fake_curl_session(get=get)
), ),
# На успехе fetch_house_history зовёт настоящий anti-ban sleep_between_requests
# (5с jitter) — тесту нужен только факт release-lease, не сама пауза.
patch("scraper_kit.base.asyncio.sleep", new_callable=AsyncMock),
): ):
_call_yandex(_db_cache_miss()) _call_yandex(_db_cache_miss())
return provider return provider

View file

@ -0,0 +1,324 @@
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
(«МЕРА сказала X продали за Y»), но в `scrape_schedules` строки для неё не было
вовсе (`WHERE source LIKE '%showcase%'` 0 строк на проде 12.09.2026), а в
реестре `product_handlers` обработчика. То есть планировщик про задачу не знал
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
счётчики прогона, но не его дата, а сводка просроченных источников
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
в таблице нет, для неё не существует.
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
нашёл бы задачу и не смог её запустить.
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days`
сводка считает порог просрочки из этого же числа.
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
читается из миграции: ожидание, взятое из той же настройки, которую
проверяешь, уезжает вместе с ней см. комментарий при _ACCEPTANCE_CYCLES.
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
повторное применение её не задваивает, и настоящий запрос сводки
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
базы само-скипается (запись в tests/skip_allowlist.txt).
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой нет.
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
тем же путём, каким гоняет CI:
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import scheduler as sched
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
SOURCE = "landing_showcase_deals"
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
_DEPS = [
_SQL_DIR / "015_scrape_runs.sql",
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
_SQL_DIR / "051_scrape_runs_extend.sql",
_SQL_DIR / "052_scrape_schedules.sql",
]
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
def _migration_sql() -> str:
return _MIGRATION.read_text("utf-8")
def _seeded_interval_days() -> int:
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
assert m is not None, "в default_params миграции 303 нет interval_days"
return int(m.group(1))
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
def test_handler_resolves_for_scheduler() -> None:
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
Ломать так: убрать ключ из реестра в product_handlers тест покраснеет, а
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
"""
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
handler = sched.resolve_handler(SOURCE, registry)
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
# витрины или не ведёт — это свойство функции, а не подписи в логе.
assert handler.job is _job_landing_showcase_deals, (
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
)
assert handler.log_name == SOURCE
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
def test_migration_303_exists() -> None:
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
def test_migration_303_seeds_source_enabled() -> None:
sql = _migration_sql()
assert f"'{SOURCE}'" in sql
assert "INSERT INTO scrape_schedules" in sql
# enabled=true — иначе сводка просроченных источников строку не увидит
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
def test_migration_303_is_idempotent_and_transactional() -> None:
sql = _migration_sql()
assert "ON CONFLICT (source) DO NOTHING" in sql
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_303_no_psycopg_trap() -> None:
assert not re.search(r":\w+::", _migration_sql())
def test_migration_303_interval_days_is_daily() -> None:
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
assert _seeded_interval_days() == 1
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
def _row(age_days: float, *, status: str | None = "done") -> Any:
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
`status=None` (LEFT JOIN не нашёл прогонов) витрину не пересчитывали ни разу
с момента появления расписания; тогда возраст считается от created_at строки.
"""
finished = None if status is None else NOW - timedelta(days=age_days)
return SimpleNamespace(
source=SOURCE,
interval_days=str(_seeded_interval_days()),
created_at=NOW - timedelta(days=age_days),
finished_at=finished,
status=status,
counters={"considered": 200, "eligible": 161, "written": 20},
)
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
return sched.stale_sources(sched.freshness_rows(rows), NOW)
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
# и задано в тактах, а не в сутках.
_ACCEPTANCE_CYCLES = 3
def test_digest_flags_showcase_after_three_cycles() -> None:
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
stale = _stale_now([_row(lag)])
assert [s.source for s in stale] == [SOURCE], (
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
)
assert stale[0].interval_days == _seeded_interval_days()
def test_digest_silent_within_cycle() -> None:
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
lag = 2 * _seeded_interval_days()
assert _stale_now([_row(lag)]) == []
def test_digest_flags_showcase_that_never_ran() -> None:
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
stale = _stale_now([_row(lag, status=None)])
assert [s.source for s in stale] == [SOURCE]
assert stale[0].never_ok is True
def test_showcase_counters_do_not_fake_freshness() -> None:
"""Свежесть даёт ПРОГОН, а не его счётчики.
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
(`considered`/`eligible`/`written` свой словарь). Значит мерой остаётся
успешный статус: прогон, свалившийся в failed, свежести не даёт.
"""
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
def _live_session() -> Any | None:
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` заглушка модулей,
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
"""
try:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
def _apply(db: Any, path: Path) -> None:
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
плейсхолдеры любой процент в комментарии миграции («полоса 5..+20 %»)
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
артефакт теста, а не свойство файла.
"""
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
db.commit()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
Значение, а не текст файла: применяем 303 на живой базе (дважды дублей быть
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL`
тот же запрос, которым сводка судит на проде.
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
ON CONFLICT DO NOTHING), то есть повтор здесь ровно то же действие, что и
повторный деплой.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
# обращается к колонке, которую снесла 214. Файл идемпотентен
# относительно себя, но не относительно схемы, прошедшей 214, — и
# прогон на чистой базе этого не видит по построению.
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
for dep in _DEPS:
_apply(db, dep)
_apply(db, _MIGRATION)
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
# счёт строк такую замену не отличает, а created_at отличает.
first_created_at = db.execute(
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
).scalar()
_apply(db, _MIGRATION)
rows = db.execute(
text(
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
" (next_run_at > now()) AS next_run_ahead, "
" default_params->>'interval_days' AS interval_days "
"FROM scrape_schedules WHERE source = :s"
),
{"s": SOURCE},
).fetchall()
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
row = rows[0]
assert row.created_at == first_created_at, (
"повторное применение пересоздало строку расписания — на каждом деплое "
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
)
assert row.enabled is True
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
assert int(row.interval_days) == _seeded_interval_days()
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
overdue_at = row.created_at + timedelta(
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
)
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
fresh_at = row.created_at + timedelta(hours=1)
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
finally:
db.close()

View file

@ -70,7 +70,7 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
yield events yield events
finally: finally:
# Иначе на каждый тест остаётся фоновый поток транспорта. # Иначе на каждый тест остаётся фоновый поток транспорта.
client.close() client.close(timeout=0)
def event_texts(events: list[dict[str, Any]]) -> list[str]: def event_texts(events: list[dict[str, Any]]) -> list[str]:

View file

@ -81,26 +81,27 @@ def test_transient_error_returns_none_and_does_not_cache() -> None:
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type] ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
assert (ratio, basis) == (None, None) assert (ratio, basis) == (None, None)
assert db.rolled_back is True assert db.rolled_back is True
# bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None. # #3512: ключ кэша — (region_code, bucket); region_code не передан → дефолт 66.
assert 2 not in estimator._asking_sold_ratio_cache # (66, 2) НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
assert (66, 2) not in estimator._asking_sold_ratio_cache
def test_transient_error_then_success_retries_not_poisoned() -> None: def test_transient_error_then_success_retries_not_poisoned() -> None:
# 1) транзиентный сбой — None, кэш не отравлен # 1) транзиентный сбой — None, кэш не отравлен
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type] r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
assert r1 is None assert r1 is None
assert 2 not in estimator._asking_sold_ratio_cache assert (66, 2) not in estimator._asking_sold_ratio_cache
# 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio # 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type] r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
assert r2 == pytest.approx(0.84) assert r2 == pytest.approx(0.84)
assert basis2 == "per_rooms" assert basis2 == "per_rooms"
# теперь успешный результат закэширован # теперь успешный результат закэширован
assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84) assert estimator._asking_sold_ratio_cache[(66, 2)][0] == pytest.approx(0.84)
def test_successful_lookup_is_cached() -> None: def test_successful_lookup_is_cached() -> None:
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type] r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
assert r == pytest.approx(0.77) assert r == pytest.approx(0.77)
assert basis == "per_rooms" assert basis == "per_rooms"
cached = estimator._asking_sold_ratio_cache.get(1) cached = estimator._asking_sold_ratio_cache.get((66, 1))
assert cached is not None and cached[0] == pytest.approx(0.77) assert cached is not None and cached[0] == pytest.approx(0.77)

View file

@ -34,8 +34,20 @@ _MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql"
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string). # Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text) _DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) _REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
_DELETE_SQL_REGION = str(ratio_mod._DELETE_SQL_REGION.text)
_REDERIVE_SQL_REGION = str(ratio_mod._REDERIVE_SQL_REGION.text)
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text) _COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
_ALL_SQL = _DELETE_SQL + "\n" + _REDERIVE_SQL + "\n" + _COUNTERS_SQL _ALL_SQL = (
_DELETE_SQL
+ "\n"
+ _REDERIVE_SQL
+ "\n"
+ _DELETE_SQL_REGION
+ "\n"
+ _REDERIVE_SQL_REGION
+ "\n"
+ _COUNTERS_SQL
)
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios) _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
@ -43,14 +55,17 @@ _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
def test_refresh_deletes_district_rows_first() -> None: def test_refresh_deletes_district_rows_first() -> None:
"""True-mirror: DELETE FROM asking_to_sold_ratios WHERE district='' before the INSERT. """True-mirror: DELETE FROM asking_to_sold_ratios WHERE region=66/district='' first.
The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
that drop below 30/30 on a later run. The refresh must DELETE first instead. that drop below 30/30 on a later run. The refresh must DELETE first instead.
#3512: the EKB delete is now scoped to `region_code = 66` too (region-aware ratios) —
the other-region loop uses its own _DELETE_SQL_REGION, tested separately below.
""" """
flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip() flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
assert "DELETE FROM asking_to_sold_ratios" in flat_del assert "DELETE FROM asking_to_sold_ratios" in flat_del
assert "WHERE district = ''" in flat_del assert "WHERE region_code = 66 AND district = ''" in flat_del
# The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it). # The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
assert "ON CONFLICT" not in _REDERIVE_SQL assert "ON CONFLICT" not in _REDERIVE_SQL
@ -278,12 +293,24 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
"""Remove the #2656 freshness predicate (absent in the 080 seed).""" """Remove the #2656 freshness predicate (absent in the 080 seed)."""
return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s) return re.sub(r"AND\s+scraped_at\s*>\s*NOW\(\)\s*-\s*\(:fresh_days[^\n]*?interval", "", s)
def _drop_region_code_column(s: str) -> str:
"""Remove the #3512 region_code column/literal (absent in the 080 seed).
Migration 304 adds region_code to the table; the refresh INSERT now writes it
explicitly (region_code, ... ) / ( ..., 66) the 080 seed predates the column.
"""
s = re.sub(r"basis,\s*region_code", "basis", s)
s = re.sub(r"basis,\s*66\s+FROM", "basis FROM", s)
return s
def _norm(s: str) -> str: def _norm(s: str) -> str:
return _strip_sql( return _strip_sql(
_normalise_ppm2( _normalise_ppm2(
_normalise_bucket_expr( _normalise_bucket_expr(
_drop_freshness_guard( _drop_freshness_guard(
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s))) _drop_region_code_column(
_drop_area_not_null_guard(_drop_city_guard(_drop_segment_guard(s)))
)
) )
) )
) )
@ -406,10 +433,10 @@ class _FakeMappingResult:
class _FakeDB: class _FakeDB:
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls. """Minimal stand-in for a SQLAlchemy Session — records execute() calls.
Execute call order: #3512: execute call order is now EKB (DELETE + INSERT) followed by a
1. DELETE asking_to_sold_ratios DELETE+INSERT pair per _OTHER_REGION_CODES region, then ONE counters SELECT
2. REDERIVE INSERT asking_to_sold_ratios at the very end identified by statement IDENTITY (`stmt is ratio_mod._COUNTERS_SQL`),
3. COUNTERS SELECT (returns counters_row) not by a hardcoded call index, so the fake stays correct if the region registry grows.
""" """
def __init__( def __init__(
@ -423,8 +450,7 @@ class _FakeDB:
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult: def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
self.executed.append((stmt, params)) self.executed.append((stmt, params))
# 3rd call is the counters SELECT (asking_to_sold_ratios). if stmt is ratio_mod._COUNTERS_SQL:
if len(self.executed) == 3:
return _FakeMappingResult(self._counters_row) return _FakeMappingResult(self._counters_row)
return _FakeMappingResult(None) return _FakeMappingResult(None)
@ -438,8 +464,8 @@ class _FakeDB:
def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None: def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
"""recompute_asking_to_sold_ratios maps the counters row into its return + marks done. """recompute_asking_to_sold_ratios maps the counters row into its return + marks done.
#2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3 #3512: FakeDB now sees 2 statements for EKB + 2 per _OTHER_REGION_CODES region +
execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT). 1 final COUNTERS SELECT (counters are still the 3 legacy keys, #2002).
""" """
marked: dict[str, Any] = {} marked: dict[str, Any] = {}
monkeypatch.setattr( monkeypatch.setattr(
@ -461,8 +487,10 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
} }
assert out == expected assert out == expected
assert db.committed is True assert db.committed is True
# Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms). # EKB (DELETE+INSERT) + 2 per other region + 1 COUNTERS SELECT.
assert len(db.executed) == 3 expected_calls = 2 + 2 * len(ratio_mod._OTHER_REGION_CODES) + 1
assert len(db.executed) == expected_calls
assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL
assert marked["run_id"] == 99 assert marked["run_id"] == 99
assert marked["counters"] == expected assert marked["counters"] == expected
@ -551,3 +579,90 @@ def test_area_bucket_matches_sql_boundaries() -> None:
for i, bound in enumerate(sql_bounds): for i, bound in enumerate(sql_bounds):
assert ratio_mod.area_bucket(bound - 0.01) == i assert ratio_mod.area_bucket(bound - 0.01) == i
assert ratio_mod.area_bucket(bound) == i + 1 assert ratio_mod.area_bucket(bound) == i + 1
# ── #3512: per-region ratio (migration 304) ───────────────────────────────────
_MIGRATION_304 = _SQL_DIR / "304_asking_to_sold_ratios_region.sql"
def test_migration_304_exists() -> None:
assert _MIGRATION_304.is_file(), f"missing migration: {_MIGRATION_304}"
def test_migration_304_adds_region_code_and_composite_pk() -> None:
sql = _MIGRATION_304.read_text("utf-8")
assert "ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66" in sql
assert "PRIMARY KEY (region_code, rooms_bucket, district)" in sql
# PK-guard идемпотентен (тот же паттерн, что 298_deal_city_price_bands_region.sql).
assert "pg_get_constraintdef" in sql
assert "DROP CONSTRAINT asking_to_sold_ratios_pkey" in sql
assert "BEGIN;" in sql and "COMMIT;" in sql
assert "SET LOCAL lock_timeout" in sql
def test_migration_304_uses_psycopg_safe_sql() -> None:
sql = _MIGRATION_304.read_text("utf-8")
assert not re.search(r":\w+::", sql)
def test_other_region_codes_matches_registry_minus_ekb() -> None:
"""_OTHER_REGION_CODES — весь реестр регионов (regions_mod.REGIONS) минус 66.
Новый регион в реестре автоматически подхватывается пересчётом ratio (#3512) —
без правки этого файла.
"""
from app.services import regions as regions_mod
expected = tuple(sorted(code for code in regions_mod.REGIONS if code != 66))
assert ratio_mod._OTHER_REGION_CODES == expected
assert 66 not in ratio_mod._OTHER_REGION_CODES
assert 77 in ratio_mod._OTHER_REGION_CODES # Москва — уже в реестре (#3051)
def test_rederive_sql_region_scopes_by_region_code_not_city() -> None:
"""_REDERIVE_SQL_REGION скоупит ОБЕ стороны по region_code, БЕЗ городской квоты ЕКБ.
В отличие от _REDERIVE_SQL (byte-identical historical EKB derivation), генерик-путь
для остальных регионов не несёт `city ILIKE` эта квота была костылём конкретно
ЕКБ-исторического asking-покрытия (#C2), не общим правилом (#3512).
"""
assert "city ILIKE" not in _REDERIVE_SQL_REGION
assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") >= 4
assert "FROM deals" in _REDERIVE_SQL_REGION
assert "FROM listings" in _REDERIVE_SQL_REGION
# Тот же порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации (переиспользуется,
# не изобретается заново — требование задачи).
assert "d.n_deals >= 30" in _REDERIVE_SQL_REGION
assert "a.n_listings >= 30" in _REDERIVE_SQL_REGION
assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL_REGION
# INSERT пишет параметризованный регион, а не литерал 66.
assert "CAST(:region_code AS int) FROM global_row" in _REDERIVE_SQL_REGION
assert "CAST(:region_code AS int) FROM per_bucket" in _REDERIVE_SQL_REGION
def test_delete_sql_region_scopes_by_region_code() -> None:
flat = re.sub(r"\s+", " ", _DELETE_SQL_REGION).strip()
assert "DELETE FROM asking_to_sold_ratios" in flat
assert "WHERE region_code = CAST(:region_code AS int) AND district = ''" in flat
def test_rederive_sql_writes_region_code_66_literal() -> None:
"""_REDERIVE_SQL (ЕКБ-путь) теперь пишет region_code=66 явно в новую колонку."""
assert "region_code" in _REDERIVE_SQL
assert "basis, 66 FROM global_row" in _REDERIVE_SQL
assert "basis, 66 FROM per_bucket" in _REDERIVE_SQL
# Числа для 66 не должны были поменяться: городская квота ЕКБ (_ASKING_CITY_PATTERN)
# осталась нетронутой в обоих deal-CTE и обоих ask-CTE.
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
assert _REDERIVE_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2
def test_task_loops_other_regions_between_ekb_and_counters() -> None:
"""recompute_asking_to_sold_ratios: EKB derive -> other-regions loop -> counters."""
ekb_pos = _TASK_SRC.index("_REDERIVE_SQL,")
loop_pos = _TASK_SRC.index("_OTHER_REGION_CODES")
region_delete_pos = _TASK_SRC.index("_DELETE_SQL_REGION")
region_insert_pos = _TASK_SRC.index("_REDERIVE_SQL_REGION,")
counters_pos = _TASK_SRC.index("_COUNTERS_SQL")
assert ekb_pos < loop_pos < region_delete_pos < region_insert_pos < counters_pos

View file

@ -215,7 +215,8 @@ async def test_backfill_yandex_addresses_error_on_non_200():
return_value=mock_session, return_value=mock_session,
), ),
): ):
result = await backfill_yandex_addresses(MagicMock(), limit=10) # request_delay_sec=0: реальный анти-бот sleep(3с) тут не нужен, 1 item в батче.
result = await backfill_yandex_addresses(MagicMock(), limit=10, request_delay_sec=0)
assert result.errors == 1 assert result.errors == 1
assert result.saved == 0 assert result.saved == 0

View file

@ -0,0 +1,140 @@
"""Tests for per-region asking→sold ratio resolution (#3512).
ПРОБЛЕМА: asking_to_sold_ratios (migration 080) считался ИСКЛЮЧИТЕЛЬНО по ЕКБ
(city ILIKE '%Екатеринбург%') и применялся estimator'ом к ЛЮБОМУ адресу, включая
Москву коэффициент рынка на порядок дешевле домножал московскую asking-цену.
Migration 304 добавила region_code в таблицу; этот файл проверяет, что
_get_asking_sold_ratio (app/services/estimator.py) теперь:
1. Скоупит ОБА SELECT (per-bucket + global -1 fallback) по region_code запроса.
2. Регион 66 без явного region_code прежнее поведение (дефолт DEFAULT_REGION_CODE).
3. Регион без единой строки (ни per-bucket, ни global -1) деградирует ЯВНО:
(None, None), без подмены чужим регионом.
4. Кэш ключуется на (region_code, bucket), не на голый bucket иначе Москва и ЕКБ
смешались бы в одном кэш-слоте.
No DB / network: fake Session (тот же паттерн, что
test_estimator_ratio_savepoint_retry_2265.py). DATABASE_URL нужен только для импорта
app.core.config.Settings.
"""
import os
from contextlib import contextmanager
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services import regions as regions_mod
class _Row:
def __init__(self, ratio: float | None, basis: str | None) -> None:
self.ratio = ratio
self.basis = basis
class _Result:
def __init__(self, row: _Row | None) -> None:
self._row = row
def fetchone(self) -> _Row | None:
return self._row
class _RegionAwareFakeSession:
"""Fake Session: отвечает по params['region_code'], как настоящая БД per-region.
rows: {(region_code, rooms_bucket): _Row} rooms_bucket=-1 для global fallback.
Записывает все (sql_text, params) в self.calls для инспекции bind-параметров.
"""
def __init__(self, rows: dict[tuple[int, int], _Row]) -> None:
self._rows = rows
self.calls: list[dict[str, Any]] = []
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield None
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
params = params or {}
self.calls.append(dict(params))
region_code = params.get("region_code")
bucket = params.get("b", -1)
return _Result(self._rows.get((region_code, bucket)))
def rollback(self) -> None:
pass
def _clear_cache() -> None:
estimator._asking_sold_ratio_cache.clear()
def test_ratio_query_scoped_by_region_code_of_request() -> None:
"""Оба SELECT (per-bucket + global -1) несут region_code запроса как bind-параметр."""
_clear_cache()
db = _RegionAwareFakeSession({(77, 1): _Row(0.95, "per_rooms")})
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
assert ratio == 0.95
assert basis == "per_rooms"
assert len(db.calls) == 1 # per-bucket строка нашлась сразу, global -1 не запрошен
assert db.calls[0]["region_code"] == 77
assert db.calls[0]["b"] == 1
def test_moscow_and_ekb_get_different_ratios_for_same_bucket() -> None:
"""Тот же rooms-бакет (1), два региона — РАЗНЫЕ коэффициенты, не смешиваются."""
_clear_cache()
db = _RegionAwareFakeSession(
{
(66, 1): _Row(0.84, "per_rooms"),
(77, 1): _Row(0.97, "per_rooms"),
}
)
ratio_ekb, basis_ekb = estimator._get_asking_sold_ratio(db, rooms=1, region_code=66) # type: ignore[arg-type]
ratio_msk, basis_msk = estimator._get_asking_sold_ratio(db, rooms=1, region_code=77) # type: ignore[arg-type]
assert (ratio_ekb, basis_ekb) == (0.84, "per_rooms")
assert (ratio_msk, basis_msk) == (0.97, "per_rooms")
assert ratio_ekb != ratio_msk
# Оба закэшированы РАЗДЕЛЬНО — (region_code, bucket) ключ, не голый bucket (#3512).
assert estimator._asking_sold_ratio_cache[(66, 1)][0] == 0.84
assert estimator._asking_sold_ratio_cache[(77, 1)][0] == 0.97
def test_default_region_code_is_ekb_previous_behaviour_preserved() -> None:
"""Вызов без region_code (легаси call-site) — дефолт 66, прежнее поведение."""
_clear_cache()
assert regions_mod.DEFAULT_REGION_CODE == 66
db = _RegionAwareFakeSession({(66, 2): _Row(0.81, "global_fallback")})
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2) # type: ignore[arg-type]
assert (ratio, basis) == (0.81, "global_fallback")
assert db.calls[0]["region_code"] == 66
def test_region_without_any_row_degrades_explicit_not_silent() -> None:
"""Регион без per-bucket И без global -1 строки → (None, None), не чужой регион.
Falsifiable: до фикса резолвер не нёс region_code вообще, и запрос без фильтра
подхватил бы ЕКБ-глобальную строку молча. Здесь region_code=50 (Московская обл.)
не имеет НИ ОДНОЙ строки в rows обе попытки (per-bucket + global -1) промахиваются.
"""
_clear_cache()
db = _RegionAwareFakeSession({(66, 3): _Row(0.9, "per_rooms")}) # только ЕКБ
ratio, basis = estimator._get_asking_sold_ratio(db, rooms=3, region_code=50) # type: ignore[arg-type]
assert (ratio, basis) == (None, None)
# Обе попытки (per-bucket=3, global=-1) реально ушли под region_code=50 —
# НЕ переключились молча на ЕКБ-данные, которые в rows есть.
assert len(db.calls) == 2
assert db.calls[0]["region_code"] == 50
assert db.calls[1]["region_code"] == 50
# Стабильный факт БД — кэшируется как обычный None-результат (см. #2175 идиома).
assert estimator._asking_sold_ratio_cache[(50, 3)][0] is None

View file

@ -71,7 +71,8 @@ def test_ratio_retry_recovers_after_first_lookup_fails() -> None:
assert basis == "per_rooms" assert basis == "per_rooms"
assert db.begin_nested_count == 2 # первый заход упал, второй прошёл assert db.begin_nested_count == 2 # первый заход упал, второй прошёл
assert db.rollback_count >= 1 # чистка tx перед retry assert db.rollback_count >= 1 # чистка tx перед retry
assert 1 in estimator._asking_sold_ratio_cache # успех кэшируется # #3512: ключ кэша (region_code, bucket); region_code не передан → дефолт 66.
assert (66, 1) in estimator._asking_sold_ratio_cache # успех кэшируется
def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None: def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
@ -82,7 +83,8 @@ def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
assert ratio is None assert ratio is None
assert basis is None assert basis is None
assert 2 not in estimator._asking_sold_ratio_cache # транзиент НЕ отравляет кэш (#2175) # транзиент НЕ отравляет кэш (#2175); ключ (region_code, bucket) — #3512.
assert (66, 2) not in estimator._asking_sold_ratio_cache
assert db.begin_nested_count == 2 assert db.begin_nested_count == 2
assert db.rollback_count >= 2 assert db.rollback_count >= 2
@ -97,7 +99,7 @@ def test_ratio_happy_path_single_attempt_no_rollback() -> None:
assert basis == "global_fallback" assert basis == "global_fallback"
assert db.begin_nested_count == 1 # ни одного retry assert db.begin_nested_count == 1 # ни одного retry
assert db.rollback_count == 0 assert db.rollback_count == 0
cached = estimator._asking_sold_ratio_cache.get(0) cached = estimator._asking_sold_ratio_cache.get((66, 0))
assert cached is not None assert cached is not None
assert cached[0] == 0.9 assert cached[0] == 0.9
assert cached[1] == "global_fallback" assert cached[1] == "global_fallback"

View file

@ -107,9 +107,23 @@ def _run_estimate_with_ratio_spy(
ratio_calls: list[Any] = [] ratio_calls: list[Any] = []
def _spy_ratio(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> Any: def _spy_ratio(
db_inner: Any,
rooms: Any,
area_m2: Any = None,
anchor_ppm2: Any = None,
region_code: Any = None,
) -> Any:
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
ratio_calls.append({"rooms": rooms, "area_m2": area_m2, "anchor_ppm2": anchor_ppm2}) # #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
ratio_calls.append(
{
"rooms": rooms,
"area_m2": area_m2,
"anchor_ppm2": anchor_ppm2,
"region_code": region_code,
}
)
return ratio_return return ratio_return
async def _run() -> Any: async def _run() -> Any:
@ -169,8 +183,15 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None:
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k). # _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
captured_anchor_ppm2: list[float | None] = [] captured_anchor_ppm2: list[float | None] = []
def _spy(db_inner: Any, rooms: Any, area_m2: Any = None, anchor_ppm2: Any = None) -> tuple: def _spy(
db_inner: Any,
rooms: Any,
area_m2: Any = None,
anchor_ppm2: Any = None,
region_code: Any = None,
) -> tuple:
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2. # #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
captured_anchor_ppm2.append(anchor_ppm2) captured_anchor_ppm2.append(anchor_ppm2)
return (0.78, "per_rooms") return (0.78, "per_rooms")

View file

@ -156,7 +156,7 @@ def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> No
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5 assert counters.read == 5
assert counters.selected == 2 assert counters.selected == 2
assert counters.skipped_not_moscow == 1 assert counters.skipped_not_target_region == 1
assert counters.skipped_artifact == 1 assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1 assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" assert counters.check(), "счётчики обязаны сходиться с прочитанным"
@ -331,7 +331,7 @@ def test_geocode_lets_avito_through_without_allow_unfiltered(
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True _FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
) )
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_oblast == 1 assert counters.skipped_geo_other_region == 1
assert counters.skipped_ungeocoded == 1 assert counters.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" assert counters.check(), "счётчики обязаны сходиться с прочитанным"
@ -444,7 +444,7 @@ def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any
] ]
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10) counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_not_moscow == 1 assert counters.skipped_not_target_region == 1
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None: def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
@ -480,6 +480,261 @@ def test_domclick_non_moscow_is_rejected() -> None:
assert not mod.is_moscow_domclick_address(None) assert not mod.is_moscow_domclick_address(None)
def test_domclick_oblast_is_recognised_without_geocode() -> None:
"""Регион 50 у ДомКлика — первый компонент адреса «Московская область»."""
assert mod.is_oblast_domclick_address("Московская область, Химки, 9 Мая, 21к3")
assert mod.is_oblast_domclick_address(
"Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28"
)
assert not mod.is_oblast_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
assert not mod.is_oblast_domclick_address("")
assert not mod.is_oblast_domclick_address(None)
def test_domclick_oblast_without_prefix_is_saved_by_its_coordinates() -> None:
"""Живой случай: областная карточка БЕЗ префикса «Московская область».
В собранном корпусе 12.09.2026 такая ровно одна на 2 961 Можайск, и
строгий префикс выбросил бы её молча. Координата внутри bbox области
вторая опора фильтра, а не замена первой.
"""
mozhaysk = _payload(
source="domclick",
address="Можайский муниципальный округ, д. Семёновское, 1",
lat=55.511625,
lon=35.829308,
)
assert mod.is_oblast_domclick_payload(mozhaysk)
# Строки вместо чисел (JSON сырья бывает и таким) читаются так же.
assert mod.is_oblast_domclick_payload({**mozhaysk, "lat": "55.511625", "lon": "35.829308"})
def test_domclick_oblast_geo_branch_cannot_swallow_moscow() -> None:
"""bbox Москвы вложен в областной — отказ по адресу обязан идти РАНЬШЕ гео."""
moscow = _payload(
source="domclick",
address="Москва, улица Нижние Мнёвники, 16 к2",
lat=55.7558,
lon=37.6173,
)
assert not mod.is_oblast_domclick_payload(moscow)
def test_domclick_oblast_geo_branch_needs_a_usable_point() -> None:
"""Нет префикса и нечего проверить координатой → карточка НЕ область."""
bare = _payload(source="domclick", address="Можайский муниципальный округ, д. Семёновское, 1")
bare.pop("lat", None)
bare.pop("lon", None)
assert not mod.is_oblast_domclick_payload(bare)
assert not mod.is_oblast_domclick_payload({**bare, "lat": None, "lon": None})
assert not mod.is_oblast_domclick_payload({**bare, "lat": "н/д", "lon": "н/д"})
# Координата за пределами области (Екатеринбург) тоже не проходит.
assert not mod.is_oblast_domclick_payload({**bare, "lat": 56.8300, "lon": 60.6000})
def test_domclick_view_is_wired_into_source_views() -> None: def test_domclick_view_is_wired_into_source_views() -> None:
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address filt77 = mod.REGION_FILTERS[("domclick", 77)]
assert filt77(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
assert not filt77(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
filt50 = mod.REGION_FILTERS[("domclick", 50)]
assert filt50 is mod.is_oblast_domclick_payload
assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
# ── регион 50 (Московская область) ───────────────────────────────────────────
def test_cian_subdomain_selects_oblast_regardless_of_okrug(
captured: list[dict[str, Any]],
) -> None:
"""khimki.cian.ru → область при --region 50, даже если адрес без округа."""
rows = [
{
"id": 1,
"payload": _payload(
source_id="khimki-1",
source_url="https://khimki.cian.ru/sale/flat/1/",
address="городской округ Химки, Олимпийский проспект, 29",
),
}
]
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
assert counters_77.selected == 0
def test_cian_www_with_okrug_stays_moscow_only(captured: list[dict[str, Any]]) -> None:
"""www.cian.ru + префикс округа — Москва (77), не область (50)."""
rows = [
{
"id": 1,
"payload": _payload(
source_id="www-1",
source_url="https://www.cian.ru/sale/flat/330200428/",
address="ЦАО, р-н Арбат, улица Арбат, 10",
),
}
]
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
assert counters_77.selected == 1
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert counters_50.selected == 0
def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _yandex(
source_id="oblast-1",
address="Россия, Московская область, Химки, Ивановская улица, 5",
),
}
]
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=50, batch_size=10)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=77, batch_size=10)
assert counters_77.selected == 0
def test_domclick_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
"""Областной прогон сборщика (батч mo-serp-domclick-20260912) отбирается по region=50."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-1",
address="Московская область, Химки, проспект Мельникова, 33",
),
}
]
counters_50 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=50, batch_size=10
)
assert counters_50.selected == 1
counters_77 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=77, batch_size=10
)
assert counters_77.selected == 0
def test_domclick_moscow_region_77_is_selected(captured: list[dict[str, Any]]) -> None:
"""Московский прогон (батч msk-serp-domclick-20260912) отбирается по region=77."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="moscow-1",
address="Москва, Генерала Дорохова проспект, 49",
),
}
]
counters_77 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=77, batch_size=10
)
assert counters_77.selected == 1
counters_50 = mod.import_msk_raw(
_FakeSession(rows), source="domclick", region=50, batch_size=10
)
assert counters_50.selected == 0
def test_domclick_writer_gets_region_50_and_city_none(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-2",
address="Московская область, Химки, проспект Мельникова, 33",
),
}
]
mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
assert captured[0]["region_code"] == 50
assert captured[0]["city"] is None
def test_domclick_mixed_batches_are_told_apart_by_address(captured: list[dict[str, Any]]) -> None:
"""Вью отдаёт московский и областной прогоны вперемешку — региональный фильтр
обязан развести их сам, курсор по `id` тут не помощник."""
rows = [
{
"id": 1,
"payload": _payload(
source="domclick",
source_id="oblast-3",
address="Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28",
),
},
{
"id": 2,
"payload": _payload(
source="domclick",
source_id="moscow-2",
address="Москва, Генерала Дорохова проспект, 49",
),
},
]
counters = mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["oblast-3"]
assert counters.selected == 1
assert counters.skipped_not_target_region == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None:
rows = [
{
"id": 1,
"payload": _payload(
source_id="khimki-1",
source_url="https://khimki.cian.ru/sale/flat/1/",
address="городской округ Химки, Олимпийский проспект, 29",
),
}
]
mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
assert captured[0]["region_code"] == 50
assert captured[0]["city"] is None
def test_moscow_region_77_numbers_unchanged(captured: list[dict[str, Any]]) -> None:
"""Анти-регресс: те же фикстуры, тот же region=77 → те же прежние числа."""
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=10, region=77)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5
assert counters.selected == 2
assert counters.skipped_not_target_region == 1
assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1
assert captured[0]["region_code"] == 77
assert captured[0]["city"] == "Москва"
def test_cian_subdomain_ignores_port_and_case() -> None:
"""Поддомен читается из `hostname`, а не из `netloc`: порт и регистр хоста
не должны превращать московскую карточку в «не целевой регион», а областную
в московскую. Хост не `*.cian.ru` (включая голый `cian.ru` и похожий
`fake-cian.ru`) остаётся None консервативно, ложной области не даёт."""
assert mod.cian_subdomain({"source_url": "https://WWW.CIAN.RU:443/sale/flat/1/"}) == "www"
assert (
mod.cian_subdomain({"source_url": "https://khimki.cian.ru:8443/sale/flat/1/"}) == "khimki"
)
assert mod.is_cian_oblast_payload({"source_url": "https://khimki.cian.ru:8443/x"}) is True
assert mod.is_cian_oblast_payload({"source_url": "https://WWW.CIAN.RU:443/x"}) is False
for bad in ("https://cian.ru/sale/flat/1/", "https://fake-cian.ru/x", "", None, 42):
assert mod.cian_subdomain({"source_url": bad}) is None
assert mod.cian_subdomain({}) is None

View file

@ -539,16 +539,24 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu
) )
def test_moscow_oblast_ref_area_is_loaded_but_not_yet_used_by_estimator() -> None: def test_moscow_oblast_ref_area_feeds_the_estimator_under_the_same_name() -> None:
"""Область грузится заранее: ряд наполняется месяцами, задним числом не берётся. """Имя ряда у загрузчика и у эстиматора обязано совпадать буква в букву.
Одновременно проверяем, что заведение ряда НЕ включает область в оценку и НЕ Загрузчик завёл REF_AREA=50 раньше (#3498), когда карта эстиматора кода 50
поднимает тревогу монитора свежести: обе величины считаются от карты ещё не знала и область считалась по «России». 12.09.2026 ряд приехал на прод
эстиматора (_SBER_REGION_SERIES), а не от карты загрузчика. (real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08), и код 50 попал
в `_SBER_REGION_SERIES`. С этого момента расхождение имён перестаёт быть
безобидным: эстиматор молча читал бы пустой ряд, а монитор свежести требовал
бы ряд, которого загрузчик не пишет. Тест сцепляет обе стороны по имени.
""" """
from app.services.estimator import SBER_MONITORED_REGIONS, SBER_REQUIRED_REGIONS from app.services.estimator import (
SBER_MONITORED_REGIONS,
SBER_REQUIRED_REGIONS,
sber_region_series_name,
)
from app.services.sber_index import SBER_REF_AREAS from app.services.sber_index import SBER_REF_AREAS
assert SBER_REF_AREAS["50"] == "Московская область" assert SBER_REF_AREAS["50"] == "Московская область"
assert "Московская область" not in SBER_REQUIRED_REGIONS assert sber_region_series_name(50) == SBER_REF_AREAS["50"]
assert "Московская область" not in SBER_MONITORED_REGIONS assert "Московская область" in SBER_REQUIRED_REGIONS
assert "Московская область" in SBER_MONITORED_REGIONS

View file

@ -88,6 +88,21 @@ def test_series_names_agree_with_region_registry() -> None:
assert regions_mod.REGIONS[code].name == name assert regions_mod.REGIONS[code].name == name
def test_resolver_maps_50_to_moscow_oblast_not_russia() -> None:
"""Область получает СВОЙ ряд, а не общероссийский фолбэк.
Загрузчик научился тянуть REF_AREA=50 (#3498), и 12.09.2026 ряд приехал на
прод: real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 та же
глубина, что у Москвы и обл.66. Пока кода 50 в карте не было, коридор
области считался по «России», то есть ошибался на всё расхождение области
со страной. Тест красный на прежней карте ПО ЗНАЧЕНИЮ: там вернулась бы
«Россия».
"""
assert est.sber_region_series_name(50) == "Московская область"
assert est.sber_region_series_name(50) != est.SBER_TIME_ADJUST_FALLBACK_REGION
assert "Московская область" in est.SBER_REQUIRED_REGIONS
def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None: def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None:
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | { assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
est.SBER_TIME_ADJUST_FALLBACK_REGION est.SBER_TIME_ADJUST_FALLBACK_REGION
@ -275,8 +290,15 @@ def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatc
def _without_moscow() -> _MonDB: def _without_moscow() -> _MonDB:
"""Источник переименовал «Москва» → ряда с прежним именем больше нет.""" """Источник переименовал «Москва» → ряда с прежним именем больше нет.
return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026})
Остальные обязательные ряды на месте иначе тесты ниже считали бы пропажу
Москвы вместе с чужими пропажами. Строятся от карты эстиматора, чтобы новый
регион в ней не ронял эти тесты по счётчику (ровно так они и покраснели,
когда в карту приехала область).
"""
present = {r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS if r != "Москва"}
return _MonDB(present)
def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict( def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict(
@ -352,7 +374,7 @@ def test_missing_fallback_series_does_not_fail_the_monitor(
) -> None: ) -> None:
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой.""" """«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
calls = _patch_runs(monkeypatch) calls = _patch_runs(monkeypatch)
db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026}) db = _MonDB({r: _JUN_2026 for r in est.SBER_REQUIRED_REGIONS})
out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type] out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["failed"] is None assert calls["failed"] is None
assert out["regions_missing"] == 1 assert out["regions_missing"] == 1
@ -457,15 +479,17 @@ def test_duplicate_series_name_does_not_pin_regions_missing(
def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None: def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули. """КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского, Пропажа ВСЕХ обязательных рядов была неотличима от пропажи одного
а найденный ряд не попадал в age_days_max вовсе. свердловского, а найденный ряд не попадал в age_days_max вовсе. Счётчик
сверяем с длиной карты, а не с литералом: новый регион в ней не повод
этому тесту краснеть.
""" """
calls = _patch_runs(monkeypatch) calls = _patch_runs(monkeypatch)
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type] out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None assert calls["done"] is None
assert calls["failed"] is not None assert calls["failed"] is not None
assert out["regions_missing"] == 2 assert out["regions_missing"] == len(est.SBER_REQUIRED_REGIONS)
assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0 assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0
assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
# прежняя семантика раннего выхода не тронута # прежняя семантика раннего выхода не тронута
@ -492,9 +516,7 @@ class _MonDBDriverFault(_MonDB):
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
if self._deactivated: if self._deactivated:
raise PendingRollbackError( raise PendingRollbackError("Can't reconnect until invalid transaction is rolled back")
"Can't reconnect until invalid transaction is rolled back"
)
params = params or {} params = params or {}
if params.get("city") == self._broken: if params.get("city") == self._broken:
self._deactivated = True self._deactivated = True

View file

@ -25,10 +25,12 @@ import os
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import scheduler as kit_sched from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.pipeline import CITY_ANCHORS from scraper_kit.orchestration.pipeline import CITY_ANCHORS, DEFAULT_REGION_CODE
from scraper_kit.orchestration.scheduler import ( from scraper_kit.orchestration.scheduler import (
Handler, Handler,
SchedulerContext, SchedulerContext,
@ -38,7 +40,10 @@ from scraper_kit.orchestration.scheduler import (
_job_avito_full_load, _job_avito_full_load,
_job_avito_full_load_exhaustive, _job_avito_full_load_exhaustive,
_job_cian_city_sweep, _job_cian_city_sweep,
_job_cian_full_load,
_job_domclick_city_sweep,
_job_yandex_city_sweep, _job_yandex_city_sweep,
_resolve_region_code,
build_registry, build_registry,
reap_zombies, reap_zombies,
reschedule_after_minutes, reschedule_after_minutes,
@ -623,3 +628,101 @@ def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry. # EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"] assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"] assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
# ── region_code: default_params → run_*-функции pipeline.py ─────────────────────────
#
# Планировщик кита раньше НЕ читал default_params["region_code"] ни разу — все sweep'ы
# писали листings регионом 66 (DEFAULT_REGION_CODE) независимо от того, что лежит в
# scrape_schedules.default_params. Это единственная причина, по которой московский обход
# нельзя было завести конфигом — pipeline.py уже принимает region_code параметром,
# scheduler.py его просто не прокидывал. Ниже: (1) отсутствие ключа — БЕЗ изменений
# поведения для всех существующих строк расписания (default 66); (2) валидный код (77)
# доезжает до run_*; (3) мусорное значение — явный отказ ДО сетевого похода, а не тихая
# запись в listings.region_code.
def test_resolve_region_code_missing_key_defaults_to_66() -> None:
"""Нет ключа в default_params → DEFAULT_REGION_CODE (66) — прежнее поведение всех
123 существующих строк расписания не меняется."""
assert _resolve_region_code({}) == DEFAULT_REGION_CODE == 66
def test_resolve_region_code_known_value_passes_through() -> None:
assert _resolve_region_code({"region_code": 77}) == 77
def test_resolve_region_code_string_digit_coerces_to_int() -> None:
"""jsonb default_params может прислать региона строкой — тот же int(...), что и
у остальных числовых параметров этого файла (params.get("pages", ...) и т.п.)."""
assert _resolve_region_code({"region_code": "77"}) == 77
def test_resolve_region_code_unknown_value_raises() -> None:
"""Регион вне реестра — явный ValueError, а не тихая запись мусора в listings."""
with pytest.raises(ValueError, match="реестре регионов"):
_resolve_region_code({"region_code": 999})
def test_resolve_region_code_non_numeric_raises() -> None:
with pytest.raises(ValueError, match="не приводится к int"):
_resolve_region_code({"region_code": "москва"})
async def test_job_avito_city_sweep_region_code_absent_defaults() -> None:
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == DEFAULT_REGION_CODE
async def test_job_avito_city_sweep_region_code_from_params() -> None:
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == 77
async def test_job_avito_city_sweep_region_code_garbage_raises_before_run() -> None:
"""Мусорный region_code роняет job ДО вызова run_* — сеть/скрапинг не трогаются."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
with pytest.raises(ValueError):
await _job_avito_city_sweep(MagicMock(), 1, {"region_code": 12345}, _oblast_ctx())
mock_run.assert_not_awaited()
async def test_job_avito_full_load_region_code_from_params() -> None:
with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run:
await _job_avito_full_load(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == 77
async def test_job_domclick_city_sweep_region_code_absent_defaults() -> None:
"""Регионный проброс не должен ломать уже читаемый city_id (тот же params dict)."""
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
await _job_domclick_city_sweep(MagicMock(), 1, {"city_id": 9}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == DEFAULT_REGION_CODE
assert kwargs["city_id"] == 9
async def test_job_domclick_city_sweep_region_code_from_params() -> None:
with patch.object(kit_sched, "run_domclick_city_sweep", AsyncMock()) as mock_run:
await _job_domclick_city_sweep(MagicMock(), 1, {"region_code": 77}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == 77
async def test_job_cian_full_load_region_code_absent_defaults() -> None:
with patch.object(kit_sched, "run_cian_full_load", AsyncMock()) as mock_run:
await _job_cian_full_load(MagicMock(), 1, {}, _oblast_ctx())
_args, kwargs = mock_run.call_args
assert kwargs["region_code"] == DEFAULT_REGION_CODE

View file

@ -330,6 +330,8 @@ describe("гейт витринных чисел (#2904)", () => {
"estimate/page.tsx", "estimate/page.tsx",
"articles/page.tsx", "articles/page.tsx",
"articles/kak-ocenit-kvartiru/page.tsx", "articles/kak-ocenit-kvartiru/page.tsx",
"articles/skolko-stoit-metr-v-ekaterinburge/page.tsx",
"articles/pochemu-kvartira-ne-prodaetsya/page.tsx",
"docs/page.tsx", "docs/page.tsx",
"business/page.tsx", "business/page.tsx",
]; ];

View file

@ -97,6 +97,41 @@ describe("витрина лэндинга v3 без данных", () => {
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy(); expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
}); });
/**
* ДАТА ПРОГОНА НА СТРАНИЦЕ, РЯДОМ СО СЧЁТЧИКАМИ (#3469).
*
* Счётчики («рассмотрено 200, показано 20») не стареют на вид, а строки под
* ними стареют: на проде задачи не было в расписании, и витрина тринадцать
* суток показывала прогон от 30.08 неотличимо от вчерашнего. Проверяется
* ПО ЗНАЧЕНИЮ: дата берётся из `computed_at` фикстуры, вписать её в разметку
* руками и остаться зелёным нельзя второй кейс отдаёт другую дату.
*/
it("«Точность»: под таблицей стоит дата пересчёта витрины, и она из данных", () => {
const { unmount } = render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText(/Витрина пересчитана 29\.08\.2026/u)).toBeTruthy();
unmount();
render(
<AccuracyV3
stats={STATS}
showcase={{ ...SHOWCASE, computed_at: "2026-09-12T06:12:03+00:00" }}
/>,
);
expect(screen.getByText(/Витрина пересчитана 12\.09\.2026/u)).toBeTruthy();
});
/**
* Даты нет нет и предложения про неё: то же правило «нет величины нет
* подписи», что у всего блока. «Invalid Date» или «null» на публичной
* странице хуже отсутствия даты, а «сегодня» на её месте враньё.
*/
it("«Точность»: без computed_at подпись не выдумывает дату", () => {
render(<AccuracyV3 stats={STATS} showcase={{ ...SHOWCASE, computed_at: null }} />);
const note = screen.getByText(/рассмотрено сделок: 4 000/u);
expect(note.textContent).not.toMatch(/Витрина пересчитана/u);
expect(note.textContent).not.toMatch(/Invalid Date|null|undefined/u);
});
/** /**
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял * Снятая величина не должна остаться на экране «по инерции»: её плитку занял
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы * свежий замер, и оговорка про уверенность без своей плитки объясняла бы
@ -136,7 +171,7 @@ describe("витрина лэндинга v3 без данных", () => {
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ. * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
* *
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона * Границы стоят в коде фронта, строки приходят из БД от последнего прогона
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом * задачи, а такт пересчёта сутки (#3469). Между выкатом фронта и пересчётом
* витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы, * витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
* худшая +75,71 %. Утверждение «показана полоса 5+20 %» и строка +75,7 % * худшая +75,71 %. Утверждение «показана полоса 5+20 %» и строка +75,7 %
* под ним хуже, чем отсутствие утверждения. * под ним хуже, чем отсутствие утверждения.

View file

@ -30,10 +30,11 @@
* обещала бы точность, которой никто не мерил. * обещала бы точность, которой никто не мерил.
* *
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит * приходят из БД от последнего прогона задачи, и прогон отстаёт от выката:
* её запускают руками. В окне «фронт выкачен, витрина не пересчитана» * с #3469 задача стоит в расписании (суточный такт), но между деплоем фронта
* страница утверждала бы полосу над строками прежнего правила, а под * и ближайшим ночным пересчётом окно всё равно есть. В нём страница утверждала
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20). * бы полосу над строками прежнего правила, а под утверждением стояла бы
* строка +75,7 % (так на проде и было 12 таких из 20).
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`): * Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
* не соответствуют про полосу не говорим, называем то, что есть, а правило * не соответствуют про полосу не говорим, называем то, что есть, а правило
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки. * прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
@ -87,6 +88,7 @@ import {
dealTitle, dealTitle,
errPct, errPct,
rub, rub,
runDate,
shownSpread, shownSpread,
} from "./deal-view"; } from "./deal-view";
@ -174,6 +176,9 @@ export function AccuracyV3({
const deals = showcase?.deals ?? []; const deals = showcase?.deals ?? [];
const showcaseStats = showcase?.stats ?? null; const showcaseStats = showcase?.stats ?? null;
const spread = shownSpread(deals); const spread = shownSpread(deals);
// Дата прогона, давшего эти строки. Стоит в той же подписи, что и счётчики:
// «рассмотрено 200, показано 20» не стареет на вид, а строки — стареют.
const computedOn = runDate(showcase?.computed_at ?? null);
return ( return (
<section <section
@ -271,16 +276,26 @@ export function AccuracyV3({
(`rejection_rule`) приходит из того же прогона и стоит в этой же (`rejection_rule`) приходит из того же прогона и стоит в этой же
подписи счётчики и правило порознь не показываются. подписи счётчики и правило порознь не показываются.
*/} */}
{/*
ДАТА ПРОГОНА СТОИТ РЯДОМ СО СЧЁТЧИКАМИ (#3469). Счётчики описывают
прогон, но не говорят, КОГДА он был, и замороженная витрина
выглядела ровно так же, как вчера пересчитанная. На проде это и
случилось: задачи не было в расписании, и страница тринадцать
суток показывала строки от 30.08 без единого признака возраста.
Даты нет (ручка отдала null) предложения тоже нет: подставлять
на её место «сегодня» нельзя.
*/}
<p className={styles.accFootnote}> <p className={styles.accFootnote}>
{showcaseStats {showcaseStats
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}` ? `${computedOn ? `Витрина пересчитана ${computedOn}. ` : ""}Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."} : "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p> </p>
{spread && ( {spread && (
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде // УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
// фронта, а строки приходят из БД, из последнего прогона задачи; // фронта, а строки приходят из БД, из последнего прогона задачи;
// задача в расписании не стоит и запускается руками. Между // такт пересчёта — сутки (#3469), то есть строки старше выката
// выкатом и пересчётом страница утверждала бы полосу над // фронта почти всегда. Между выкатом и пересчётом страница
// утверждала бы полосу над
// строками, собранными до неё (на проде сейчас 12 из 20 вне // строками, собранными до неё (на проде сейчас 12 из 20 вне
// полосы, худшая +75,7 %) — утверждение и его опровержение в // полосы, худшая +75,7 %) — утверждение и его опровержение в
// одном экране. Поэтому про полосу говорим, только если ни одна // одном экране. Поэтому про полосу говорим, только если ни одна

View file

@ -29,6 +29,24 @@ export const count = (value: number): string => RUB.format(value);
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */ /** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
export const errPct = (value: number): string => `${PCT.format(value)} %`; export const errPct = (value: number): string => `${PCT.format(value)} %`;
/**
* Дата пересчёта витрины «30.08.2026» из `computed_at`.
*
* РАЗБОР СТРОКИ, А НЕ `new Date().toLocaleDateString()`: подпись рендерит
* серверный компонент, и локальная дата там считалась бы по часовому поясу
* КОНТЕЙНЕРА то есть дата на странице зависела бы от того, где её собрали.
* Дальше того же правила держится и формат: `landing-facts` печатает свои
* даты замеров так же, разбором ISO.
*
* `null` на неразобранном входе та же дисциплина, что у всей витрины: нет
* величины, нет подписи. «Invalid Date» на публичной странице хуже её
* отсутствия.
*/
export const runDate = (iso: string | null): string | null => {
const m = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})/u.exec(iso);
return m === null ? null : `${m[3]}.${m[2]}.${m[1]}`;
};
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */ /** «2-к, 52 м²» — всё, что про объект известно наверняка. */
/** /**
* Заголовок сделки. `rooms = 0` это СТУДИЯ, а не «ноль комнат»: так её * Заголовок сделки. `rooms = 0` это СТУДИЯ, а не «ноль комнат»: так её

View file

@ -45,7 +45,7 @@ export interface ArticleCardMeta {
* второй статьи не может увести её на первую (так и было, пока * второй статьи не может увести её на первую (так и было, пока
* `to="articleHowTo"` стоял константой в JSX). * `to="articleHowTo"` стоял константой в JSX).
*/ */
readonly route: "articleHowTo" | null; readonly route: "articleHowTo" | "articlePrices" | "articleWaiting" | null;
readonly rubric: string; readonly rubric: string;
readonly title: string; readonly title: string;
/** Подводка карточки — дословно из макета. */ /** Подводка карточки — дословно из макета. */
@ -61,16 +61,16 @@ export const ARTICLE_CARDS: readonly ArticleCardMeta[] = [
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.", lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
}, },
{ {
route: null, route: "articlePrices",
rubric: "СРОКИ · 5 МИН", rubric: "ЦЕНЫ СДЕЛОК · 5 МИН",
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление", title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
lead: "Средние сроки по типам квартир и главные причины простоя.", lead: "Медиана по 24 535 сделкам Росреестра и почему метр в двушке самый дешёвый.",
}, },
{ {
route: null, route: "articleWaiting",
rubric: "ТОРГ · 7 МИН", rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН",
title: "Завысить и торговаться или поставить в рынок: что выгоднее", title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
lead: "Считаем на данных, сколько теряет «висящая» квартира каждый месяц.", lead: "Кто снижает цену, на сколько и за какой срок — по 49 620 объявлениям.",
}, },
]; ];
@ -181,3 +181,262 @@ export const HOWTO = {
summary: summary:
"Смотрите на цены сделок, а не объявлений. Берите 514 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.", "Смотрите на цены сделок, а не объявлений. Берите 514 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
} as const; } as const;
/**
* Статья «Сколько стоит метр в Екатеринбурге» (13.09.2026).
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `deals` договоры купли-продажи
* Росреестра, регион 66. Взяты четыре последних доступных квартала
* (`deal_date` 2025-07-01, 2025-10-01, 2026-01-01, 2026-04-01 дата в данных
* штампуется началом квартала, отдельного дня сделки в выгрузке нет), то есть
* июль 2025 июнь 2026. Отсечены цены за метр вне коридора 20 000500 000
* как заведомый мусор выгрузки. По Екатеринбургу осталось 24 535 сделок.
*
* ГРАНИЦА, КОТОРУЮ НАЗЫВАЕМ В ТЕКСТЕ: Росреестр публикует цену договора. Она
* не всегда равна сумме, которую покупатель отдал на руки, и связки со
* сделавшим её объявлением в данных нет `cadastral_number` пуст и у сделок,
* и у объявлений. Поэтому «разрыв между ценой объявления и ценой сделки» в
* статье не считается: считать его нечем.
*
* ПОЧЕМУ ДИНАМИКА ДАНА ПОКВАРТАЛЬНО: в выгрузке ровно десять различных
* значений `deal_date`, все на первое число квартала. Месячного разрешения в
* данных нет вовсе.
*/
export const PRICES = {
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН ЧТЕНИЯ",
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
lead: [
"Когда вы смотрите объявления, вы видите, сколько продавцы хотят получить. Сколько покупатели заплатили на самом деле, видно в другом месте — в договорах купли-продажи, которые проходят через Росреестр. Эти два числа не совпадают, и ориентироваться стоит на второе.",
"Ниже — медианы по реальным сделкам Екатеринбурга и области за последние четыре квартала. Без округлений до красивого и без слова «примерно».",
],
toc: [
{ id: "s1", title: "Сколько стоит метр по сделкам" },
{ id: "s2", title: "Почему метр в двушке самый дешёвый" },
{ id: "s3", title: "Что делала цена последние два с половиной года" },
{ id: "s4", title: "Города области: где метр дешевле" },
{ id: "s5", title: "Пять способов неправильно прочитать эти цифры" },
],
s1: {
id: "s1",
heading: "1. Сколько стоит метр по сделкам",
body: "Медианная цена квадратного метра в сделках по Екатеринбургу — 123 626 ₽. Медиана, а не среднее: среднее в недвижимости всегда тянут вверх несколько дорогих объектов, и к типичной квартире оно отношения не имеет. Четверть сделок прошла дешевле 100 000 ₽ за метр, четверть дороже 151 288 ₽ — вот настоящая ширина рынка, и она больше, чем кажется по объявлениям. Если ваша цена попадает в эту вилку, вы в рынке.",
source:
"ЗАМЕР 13.09.2026 · 24 535 ДОГОВОРОВ КУПЛИ-ПРОДАЖИ РОСРЕЕСТРА ПО ЕКАТЕРИНБУРГУ ЗА ИЮЛЬ 2025 — ИЮНЬ 2026 · ЭТО ЦЕНА ДОГОВОРА, А НЕ ЦЕНА ОБЪЯВЛЕНИЯ",
memo: "Цена объявления и цена договора — разные величины. Насколько именно разные, не скажет никто: Росреестр публикует сделки без привязки к объявлению, сопоставить одно с другим нечем.",
},
s2: {
id: "s2",
heading: "2. Почему метр в двушке самый дешёвый",
body: "«Чем больше квартира, тем дешевле метр» — самое живучее заблуждение о ценах. По сделкам кривая не падает, а прогибается: дешевле всего метр в двухкомнатной, а дальше он снова дорожает.",
table: {
head: ["КОМНАТНОСТЬ", "МЕДИАНА ЗА МЕТР"],
rows: [
{ name: "Студия · медиана 25 м² · 2 967 сделок", value: "134 507 ₽" },
{ name: "Однокомнатная · 38 м² · 8 342 сделки", value: "124 256 ₽" },
{ name: "Двухкомнатная · 51 м² · 6 548 сделок", value: "116 910 ₽" },
{ name: "Трёхкомнатная · 70 м² · 3 861 сделка", value: "121 835 ₽" },
{
name: "Четыре комнаты и больше · 108 м² · 2 817 сделок",
value: "132 122 ₽",
},
],
},
footnote:
"ТОТ ЖЕ ЗАМЕР · РЯДОМ С КАЖДОЙ СТРОКОЙ — МЕДИАННАЯ ПЛОЩАДЬ И ЧИСЛО СДЕЛОК, НА КОТОРЫХ ПОСЧИТАНА МЕДИАНА",
after:
"Объяснение простое: у студий и у больших квартир метр продаётся вместе с чем-то ещё. У студии — с низким порогом входа, её берут те, кому важна сумма целиком, а не цена метра. У четырёхкомнатной — с домом и районом, потому что такие квартиры почти не строят в дешёвых сериях. Двушка же самый массовый и самый конкурентный товар на рынке, и цена метра в ней прижата плотнее всего.",
},
s3: {
id: "s3",
heading: "3. Что делала цена последние два с половиной года",
body: "Рынок Екатеринбурга не падал ни одного квартала из десяти доступных. Медиана росла непрерывно, но по-разному: в 2024 году прибавка держалась около полутора процентов за квартал, в 2025-м и 2026-м ускорилась. Для продавца отсюда следует практический вывод: цена, посчитанная по сделкам полугодовой давности, сегодня занижена.",
tiles: [
{ value: "106 727 ₽", label: "метр в первом квартале 2024 года" },
{ value: "128 756 ₽", label: "метр во втором квартале 2026 года" },
{ value: "+20,6%", label: "рост медианы за два с половиной года" },
],
source:
"ТОТ ЖЕ ИСТОЧНИК, ВСЕ ДЕСЯТЬ ДОСТУПНЫХ КВАРТАЛОВ · ДАТА СДЕЛКИ В ВЫГРУЗКЕ РОСРЕЕСТРА ШТАМПУЕТСЯ НАЧАЛОМ КВАРТАЛА, МЕСЯЧНОГО РАЗРЕШЕНИЯ В ДАННЫХ НЕТ",
},
s4: {
id: "s4",
heading: "4. Города области: где метр дешевле",
body: "Внутри Свердловской области разброс больше, чем между комнатностями. Города-спутники Екатеринбурга идут почти по областной столице, дальние промышленные города — втрое дешевле.",
kv: [
{ k: "ВЕРХНЯЯ ПЫШМА", v: "102 888 ₽ за метр · 1 193 сделки" },
{ k: "ЗАРЕЧНЫЙ", v: "95 865 ₽ · 393 сделки" },
{ k: "БЕРЁЗОВСКИЙ", v: "95 745 ₽ · 858 сделок" },
{ k: "ПЕРВОУРАЛЬСК", v: "66 384 ₽ · 1 458 сделок" },
{ k: "КАМЕНСК-УРАЛЬСКИЙ", v: "63 763 ₽ · 1 994 сделки" },
{ k: "НИЖНИЙ ТАГИЛ", v: "62 637 ₽ · 3 799 сделок" },
{ k: "АСБЕСТ", v: "46 429 ₽ · 642 сделки" },
{ k: "НОВОУРАЛЬСК", v: "40 527 ₽ · 816 сделок" },
],
footnote:
"ТОТ ЖЕ ПЕРИОД · ПОКАЗАНЫ ГОРОДА, ГДЕ ЗА ГОД НАБРАЛОСЬ НЕ МЕНЬШЕ СТА СДЕЛОК · ГДЕ ИХ МЕНЬШЕ, МЕДИАНА НЕУСТОЙЧИВА И МЫ ЕЁ НЕ ПУБЛИКУЕМ",
},
cta: {
eyebrow: "МЕДИАНА ПО ГОРОДУ — ЭТО ЕЩЁ НЕ ВАША ЦЕНА",
title: "МЕРА считает по вашему дому, а не по городу",
body: `Между двумя домами в одном районе разница бывает больше, чем между районами. Мы находим аналоги вокруг вашего адреса, считаем медиану по ним и показываем, на чём она построена. Рынок вокруг — бесплатно, ваша цена и список аналогов — за ${SERVICE_PRICE_RUB} ₽.`,
},
s5: {
id: "s5",
heading: "5. Пять способов неправильно прочитать эти цифры",
mistakes: [
{
title: "Умножить медиану города на свою площадь",
body: "Получится цена абстрактной квартиры в абстрактном районе. Медиана по городу — ориентир порядка величины, а не оценка объекта.",
},
{
title: "Решить, что раз метр в двушке дешевле, двушку продавать невыгодно",
body: "Дешевле метр, а не квартира. Двушка всё равно стоит дороже однушки: 6 015 000 ₽ против 4 650 000 ₽ по медиане сделок.",
},
{
title: "Считать цену договора суммой на руки",
body: "Росреестр публикует то, что написано в договоре. Совпадает ли это с фактическим расчётом между сторонами, из данных не видно.",
},
{
title: "Брать медиану полугодовой давности",
body: "За последние четыре квартала медиана прибавляла около двух процентов за квартал. Полгода отставания — это несколько процентов мимо вашей цены.",
},
{
title: "Сравнивать свою квартиру с городом, а не с соседями",
body: "Между квартилями Екатеринбурга разброс от 100 000 до 151 288 ₽ за метр. Где внутри этой вилки стоит ваш дом, медиана города не скажет.",
},
],
},
summary:
"Медианный метр в Екатеринбурге стоит 123 626 ₽ по 24 535 сделкам за год. Дешевле всего метр в двухкомнатной, дороже всего в студии. Рынок рос все десять доступных кварталов и прибавил 20,6% с начала 2024 года. Города области идут от 40 527 ₽ в Новоуральске до 102 888 ₽ в Верхней Пышме. Всё это ориентиры порядка, а не оценка вашей квартиры: её считают по соседним домам.",
} as const;
/**
* Статья «Почему квартира не продаётся» (13.09.2026).
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. Таблица `offer_price_history` история цены
* объявления, которую ведут сами площадки (cian, yandex, domklik, avito),
* склеенная с `listings` по региону 66. Срез 13.09.2026:
*
* 49 620 объявлений области, у которых есть хоть одна запись о цене
* 21 608 из них цена менялась хотя бы раз (43,5%)
* 13 009 итоговая цена ниже стартовой (60,2% от менявших)
* 8 212 итоговая цена ВЫШЕ стартовой (38,0% от менявших)
*
* Медиана по снизившим: 3,85%, 291 000 , 30 дней между первой и последней
* записью о цене.
*
* ГРАНИЦЫ, КОТОРЫЕ НАЗЫВАЕМ В ТЕКСТЕ. (1) Это движение ЗАПРАШИВАЕМОЙ цены, а
* не скидка на сделке: связки объявления со сделкой в данных нет. (2) Знаменатель
* 21 608 объявления, где цена двигалась; те, у кого запись одна, в долю не
* входят, потому что у них неподвижная цена и отсутствие наблюдения
* неразличимы. (3) «30 дней» срок между первой и последней записью о цене, а
* не срок продажи. Срока продажи в данных нет вовсе: поле `days_on_market` в
* сделках Росреестра пусто у всех 435 тысяч записей, а исчезновение объявления
* означает только исчезновение из выдачи, но не продажу.
*
* ПОЧЕМУ В СТАТЬЕ НЕТ СРЕДНЕГО СРОКА ПРОДАЖИ. Он не измеряется. Снятие
* объявления и продажа в наших данных неразличимы, а даты «снятий» частично
* совпадают с провалами собственного обхода то есть измеряли бы мы свой
* сборщик, а не рынок.
*/
export const WAITING = {
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН ЧТЕНИЯ",
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
lead: [
"Квартира висит третий месяц, звонков нет, и первый совет от всех вокруг — «подожди, твой покупатель найдётся». Иногда это правда. Чаще ожидание заканчивается тем же, чем закончилось у большинства: продавец снижает цену, только на несколько месяцев позже и на большую сумму.",
"Мы посмотрели, что на самом деле происходит с ценой в объявлениях Свердловской области, пока они висят. Ниже — что видно в данных и, не менее важно, чего в них не видно.",
],
toc: [
{ id: "s1", title: "Что вообще можно измерить, а что нельзя" },
{ id: "s2", title: "Кто двигает цену и в какую сторону" },
{ id: "s3", title: "На сколько снижают те, кто снижает" },
{ id: "s4", title: "Что делать, если звонков нет" },
{ id: "s5", title: "Пять причин простоя, которые не в цене" },
],
s1: {
id: "s1",
heading: "1. Что вообще можно измерить, а что нельзя",
body: "Честно о границах, прежде чем показывать числа. Среднего срока продажи мы вам не назовём, и никто честный не назовёт: чтобы его посчитать, нужно знать день, когда квартиру выставили, и день, когда её купили. Второго нет ни у кого, кроме сторон сделки. Росреестр публикует договоры без срока экспозиции, а исчезновение объявления с площадки означает только исчезновение с площадки — продал, передумал или переподал заново, из данных не отличить. Зато прекрасно видно другое: как продавец сам двигает цену, пока ждёт.",
source:
"ЗАМЕР 13.09.2026 · 49 620 ОБЪЯВЛЕНИЙ СВЕРДЛОВСКОЙ ОБЛАСТИ, У КОТОРЫХ ПЛОЩАДКА ВЕДЁТ ИСТОРИЮ ЦЕНЫ · ЭТО ЗАПРАШИВАЕМАЯ ЦЕНА, А НЕ ЦЕНА СДЕЛКИ",
memo: "Любая статья, которая называет вам «средний срок продажи квартиры», считает его по снятиям объявлений. Снятие — это не продажа. Спросите, как отличали одно от другого.",
},
s2: {
id: "s2",
heading: "2. Кто двигает цену и в какую сторону",
body: "Из 49 620 объявлений цена хотя бы раз сдвинулась у 21 608. Дальше начинается неожиданное: вниз ходят не все. Почти четыре из десяти движений — вверх.",
tiles: [
{ value: "43,5%", label: "объявлений, где цена вообще двигалась" },
{ value: "60,2%", label: "из двигавших — снизили" },
{ value: "38,0%", label: "из двигавших — подняли" },
],
source:
"ТОТ ЖЕ ЗАМЕР · ЗНАМЕНАТЕЛЬ ДОЛЕЙ — 21 608 ОБЪЯВЛЕНИЙ, У КОТОРЫХ ЦЕНА МЕНЯЛАСЬ · У ОСТАЛЬНЫХ ЗАПИСЬ ОДНА, И НЕПОДВИЖНУЮ ЦЕНУ ОТ ОТСУТСТВИЯ НАБЛЮДЕНИЯ НЕ ОТЛИЧИТЬ",
after:
"Подъём цены обычно означает не жадность, а переоценку: продавец начал слишком низко, получил шквал звонков в первую неделю и понял это. Ошибка в меньшую сторону чинится легко и быстро. Ошибка в большую сторону чинится месяцами — и это ровно та асимметрия, ради которой стоит потратить вечер на расчёт до публикации.",
},
s3: {
id: "s3",
heading: "3. На сколько снижают те, кто снижает",
body: "Медианное снижение выглядит скромно в процентах и совсем не скромно в рублях. И это не одна большая уступка в конце, а обычно две-три ступеньки подряд.",
table: {
head: ["ЧТО ИЗМЕРЕНО", "МЕДИАНА ПО 13 009 ОБЪЯВЛЕНИЯМ"],
rows: [
{ name: "Снижение от стартовой цены", value: "3,85%" },
{ name: "Снижение в рублях", value: "291 000 ₽" },
{ name: "Срок между первой и последней ценой", value: "30 дней" },
{ name: "Нижний квартиль снижения", value: "7,7%" },
{ name: "Верхний квартиль снижения", value: "2,4%" },
],
},
footnote:
"ТОТ ЖЕ ЗАМЕР · «30 ДНЕЙ» — СРОК МЕЖДУ ПЕРВОЙ И ПОСЛЕДНЕЙ ЗАПИСЬЮ О ЦЕНЕ, А НЕ СРОК ПРОДАЖИ · СРОК ПРОДАЖИ ИЗМЕРИТЬ НЕЧЕМ",
},
s4: {
id: "s4",
heading: "4. Что делать, если звонков нет",
body: "Ожидание само по себе не аргумент. Рынок отвечает на цену быстро, и молчание — тоже ответ. Порядок действий такой:",
kv: [
{ k: "ПЕРВАЯ НЕДЕЛЯ", v: "считаем показы и звонки, а не дни. Ноль звонков за неделю при живом объявлении — цена выше рынка" },
{ k: "ВТОРАЯ НЕДЕЛЯ", v: "пересчитываем по аналогам заново: что сейчас стоит рядом и что из этого реально ушло" },
{ k: "ПЕРЕД СНИЖЕНИЕМ", v: "проверяем фото, описание и этаж — часть простоя не в цене вовсе, см. раздел 5" },
{ k: "ЕСЛИ СНИЖАЕМ", v: "один заметный шаг лучше пяти незаметных: снижение на 1% рынок не видит" },
{ k: "ЧЕГО НЕ ДЕЛАТЬ", v: "снимать и публиковать заново ради «свежести» — история цены на площадках всё равно сохраняется" },
],
footnote:
"ЭТО ПОРЯДОК ДЕЙСТВИЙ, А НЕ ЗАМЕР · ИЗ ДАННЫХ ЗДЕСЬ ТОЛЬКО ОДНО: СНИЖАЮЩИЕ ДЕЛАЮТ ЭТО В МЕДИАНЕ ЗА ТРИ СТУПЕНЬКИ ЦЕНЫ",
},
cta: {
eyebrow: "ПЕРЕД ТЕМ КАК СНИЖАТЬ",
title: "Проверьте, действительно ли цена выше рынка",
body: `Снижать вслепую — это отдавать сотни тысяч на всякий случай. МЕРА найдёт аналоги вокруг вашего адреса и покажет, где ваша цена относительно них. Рынок вокруг — бесплатно, разбор вашей цены — за ${SERVICE_PRICE_RUB} ₽.`,
},
s5: {
id: "s5",
heading: "5. Пять причин простоя, которые не в цене",
mistakes: [
{
title: "Четыре фотографии, снятые вечером",
body: "Объявление конкурирует в ленте миниатюр, а не в тексте. Если карточку не открыли, цена значения не имела.",
},
{
title: "Описание из характеристик",
body: "«2/5, панель, 44 м²» покупатель уже видит в фильтре. В описании должно быть то, чего в фильтре нет.",
},
{
title: "Показы только по будням до шести",
body: "Ваш покупатель в это время на работе. Часть простоя — это не отказ рынка, а несовпадение расписаний.",
},
{
title: "Обременение, о котором вы скажете позже",
body: "Ипотека, доли, несовершеннолетний собственник. Всплывает на этапе аванса и отправляет вас в начало.",
},
{
title: "Продажа «в никуда»",
body: "Если вы не знаете, куда переезжаете, вы неосознанно тормозите сделку сами. Покупатель это чувствует раньше вас.",
},
],
},
summary:
"Срок продажи никто честно не измерит: снятие объявления и продажа в данных неразличимы. Зато видно движение цены. Она двигалась у 43,5% объявлений, и в 60,2% случаев вниз, в медиане на 3,85% и 291 000 ₽. Прежде чем снижать, проверьте цену по аналогам и проверьте само объявление: часть простоя не в цене.",
} as const;

View file

@ -202,8 +202,19 @@ export default function HowToArticlePage() {
</PublicLink> </PublicLink>
</div> </div>
<div className={styles.artGrid}> <div className={styles.artGrid}>
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map( {ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map((c) =>
(c) => ( c.route ? (
<PublicLink
key={c.title}
to={c.route}
className={styles.artCardLink}
>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artReadMore}>Читать </div>
</PublicLink>
) : (
<article key={c.title} className={styles.artCard}> <article key={c.title} className={styles.artCard}>
<div className={styles.artRubric}>{c.rubric}</div> <div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div> <div className={styles.artCardTitle}>{c.title}</div>

View file

@ -0,0 +1,231 @@
import type { Metadata } from "next";
import { InnerHeader } from "../../_components/InnerHeader";
import { JsonLd } from "../../_components/JsonLd";
import { PublicLink } from "../../_components/PublicLink";
import { FooterV3 } from "../../_components/v3/FooterV3";
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
import styles from "../../landing-v3.module.css";
import { ARTICLE_CARDS, WAITING } from "../articles-content";
/**
* Статья «Почему квартира не продаётся: что происходит с ценой, пока вы ждёте».
* Текст и все величины в `articles-content.ts` (там же замер, на котором они
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
* попадает в `SUBPAGES` гейта чисел.
*
* Серверный компонент без клиентского JS: содержание якорные ссылки,
* секции обычные заголовки с id.
*
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
*/
const PUBLIC_WAITING_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articleWaiting}`;
const DESCRIPTION = WAITING.lead[0];
export const metadata: Metadata = {
title: `${WAITING.title}МЕРА`,
description: DESCRIPTION,
alternates: { canonical: PUBLIC_WAITING_URL },
openGraph: {
type: "article",
title: `${WAITING.title}МЕРА`,
description: DESCRIPTION,
url: PUBLIC_WAITING_URL,
},
};
/**
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата
* то самое нечестное утверждение, которое запрещает шапка content.ts.
*/
const ARTICLE_JSON_LD = {
"@context": "https://schema.org",
"@type": "Article",
headline: WAITING.title,
description: DESCRIPTION,
author: { "@type": "Organization", name: "МЕРА" },
publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_WAITING_URL },
};
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
{ name: WAITING.title, url: PUBLIC_WAITING_URL },
]),
};
export default function WaitingArticlePage() {
return (
<>
<JsonLd data={ARTICLE_JSON_LD} />
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<PublicLink to="articles" className={styles.artCrumbLink}>
Статьи
</PublicLink>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>{WAITING.rubric}</div>
<h1 className={styles.artPageTitle}>{WAITING.title}</h1>
</header>
<nav className={styles.artToc} aria-label="Содержание">
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
<ol className={styles.artTocList}>
{WAITING.toc.map((t) => (
<li key={t.id}>
<a href={`#${t.id}`}>{t.title}</a>
</li>
))}
</ol>
</nav>
<div className={styles.artBody}>
{WAITING.lead.map((p) => (
<p key={p.slice(0, 24)} className={styles.artP}>
{p}
</p>
))}
<h2 id={WAITING.s1.id} className={styles.artH2}>
{WAITING.s1.heading}
</h2>
<p className={styles.artP}>{WAITING.s1.body}</p>
<div className={styles.artFootnote}>{WAITING.s1.source}</div>
<aside className={styles.artMemo}>
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
<p>{WAITING.s1.memo}</p>
</aside>
<h2 id={WAITING.s2.id} className={styles.artH2}>
{WAITING.s2.heading}
</h2>
<p className={styles.artP}>{WAITING.s2.body}</p>
<div className={styles.artTiles}>
{WAITING.s2.tiles.map((t) => (
<div key={t.label} className={styles.artTile}>
<div className={styles.artTileValue}>{t.value}</div>
<div className={styles.artTileLabel}>{t.label}</div>
</div>
))}
</div>
<div className={styles.artFootnote}>{WAITING.s2.source}</div>
<p className={styles.artP}>{WAITING.s2.after}</p>
<h2 id={WAITING.s3.id} className={styles.artH2}>
{WAITING.s3.heading}
</h2>
<p className={styles.artP}>{WAITING.s3.body}</p>
<div className={styles.artTable} role="table">
<div className={styles.artTableHead} role="row">
<span role="columnheader">{WAITING.s3.table.head[0]}</span>
<span role="columnheader" className={styles.artTableRight}>
{WAITING.s3.table.head[1]}
</span>
</div>
{WAITING.s3.table.rows.map((r) => (
<div key={r.name} className={styles.artTableRow} role="row">
<span role="cell">{r.name}</span>
<span role="cell" className={styles.artTableRight}>
{r.value}
</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{WAITING.s3.footnote}</div>
<h2 id={WAITING.s4.id} className={styles.artH2}>
{WAITING.s4.heading}
</h2>
<p className={styles.artP}>{WAITING.s4.body}</p>
<div className={styles.artKv}>
{WAITING.s4.kv.map((row) => (
<div key={row.k} className={styles.artKvRow}>
<span className={styles.artKvKey}>{row.k}</span>
<span className={styles.artKvVal}>{row.v}</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{WAITING.s4.footnote}</div>
<div className={styles.artCta}>
<div className={styles.artCtaEyebrow}>{WAITING.cta.eyebrow}</div>
<div className={styles.artCtaTitle}>{WAITING.cta.title}</div>
<p className={styles.artCtaBody}>{WAITING.cta.body}</p>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить квартиру
</PublicLink>
</div>
<h2 id={WAITING.s5.id} className={styles.artH2}>
{WAITING.s5.heading}
</h2>
<ol className={styles.artMistakes}>
{WAITING.s5.mistakes.map((m, i) => (
<li key={m.title} className={styles.artMistake}>
<span className={styles.artMistakeNum}>
{String(i + 1).padStart(2, "0")}
</span>
<div>
<div className={styles.artMistakeTitle}>{m.title}</div>
<div className={styles.artMistakeBody}>{m.body}</div>
</div>
</li>
))}
</ol>
<div className={styles.artSummary}>
<div className={styles.artTocLabel}>КОРОТКО</div>
<p>{WAITING.summary}</p>
</div>
</div>
<section className={styles.artMore} aria-label="Читайте дальше">
<div className={styles.artHead}>
<h2 className={styles.artTitle}>Читайте дальше</h2>
<PublicLink to="articles" className={styles.artAllLink}>
Все статьи
</PublicLink>
</div>
<div className={styles.artGrid}>
{ARTICLE_CARDS.filter((c) => c.route !== "articleWaiting").map((c) =>
c.route ? (
<PublicLink
key={c.title}
to={c.route}
className={styles.artCardLink}
>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artReadMore}>Читать </div>
</PublicLink>
) : (
<article key={c.title} className={styles.artCard}>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artSoon}>СКОРО</div>
</article>
),
)}
</div>
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -0,0 +1,231 @@
import type { Metadata } from "next";
import { InnerHeader } from "../../_components/InnerHeader";
import { JsonLd } from "../../_components/JsonLd";
import { PublicLink } from "../../_components/PublicLink";
import { FooterV3 } from "../../_components/v3/FooterV3";
import { breadcrumbNode, ORGANIZATION_ID } from "../../_lib/schema";
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
import styles from "../../landing-v3.module.css";
import { ARTICLE_CARDS, PRICES } from "../articles-content";
/**
* Статья «Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений».
* Текст и все величины в `articles-content.ts` (там же замер, на котором они
* стоят, и его границы). В этом файле не должно быть ни одного числа: папка
* попадает в `SUBPAGES` гейта чисел.
*
* Серверный компонент без клиентского JS: содержание якорные ссылки,
* секции обычные заголовки с id.
*
* ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
*/
const PUBLIC_PRICES_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articlePrices}`;
const DESCRIPTION = PRICES.lead[0];
export const metadata: Metadata = {
title: `${PRICES.title}МЕРА`,
description: DESCRIPTION,
alternates: { canonical: PUBLIC_PRICES_URL },
openGraph: {
type: "article",
title: `${PRICES.title}МЕРА`,
description: DESCRIPTION,
url: PUBLIC_PRICES_URL,
},
};
/**
* JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
* `dateModified`: достоверной даты публикации в коде нет, а выдуманная дата
* то самое нечестное утверждение, которое запрещает шапка content.ts.
*/
const ARTICLE_JSON_LD = {
"@context": "https://schema.org",
"@type": "Article",
headline: PRICES.title,
description: DESCRIPTION,
author: { "@type": "Organization", name: "МЕРА" },
publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_PRICES_URL },
};
const BREADCRUMB_JSON_LD = {
"@context": "https://schema.org",
...breadcrumbNode([
{ name: "Главная", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.home}` },
{ name: "Статьи", url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}` },
{ name: PRICES.title, url: PUBLIC_PRICES_URL },
]),
};
export default function PricesArticlePage() {
return (
<>
<JsonLd data={ARTICLE_JSON_LD} />
<JsonLd data={BREADCRUMB_JSON_LD} />
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<PublicLink to="articles" className={styles.artCrumbLink}>
Статьи
</PublicLink>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>{PRICES.rubric}</div>
<h1 className={styles.artPageTitle}>{PRICES.title}</h1>
</header>
<nav className={styles.artToc} aria-label="Содержание">
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
<ol className={styles.artTocList}>
{PRICES.toc.map((t) => (
<li key={t.id}>
<a href={`#${t.id}`}>{t.title}</a>
</li>
))}
</ol>
</nav>
<div className={styles.artBody}>
{PRICES.lead.map((p) => (
<p key={p.slice(0, 24)} className={styles.artP}>
{p}
</p>
))}
<h2 id={PRICES.s1.id} className={styles.artH2}>
{PRICES.s1.heading}
</h2>
<p className={styles.artP}>{PRICES.s1.body}</p>
<div className={styles.artFootnote}>{PRICES.s1.source}</div>
<aside className={styles.artMemo}>
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
<p>{PRICES.s1.memo}</p>
</aside>
<h2 id={PRICES.s2.id} className={styles.artH2}>
{PRICES.s2.heading}
</h2>
<p className={styles.artP}>{PRICES.s2.body}</p>
<div className={styles.artTable} role="table">
<div className={styles.artTableHead} role="row">
<span role="columnheader">{PRICES.s2.table.head[0]}</span>
<span role="columnheader" className={styles.artTableRight}>
{PRICES.s2.table.head[1]}
</span>
</div>
{PRICES.s2.table.rows.map((r) => (
<div key={r.name} className={styles.artTableRow} role="row">
<span role="cell">{r.name}</span>
<span role="cell" className={styles.artTableRight}>
{r.value}
</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{PRICES.s2.footnote}</div>
<p className={styles.artP}>{PRICES.s2.after}</p>
<h2 id={PRICES.s3.id} className={styles.artH2}>
{PRICES.s3.heading}
</h2>
<p className={styles.artP}>{PRICES.s3.body}</p>
<div className={styles.artTiles}>
{PRICES.s3.tiles.map((t) => (
<div key={t.label} className={styles.artTile}>
<div className={styles.artTileValue}>{t.value}</div>
<div className={styles.artTileLabel}>{t.label}</div>
</div>
))}
</div>
<div className={styles.artFootnote}>{PRICES.s3.source}</div>
<h2 id={PRICES.s4.id} className={styles.artH2}>
{PRICES.s4.heading}
</h2>
<p className={styles.artP}>{PRICES.s4.body}</p>
<div className={styles.artKv}>
{PRICES.s4.kv.map((row) => (
<div key={row.k} className={styles.artKvRow}>
<span className={styles.artKvKey}>{row.k}</span>
<span className={styles.artKvVal}>{row.v}</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{PRICES.s4.footnote}</div>
<div className={styles.artCta}>
<div className={styles.artCtaEyebrow}>{PRICES.cta.eyebrow}</div>
<div className={styles.artCtaTitle}>{PRICES.cta.title}</div>
<p className={styles.artCtaBody}>{PRICES.cta.body}</p>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить квартиру
</PublicLink>
</div>
<h2 id={PRICES.s5.id} className={styles.artH2}>
{PRICES.s5.heading}
</h2>
<ol className={styles.artMistakes}>
{PRICES.s5.mistakes.map((m, i) => (
<li key={m.title} className={styles.artMistake}>
<span className={styles.artMistakeNum}>
{String(i + 1).padStart(2, "0")}
</span>
<div>
<div className={styles.artMistakeTitle}>{m.title}</div>
<div className={styles.artMistakeBody}>{m.body}</div>
</div>
</li>
))}
</ol>
<div className={styles.artSummary}>
<div className={styles.artTocLabel}>КОРОТКО</div>
<p>{PRICES.summary}</p>
</div>
</div>
<section className={styles.artMore} aria-label="Читайте дальше">
<div className={styles.artHead}>
<h2 className={styles.artTitle}>Читайте дальше</h2>
<PublicLink to="articles" className={styles.artAllLink}>
Все статьи
</PublicLink>
</div>
<div className={styles.artGrid}>
{ARTICLE_CARDS.filter((c) => c.route !== "articlePrices").map((c) =>
c.route ? (
<PublicLink
key={c.title}
to={c.route}
className={styles.artCardLink}
>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artReadMore}>Читать </div>
</PublicLink>
) : (
<article key={c.title} className={styles.artCard}>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artSoon}>СКОРО</div>
</article>
),
)}
</div>
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -195,6 +195,8 @@ export const PUBLIC_ROUTES = {
// Макет 29.08: раздел статей и хаб документов. // Макет 29.08: раздел статей и хаб документов.
articles: "/articles", articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru", articleHowTo: "/articles/kak-ocenit-kvartiru",
articlePrices: "/articles/skolko-stoit-metr-v-ekaterinburge",
articleWaiting: "/articles/pochemu-kvartira-ne-prodaetsya",
docs: "/docs", docs: "/docs",
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026 // Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур // взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур

View file

@ -22,7 +22,7 @@ import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "./content";
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным * актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
* изменением состава/контента страниц. * изменением состава/контента страниц.
*/ */
const LAST_MODIFIED = new Date("2026-09-10T00:00:00Z"); const LAST_MODIFIED = new Date("2026-09-13T00:00:00Z");
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`; const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
@ -52,6 +52,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, priority: 0.6,
}, },
{
url: url(PUBLIC_ROUTES.articlePrices),
lastModified: LAST_MODIFIED,
changeFrequency: "monthly",
priority: 0.6,
},
{
url: url(PUBLIC_ROUTES.articleWaiting),
lastModified: LAST_MODIFIED,
changeFrequency: "monthly",
priority: 0.6,
},
{ {
url: url(PUBLIC_ROUTES.business), url: url(PUBLIC_ROUTES.business),
lastModified: LAST_MODIFIED, lastModified: LAST_MODIFIED,

View file

@ -102,6 +102,8 @@ const PUBLIC_SHORT_PATHS = [
"/privacy", "/privacy",
"/articles", "/articles",
"/articles/kak-ocenit-kvartiru", "/articles/kak-ocenit-kvartiru",
"/articles/skolko-stoit-metr-v-ekaterinburge",
"/articles/pochemu-kvartira-ne-prodaetsya",
"/docs", "/docs",
"/business", "/business",
// Машинные артефакты для поисковых роботов, а не страницы. React на них не // Машинные артефакты для поисковых роботов, а не страницы. React на них не

View file

@ -47,6 +47,7 @@ except ImportError: # pragma: no cover - в проде backend-env sentry_sdk
from scraper_kit.orchestration import runs as _kit_runs from scraper_kit.orchestration import runs as _kit_runs
from scraper_kit.orchestration.pipeline import ( from scraper_kit.orchestration.pipeline import (
DEFAULT_REGION_CODE,
get_city_anchors, get_city_anchors,
run_avito_city_sweep, run_avito_city_sweep,
run_avito_full_load, run_avito_full_load,
@ -880,6 +881,37 @@ def _interval_minutes(params: dict[str, Any], *, param: str = "interval_minutes"
return None if raw is None else int(raw) return None if raw is None else int(raw)
# Kit не импортирует `app.*` (contracts.py — граница strangler'а, см. её docstring),
# поэтому реестр `app.services.regions.REGIONS` сюда не дотянуть напрямую. Список
# ниже — суженная копия ЕГО КЛЮЧЕЙ только для отказа мусорного region_code из
# default_params; она не заменяет REGIONS и не должна расти самостоятельно — при
# добавлении региона в REGIONS обновить и здесь.
_KNOWN_REGION_CODES: frozenset[int] = frozenset({50, 66, 77})
def _resolve_region_code(params: dict[str, Any]) -> int:
"""default_params["region_code"] → валидированный int для run_*.
Ключа нет DEFAULT_REGION_CODE (66, тот же дефолт что и у run_*-функций
pipeline.py) прежнее поведение всех 123 существующих строк расписания не
меняется. Значение не парсится в int ИЛИ отсутствует в _KNOWN_REGION_CODES
ValueError: явный отказ прогона (падает сквозь job в `_dispatch`'s
`except Exception` `logger.exception`, тот же путь, что и любой другой
crash хендлера), а не тихая запись мусора в listings.region_code.
"""
raw = params.get("region_code", DEFAULT_REGION_CODE)
try:
region_code = int(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"region_code={raw!r} из default_params не приводится к int") from exc
if region_code not in _KNOWN_REGION_CODES:
raise ValueError(
f"region_code={region_code} отсутствует в реестре регионов "
f"(известны: {sorted(_KNOWN_REGION_CODES)})"
)
return region_code
# Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules, # Нижний порог defer'а (#1522): пропуск обязан пережить несколько get_due_schedules,
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при # иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
# рассинхроне часов планировщика и БД на такт в любую сторону. # рассинхроне часов планировщика и БД на такт в любую сторону.
@ -1047,6 +1079,7 @@ async def _job_avito_city_sweep(
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
enrich_houses=bool(params.get("enrich_houses", True)), enrich_houses=bool(params.get("enrich_houses", True)),
radius_m=int(params.get("radius_m", 1500)), radius_m=int(params.get("radius_m", 1500)),
region_code=_resolve_region_code(params),
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта — # #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от # имя якоря, оно не зависит от количества якорей, поэтому в отличие от
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется. # combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
@ -1066,6 +1099,7 @@ async def _job_avito_newbuilding_sweep(
proxy_provider=ctx.proxy_provider, proxy_provider=ctx.proxy_provider,
pages=int(params.get("pages", 20)), pages=int(params.get("pages", 20)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
region_code=_resolve_region_code(params),
# #3074: подхват страниц у оборванного предшественника — см. # #3074: подхват страниц у оборванного предшественника — см.
# _job_avito_city_sweep выше. # _job_avito_city_sweep выше.
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
@ -1110,6 +1144,7 @@ async def _job_avito_full_load(
concurrency=int(params.get("concurrency", 5)), concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)), secondary_only=bool(params.get("secondary_only", True)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
incremental_days=incremental_days, incremental_days=incremental_days,
) )
@ -1130,6 +1165,7 @@ async def _job_avito_full_load_exhaustive(
concurrency=int(params.get("concurrency", 5)), concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)), secondary_only=bool(params.get("secondary_only", True)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
incremental_days=None, incremental_days=None,
) )
@ -1159,6 +1195,7 @@ async def _job_yandex_city_sweep(
radius_m=int(params.get("radius_m", 1500)), radius_m=int(params.get("radius_m", 1500)),
enrich_address=bool(params.get("enrich_address", True)), enrich_address=bool(params.get("enrich_address", True)),
segments=params.get("segments"), segments=params.get("segments"),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
) )
@ -1184,6 +1221,7 @@ async def _job_cian_city_sweep(
detail_top_n=int(params.get("detail_top_n", 10)), detail_top_n=int(params.get("detail_top_n", 10)),
enrich_houses=bool(params.get("enrich_houses", True)), enrich_houses=bool(params.get("enrich_houses", True)),
newbuilding_only=bool(params.get("newbuilding_only", True)), newbuilding_only=bool(params.get("newbuilding_only", True)),
region_code=_resolve_region_code(params),
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта — # #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
# имя якоря, от их количества не зависит, поэтому гарда как у combo- # имя якоря, от их количества не зависит, поэтому гарда как у combo-
# чекпоинта яндекса здесь не требуется. # чекпоинта яндекса здесь не требуется.
@ -1206,6 +1244,7 @@ async def _job_cian_full_load(
request_delay_sec=float(params.get("request_delay_sec", 4.0)), request_delay_sec=float(params.get("request_delay_sec", 4.0)),
enrich_detail=bool(params.get("enrich_detail", False)), enrich_detail=bool(params.get("enrich_detail", False)),
detail_top_n=int(params.get("detail_top_n", 0)), detail_top_n=int(params.get("detail_top_n", 0)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
) )
@ -1224,6 +1263,7 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"), rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)), pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)), request_delay_sec=float(params.get("request_delay_sec", 6.0)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id), resume_run_id=_pick_resume(db, run_id),
) )

View file

@ -80,18 +80,37 @@ python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
# 3) продолжить прерванный прогон по сохранённому плану коридоров # 3) продолжить прерванный прогон по сохранённому плану коридоров
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908 python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
# 4) DomClick по Московской области (--region — только для domclick)
python .\collect.py --platform domclick --region 50 --full `
--batch-id mo-serp-domclick-20260912-1200
``` ```
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**. Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
Дефолт — `--platform avito`. Дефолт — `--platform avito --region 77` (Москва).
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками, `--region {77,50}` переключает регион **только для `--platform domclick`**: у DomClick
Москва и область — разные GUID листинга (`MSK_DOMCLICK_GUID` / `MO_DOMCLICK_GUID` в
`collect.py`) и разные прогоны. У Авито/Циана/Яндекса единый скоуп «Москва и МО»
собирается одним прогоном (`moskva_i_mo` / `region=-1` / `rgid=741964`) и делится на
регионы уже при импорте в `listings` — отдельного прогона по области у них нет, и
`--region 50` с любой площадкой кроме domclick падает `SystemExit` при разборе аргументов.
Скрипт также сверяет GUID, зашитый в фактический `base_url` (параметр `address`), с
GUID, ожидаемым для выбранного `--region` — расхождение (например, ручной `--base-url`
с московским GUID при `--region 50`) останавливает прогон до первого запроса, а не
заливает чужой регион под областной `batch_id`.
Ключи: `--platform {avito,cian,yandex,domclick}` (дефолт avito), `--region {77,50}`
(дефолт 77, только domclick), `--delay` (пауза между загрузками,
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
`request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000), `request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у `--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени, от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>`, а при
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`, `--platform domclick --region 50``mo-serp-domclick-<UTC>`: платформа/регион в имени,
чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в
таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет),
`--out-dir`,
`--ssh-host/--container/--db-user/--db-name`. `--ssh-host/--container/--db-user/--db-name`.
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ — `AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —

View file

@ -6,9 +6,19 @@
(подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql (подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает. через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) см. Платформа выбирается ключом --platform {avito,cian,yandex,domclick} (дефолт
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой avito) см. класс PlatformAdapter ниже. У каждой платформы свой потолок
билдер URL коридора и своя целевая таблица в msk_raw. пагинации, свой билдер URL коридора и своя целевая таблица в msk_raw.
--region {77,50} (дефолт 77 Москва) переключает регион сбора и работает
ТОЛЬКО с --platform domclick: у DomClick Москва и область разные GUID и
разные прогоны. У Авито/Циана/Яндекса корпус Москва+МО собирается одним
прогоном (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже
при импорте, поэтому --region 50 с любой другой площадкой падает SystemExit.
Пример прогона по Московской области (DomClick, полный проход):
python collect.py --platform domclick --region 50 --full \
--batch-id mo-serp-domclick-20260912-1200
Дефолтный режим --measure 100 (замер): полный проход только по явному --full. Дефолтный режим --measure 100 (замер): полный проход только по явному --full.
""" """
@ -153,7 +163,9 @@ YANDEX_MAX_PAGES = 25
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated. # штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500 YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1). # DomClick: JSON BFF listing API, Москва + отдельно Московская область
# (эпик #2989, трек 1). У DomClick, в отличие от Авито/Циана/Яндекса, Москва и
# область — РАЗНЫЕ прогоны с разными GUID (--region), а не один общий скоуп.
# #
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот # GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ — # же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
@ -162,28 +174,74 @@ YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
# листинга точно совпадает с сайтом. # листинга точно совпадает с сайтом.
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553" MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
DEFAULT_DOMCLICK_BASE_URL = ( # GUID Московской области проверен живым запросом 12.09: тот же listing-эндпойнт
"https://bff-search-web.domclick.ru/api/offers/v1" # с этим address отдаёт result.pagination.total = 17572, aids тоже не нужен.
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat" MO_DOMCLICK_GUID = "9930cc20-32c6-4f6f-a55e-cd67086c5171"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
def _domclick_base_url(guid: str) -> str:
"""Base_url DomClick BFF listing API под конкретный GUID региона.
Обе региональные константы (MSK/MO) обязаны строиться этой функцией, а не
двумя независимыми f-строками, иначе расхождение параметров (limit,
offset, sort) между Москвой и областью было бы легко внести незаметно.
"""
return (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={guid}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
DEFAULT_DOMCLICK_BASE_URL = _domclick_base_url(MSK_DOMCLICK_GUID)
MO_DOMCLICK_BASE_URL = _domclick_base_url(MO_DOMCLICK_GUID)
def _domclick_extract_guid(base_url: str) -> str | None:
"""GUID региона, зашитый в параметр address базового URL DomClick."""
return dict(parse_qsl(urlsplit(base_url).query)).get("address")
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную # Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
# страницу (20 items), offset=2000 отвечает HTTP 400 # страницу (20 items), offset=2000 отвечает HTTP 400
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации — # {"statusCode":400,"error":"Bad Request"}. Одинаково и для Москвы, и для
# области (проверено на обоих GUID) — значит жёсткий потолок пагинации общий:
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров. # 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
DOMCLICK_PAGE_SIZE = 20 DOMCLICK_PAGE_SIZE = 20
DOMCLICK_MAX_PAGES = 100 DOMCLICK_MAX_PAGES = 100
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick. # bbox — единственный надёжный гео-гард для DomClick, отдельно для Москвы (с
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с # ТиНАО) и для Московской области. offerRegionName использовать НЕЛЬЗЯ: часть
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча # офферов Новой Москвы приходит с именами вида "г. Говорово", а не "Москва" —
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит: # гард по имени региона молча вырезал бы легитимные лоты. scraper._is_geo_ok()
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально # кита сюда тоже не подходит: он захардкожен на offerRegionName ==
# всю московскую выдачу. # "Екатеринбург" и отбросил бы буквально всю выдачу обоих регионов.
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02 _MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97 _MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
# bbox Московской области — литерал, РАВНЫЙ bbox_region региона 50 в реестре
# tradein-mvp/backend/app/services/regions.py (сослались на реестр, значение
# впечатано сюда буквально: этот скрипт лежит вне пакета backend и не может
# его импортировать). Московский bbox область не накрывает: Серпухов
# (lat~54.9), Коломна (lon~39.4), Дубна (lat~56.7) все лежат вне _MSK_*.
_MO_LAT_MIN, _MO_LAT_MAX = 54.20, 56.96
_MO_LON_MIN, _MO_LON_MAX = 35.14, 40.21
_MSK_BBOX: tuple[float, float, float, float] = (
_MSK_LAT_MIN, _MSK_LAT_MAX, _MSK_LON_MIN, _MSK_LON_MAX,
)
_MO_BBOX: tuple[float, float, float, float] = (
_MO_LAT_MIN, _MO_LAT_MAX, _MO_LON_MIN, _MO_LON_MAX,
)
# Активный гео-гард, который реально читает _domclick_parse_cards. Модульная
# переменная, а НЕ аргумент функции: сигнатура parse_cards зафиксирована типом
# PlatformAdapter.parse_cards (Callable[[Any, str], list[Any]]) и завязана на
# него все остальные платформы, менять её ради одного DomClick нельзя. collect()
# выставляет это значение один раз при старте прогона, по --region, до первого
# запроса к сети.
_DOMCLICK_BBOX: tuple[float, float, float, float] = _MSK_BBOX
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽ PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
PRICE_CEIL = 2_000_000_000 PRICE_CEIL = 2_000_000_000
@ -712,8 +770,8 @@ def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
lon = float(loc["lon"]) lon = float(loc["lon"])
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
continue continue
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX lat_min, lat_max, lon_min, lon_max = _DOMCLICK_BBOX
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX): if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
continue continue
lot = scraper._map_item(item) lot = scraper._map_item(item)
if lot is not None: if lot is not None:
@ -1411,6 +1469,31 @@ async def collect(args: argparse.Namespace) -> int:
) )
base_url = plan.base_url if plan is not None else args.base_url base_url = plan.base_url if plan is not None else args.base_url
# Гвард региона DomClick — САМОЕ ВАЖНОЕ в этом прогоне. --base-url можно
# передать вручную с любым GUID, а base_url резюме приходит из сохранённого
# плана в обход --region вовсе; расхождение GUID и --region залило бы
# Москву под областным batch_id (или наоборот) и отравило бы корпус.
# Проверка стоит здесь намеренно: этой точки достигают все три источника
# base_url — дефолтный (выставлен в parse_args по --region), переданный
# вручную через --base-url и восстановленный из плана при --resume — уже
# ПОСЛЕ сверки plan.base_url != args.base_url чуть выше, так что при
# --resume расхождение платформы/URL с CLI отловится раньше и с более
# точным сообщением.
if args.platform == "domclick":
expected_guid = MO_DOMCLICK_GUID if args.region == 50 else MSK_DOMCLICK_GUID
actual_guid = _domclick_extract_guid(base_url)
if actual_guid != expected_guid:
raise SystemExit(
f"--region {args.region} ожидает GUID {expected_guid!r} в base_url"
f" (параметр address), а фактический base_url несёт GUID"
f" {actual_guid!r}. Поправь --region либо --base-url — иначе"
f" прогон зальёт чужой регион под batch_id={args.batch_id}."
)
global _DOMCLICK_BBOX
_DOMCLICK_BBOX = _MO_BBOX if args.region == 50 else _MSK_BBOX
print(f"DomClick: --region={args.region}, guid={expected_guid}, "
f"bbox={_DOMCLICK_BBOX}", flush=True)
page_budget = None if args.full else args.measure page_budget = None if args.full else args.measure
mode = "FULL" if args.full else f"MEASURE<={page_budget}" mode = "FULL" if args.full else f"MEASURE<={page_budget}"
print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; " print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; "
@ -1523,8 +1606,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
) )
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito", p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
help="площадка сбора (дефолт avito)") help="площадка сбора (дефолт avito)")
p.add_argument("--region", type=int, choices=(77, 50), default=77,
help="регион сбора: 77 Москва (дефолт), 50 Московская область"
" (поддерживается только --platform domclick)")
p.add_argument("--base-url", default=None, p.add_argument("--base-url", default=None,
help="базовый URL выдачи (дефолт зависит от --platform)") help="базовый URL выдачи (дефолт зависит от --platform/--region)")
p.add_argument("--measure", type=int, default=100, metavar="N", p.add_argument("--measure", type=int, default=100, metavar="N",
help="режим замера: не больше N загрузок страниц (дефолт 100)") help="режим замера: не больше N загрузок страниц (дефолт 100)")
p.add_argument("--full", action="store_true", p.add_argument("--full", action="store_true",
@ -1551,16 +1637,42 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p.add_argument("--db-user", default="tradein") p.add_argument("--db-user", default="tradein")
p.add_argument("--db-name", default="tradein") p.add_argument("--db-name", default="tradein")
args = p.parse_args(argv) args = p.parse_args(argv)
if args.region == 50 and args.platform != "domclick":
p.error(
"--region 50 поддерживается только для --platform domclick: корпус"
" Авито/Циана/Яндекса по Москве и области собирается ОДНИМ прогоном"
" (moskva_i_mo / region=-1 / rgid Москва+МО) и делится на регионы уже"
" при импорте — отдельного областного прогона у этих площадок нет."
)
if args.base_url is None: if args.base_url is None:
args.base_url = ADAPTERS[args.platform].default_base_url if args.platform == "domclick" and args.region == 50:
args.base_url = MO_DOMCLICK_BASE_URL
else:
args.base_url = ADAPTERS[args.platform].default_base_url
if args.target_count is None: if args.target_count is None:
args.target_count = ADAPTERS[args.platform].default_target args.target_count = ADAPTERS[args.platform].default_target
if args.batch_id is None: if args.batch_id is None:
args.batch_id = ( prefix = (
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") "mo-serp-domclick" if (args.platform == "domclick" and args.region == 50)
else f"msk-serp-{args.platform}"
) )
args.batch_id = prefix + "-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if not _BATCH_ID_RE.match(args.batch_id): if not _BATCH_ID_RE.match(args.batch_id):
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]") p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
# batch_id — ЕДИНСТВЕННАЯ метка региона в msk_raw.domclick_cards: колонки
# региона там нет, партии потом разделяются по префиксу имени батча. Имя,
# заданное руками вразрез с --region, сделало бы областную партию
# неотличимой от московской уже после заливки, когда чинить поздно. Поэтому
# префикс сверяется с регионом так же строго, как GUID в base_url.
if args.platform == "domclick":
want = "mo-serp-domclick" if args.region == 50 else "msk-serp-domclick"
other = "msk-serp-domclick" if args.region == 50 else "mo-serp-domclick"
if args.batch_id.startswith(other) or not args.batch_id.startswith(want):
p.error(
f"--batch-id должен начинаться с {want!r} при --region"
f" {args.region}: имя батча — единственный признак региона в"
f" msk_raw.domclick_cards, колонки региона там нет."
)
if args.measure < 1: if args.measure < 1:
p.error("--measure должен быть >= 1") p.error("--measure должен быть >= 1")
return args return args