Compare commits

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

36 commits

Author SHA1 Message Date
6da7de06a4 Черновик регулярного сбора по Москве и области: якоря, ID площадок, гео-профили ДомКлика (#3533)
All checks were successful
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
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m49s
Deploy Trade-In / deploy (push) Successful in 4m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
ДомКлик: DomClickGeoProfile вместо зашитых _EKB_ADDRESS_GUID/_EKB_AREA_ID; GUID Москвы
и области проверены живьём, aids вне ЕКБ не нужен, гард по bbox профиля. Страница
прогрева 77/50 — апексный domclick.ru: субдомены msk./moskovskaya-oblast. отдают 301.

Циан/Авито/Яндекс: CityLocation.cian_host, три новых скоупа (moskva, moskovskaya_oblast,
moskva_i_mo), avito_slug_is_region, city=NULL у мультигородских скоупов. Неизвестный слаг
теперь падает с ValueError вместо молчаливого отката на Екатеринбург.

Якоря: Москва — сетка 25 точек под radius_m=8000; область — 22 города-спутника
(10 добраны из Nominatim) плюс 22 кластера лот-массы. Замер на проде: города радиусом
10 км дают 73.8% лот-массы области, вместе с кластерами — 95.2%.

Ценовые коридоры: планировщик plan_price_corridors со статистикой усечения плюс
BisectionStats в живом движке. Провайдеры их пока не передают — отдельный заход.

Расписаний scrape_schedules для 77/50 в этом PR нет: они пойдут после первого ручного
прогона, подтверждающего живость профилей.
2026-09-15 17:51:30 +00:00
ce60fc214d Геокодирование сделок области: ключ (регион, НП, улица) вместо голой улицы (#3532)
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 1m20s
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
Центроид строился по названию улицы без города — в области это схлопывало одноимённые
улицы разных городов. Ключ стал (region_code, населённый пункт, улица); НП берётся из
типа сегмента адреса, из реестра городов региона или из deals.city; при пустом НП ключ
у области отбрасывается, а не склеивается с чужим городом.

Фильтры по региону добавлены в SELECT кандидатов, в запрос домов и в UPDATE. Новый
--max-spread-km (5 км) отбрасывает бакет с разбросанными домами.

geocoder.py: поддержка региона 50 (маркер «московская» — намеренно не «москва»,
DaData-имя «Московская», новое поле Region.has_city_core=False, чтобы к адресу области
не приклеивался суффикс главного города).
2026-09-15 17:28:09 +00:00
39996ef3fd Коэффициент asking→sold вне ЕКБ считается на согласованной географии (#3531)
All checks were successful
Deploy Trade-In / changes (push) Successful in 18s
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 3m58s
Deploy Trade-In / build-backend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 2m9s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Обе стороны раскладываются по одной сетке 0.1°x0.2°, медианы внутри ячейки, в итог
только ячейки с ≥10 сделок И ≥10 объявлений, агрегация взвешена числом сделок.

Замер на проде (SQL исполнен в транзакции с ROLLBACK): регион 50 — 0.8136 вместо
пулового 0.8851, регион 77 — 0.7287 вместо 0.7100. Разнонаправленный сдвиг — подпись
поправки состава. Регион 66 не тронут байт в байт.

Гарды: регион не получает строк при покрытии geom <50%, совпавших ячейках <3 или
попадании в пересечение <50% геокодированных сделок; DELETE идёт в любом случае.
2026-09-15 17:18:05 +00:00
f2c18f5216 Метрика на корпоративном аккаунте и подтверждение прав в Яндекс.Вебмастере (#3530)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m32s
Deploy Trade-In / test (push) Successful in 3m57s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-15 16:48:57 +00:00
19418066c2 Счётчик Яндекс.Метрики 112563104 доведён до сборки публичного контура (#3529)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 48s
Deploy Trade-In / build-frontend (push) Successful in 2m36s
Deploy Trade-In / test (push) Successful in 3m59s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy Trade-In / deploy (push) Successful in 8m18s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m57s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-15 16:29:32 +00:00
dbf46228fb Дробить плитку Overpass только при перегрузке, а не при отказе сети (#3528)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m29s
Deploy / build-worker (push) Successful in 4m1s
Deploy / deploy (push) Successful in 1m28s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m44s
2026-09-15 07:33:56 +00:00
824de24897 Снятие устаревших объявлений работает только там, где есть пересбор (#3526)
All checks were successful
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) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 8m37s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:32:50 +00:00
967f2b53ba Границы регионов без упрощения, чтобы Куркино не считалось по Екатеринбургу (#3527)
All checks were successful
Deploy Trade-In / changes (push) Successful in 23s
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 4m18s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:22:31 +00:00
2d2d9ebf17 Свип с чужим регионом падает вместо тихого сбора Екатеринбурга (#3525)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m57s
Deploy Trade-In / deploy (push) Successful in 1m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
2026-09-15 07:09:05 +00:00
a83418799c Загрузчик точек интереса берёт регион параметром, а не только Екатеринбург (#3524)
All checks were successful
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 2m21s
Deploy / build-worker (push) Successful in 3m38s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:05:21 +00:00
d7aaa00326 Матч ГАР работает по любому региону, а не только по Екатеринбургу (#3523)
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
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
2026-09-15 06:59:01 +00:00
3d114672fe Регион точки резолвится по настоящей границе, а не по прямоугольнику (#3052) (#3522)
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 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / deploy (push) Successful in 6m59s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
2026-09-15 06:44:31 +00:00
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
86 changed files with 8641 additions and 634 deletions

View file

@ -233,6 +233,16 @@ jobs:
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-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/**'
- '.forgejo/workflows/ci.yml'

View file

@ -511,23 +511,36 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# Dockerfile-комментарий). Когда номера появятся — вписать
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# образа (build-time bake, не runtime-правка на проде).
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
# автоматически, на разных — только через письмо-подтверждение
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
# права и снимает их, если тег исчез.
# Литералы, а не secret'ы: оба значения и так уходят в разметку
# каждой страницы, прятать нечего, а secret'ом их не проверить
# глазами в диффе.
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
# права в Search Console ещё не заведены. Пустая строка =
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
# тот же Dockerfile-комментарий).
# ЛЮБОЕ из этих значений вписывать литералом СЮДА И в retry-блок
# ниже — оба обязательны, иначе ретрай без кеша уедет без счётчика.
# Требует пересборки образа: build-time bake, не runtime-правка.
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_YM_ID=112672927
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
@ -552,9 +565,9 @@ jobs:
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_YM_ID=112672927
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |

View file

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

View file

@ -1,12 +1,17 @@
"""Загрузчик OSM POI из Overpass API для site-finder.
Запускается раз в неделю через Celery beat. Поддерживает фильтр
"не старше 2 лет" (требование Максима) last_osm_edit_date.
Запускается раз в неделю через Celery beat (регион по умолчанию ЕКБ, см. DEFAULT_REGION).
Поддерживает фильтр "не старше 2 лет" (требование Максима) last_osm_edit_date.
Параметризован регионом (REGION_BBOX) sync_poi_to_db(region=...) может грузить любой
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
"""
import asyncio
import json
import logging
import math
from datetime import date, datetime, timedelta
import httpx
@ -19,6 +24,31 @@ logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
"ekb": EKB_BBOX,
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
}
DEFAULT_REGION = "ekb"
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
# региона не меняется.
MAX_TILE_SIDE_DEG = 0.25
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
@ -54,17 +84,119 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
}
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
def _bbox_tiles(
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
(south, west, north, east) список тайлов того же формата. Для bbox, у которого обе
стороны уже max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
ОДНОГО тайла, идентичного входному bbox тайлинг не меняет поведение для ЕКБ.
"""
south, west, north, east = bbox
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
lat_step = (north - south) / rows
lon_step = (east - west) / cols
tiles = []
for r in range(rows):
for c in range(cols):
tiles.append(
(
south + r * lat_step,
west + c * lon_step,
south + (r + 1) * lat_step,
west + (c + 1) * lon_step,
)
)
return tiles
def _split_bbox_quadrants(
bbox: tuple[float, float, float, float],
) -> list[tuple[float, float, float, float]]:
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
south, west, north, east = bbox
mid_lat = (south + north) / 2
mid_lon = (west + east) / 2
return [
(south, west, mid_lat, mid_lon),
(south, mid_lon, mid_lat, east),
(mid_lat, west, north, mid_lon),
(mid_lat, mid_lon, north, east),
]
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
# дефолтного региона не меняется.
RECURSIVE_SPLIT_MAX_DEPTH = 3
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
# 4 успеха — ровно этот механизм.
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
# прогона с явной ошибкой, а не тихий пропуск категорий.
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
class OverpassUnreachableError(RuntimeError):
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
class _RunState:
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
__slots__ = ("consecutive_transport_errors",)
def __init__(self) -> None:
self.consecutive_transport_errors = 0
def _is_overload(exc: Exception) -> bool:
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
if isinstance(exc, httpx.TimeoutException):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _SPLIT_WORTHY_STATUS
return False
def _is_transport_error(exc: Exception) -> bool:
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
def _build_overpass_query(
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
) -> str:
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
в ОДНОМ тайле bbox (south, west, north, east) см. _bbox_tiles.
Раньше делали один большой запрос на все 14 категорий Overpass возвращал
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
быстрые запросы вместо одного 60+ секундного.
"""
south, west, north, east = EKB_BBOX
bbox = f"({south},{west},{north},{east})"
south, west, north, east = bbox
bbox_str = f"({south},{west},{north},{east})"
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
def _classify(tags: dict[str, str]) -> str | None:
@ -80,55 +212,117 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
async def _fetch_category(
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
client: httpx.AsyncClient,
tag_filters: tuple[tuple[str, str], ...],
category: str,
bbox: tuple[float, float, float, float],
state: _RunState,
depth: int = 0,
) -> list[dict]:
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
транзиентной ошибке; если тайл падает оба раза адаптивно дробится на 4 четверти
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
тихо потерять весь тайл.
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
сбоев без риска зациклиться (Overpass rate-limit max 2 concurrent, поэтому не более
2 попыток на категорию).
2 попыток на тайл ДО дробления).
"""
tag_desc = _tag_filters_desc(tag_filters)
query = _build_overpass_query(tag_filters)
query = _build_overpass_query(tag_filters, bbox)
for attempt in (1, 2):
try:
r = await client.post(OVERPASS_URL, data={"data": query})
r.raise_for_status()
elements: list[dict] = r.json().get("elements", [])
logger.info(
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
"Overpass: %s (%s) bbox=%s depth=%d%d [attempt %d]",
tag_desc,
category,
bbox,
depth,
len(elements),
attempt,
)
# Привязываем category именно к тому per-category запросу, под который
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
state.consecutive_transport_errors = 0
for el in elements:
el["_gd_category"] = category
return elements
except Exception as e:
if _is_transport_error(e):
state.consecutive_transport_errors += 1
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
raise OverpassUnreachableError(
f"Overpass отказывает на уровне соединения "
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
f"остановлен, чтобы не стучаться в блокирующий хост"
) from e
logger.warning(
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
"без дробления: %s",
tag_desc,
bbox,
state.consecutive_transport_errors,
e,
)
return []
state.consecutive_transport_errors = 0
if attempt == 1:
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
await asyncio.sleep(3.0)
continue
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
logger.warning(
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
"(depth %d%d) instead of dropping the tile: %s",
tag_desc,
bbox,
depth,
depth + 1,
e,
)
combined: list[dict] = []
for quadrant in _split_bbox_quadrants(bbox):
combined.extend(
await _fetch_category(
client, tag_filters, category, quadrant, state, depth + 1
)
)
await asyncio.sleep(1.0)
return combined
logger.warning(
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
tag_desc,
bbox,
depth,
e,
)
return []
async def fetch_overpass() -> list[dict]:
"""Запросить Overpass API per category, вернуть combined список elements.
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
Делаем отдельные запросы вместо одного гигантского большой запрос
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
policy: max 2 concurrent, лучше 1 req/s).
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
MAX_TILE_SIDE_DEG (_bbox_tiles) для "ekb" это ровно один тайл (без
изменения поведения), для регионов с большим bbox (напр. "msk") несколько,
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
Overpass блокирует default `python-httpx/*` User-Agent (406) поэтому
явный UA с контактом проекта.
"""
bbox = REGION_BBOX[region]
tiles = _bbox_tiles(bbox)
state = _RunState()
headers = {
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
"Accept": "application/json",
@ -136,24 +330,34 @@ async def fetch_overpass() -> list[dict]:
all_elements: list[dict] = []
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
for tag_filters, category in OSM_CATEGORIES.items():
elements = await _fetch_category(client, tag_filters, category)
all_elements.extend(elements)
await asyncio.sleep(1.0)
for tile in tiles:
elements = await _fetch_category(client, tag_filters, category, tile, state)
all_elements.extend(elements)
await asyncio.sleep(1.0)
logger.info(
"Overpass: total %d elements across %d category-queries",
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
region,
len(all_elements),
len(OSM_CATEGORIES),
len(tiles),
)
return all_elements
def sync_poi_to_db() -> dict[str, int]:
"""Синхронизирует POI из Overpass в osm_poi_ekb.
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]:
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона.
region ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
weekly-расписания). Имя таблицы osm_poi_ekb историческое (изначально
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
НЕ переименована: переименование потянуло бы миграции в обеих половинах
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
UPSERT по UNIQUE(osm_type, osm_id, category).
Returns: counters {fetched, inserted, updated, skipped_old}.
"""
elements = asyncio.run(fetch_overpass())
elements = asyncio.run(fetch_overpass(region))
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
two_years_ago = date.today() - timedelta(days=730)

View file

@ -17,12 +17,22 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
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 collections.abc import Iterator
from unittest.mock import patch
from unittest.mock import MagicMock, patch
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):
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 subprocess
import sys
from pathlib import Path
import pytest
@ -72,6 +73,16 @@ GIT_ENV = {
"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:
"""Тело шага, который считает изменённые файлы в job `changes`."""
@ -181,6 +192,7 @@ def _run(
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:
"""Случай #3448 дословно: мерж, в диффе один файл под 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"
@posix_only
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"))
@ -203,6 +216,7 @@ def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
assert outputs["backend"] == "true"
@posix_only
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
@posix_only
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:
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
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:
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
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, "лок открывается, но не захватывается"
@posix_only
@pytest.mark.parametrize(
"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}"
@posix_only
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
@ -350,6 +369,7 @@ def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
assert "изменённых файлов: 0" in log
@posix_only
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
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
@posix_only
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}"
@posix_only
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
"""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_partition_exists_for_every_publishable_quarter
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 stat
import subprocess
import sys
from pathlib import Path
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]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"

View file

@ -0,0 +1,257 @@
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
Mock-based / pure БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
к Overpass в тестах недопустимы). Покрывает:
- REGION_BBOX / DEFAULT_REGION дефолт остаётся "ekb", не меняется молча.
- _bbox_tiles для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы несколько
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
- _build_overpass_query bbox теперь параметр, а не глобальная константа.
- _fetch_category retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
"""
from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from app.services.site_finder.poi_loader import (
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
DEFAULT_REGION,
EKB_BBOX,
RECURSIVE_SPLIT_MAX_DEPTH,
REGION_BBOX,
OverpassUnreachableError,
_bbox_tiles,
_build_overpass_query,
_fetch_category,
_RunState,
_split_bbox_quadrants,
)
class _FakeResponse:
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
класс отказа, ради которого тайл вообще осмысленно дробить."""
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
self._ok = ok
self._elements = elements or []
def raise_for_status(self) -> None:
if not self._ok:
raise httpx.HTTPStatusError(
"simulated Overpass overload",
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
response=httpx.Response(504),
)
def json(self) -> dict:
return {"elements": self._elements}
@pytest.fixture
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
уровне рекурсии)."""
async def _instant(_seconds: float) -> None:
return None
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
def test_default_region_is_ekb_unchanged() -> None:
assert DEFAULT_REGION == "ekb"
assert REGION_BBOX["ekb"] == EKB_BBOX
def test_region_bbox_has_msk_product_core() -> None:
assert "msk" in REGION_BBOX
south, west, north, east = REGION_BBOX["msk"]
assert south < north
assert west < east
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
tiles = _bbox_tiles(EKB_BBOX)
assert tiles == [EKB_BBOX]
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
bbox = REGION_BBOX["msk"]
tiles = _bbox_tiles(bbox)
assert len(tiles) > 1
south, west, north, east = bbox
total_area = (north - south) * (east - west)
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
assert tiles_area == pytest.approx(total_area, rel=1e-9)
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
def test_split_bbox_quadrants_covers_original_area() -> None:
bbox = (55.55, 37.30, 55.95, 37.90)
quads = _split_bbox_quadrants(bbox)
assert len(quads) == 4
south, west, north, east = bbox
total_area = (north - south) * (east - west)
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
assert quads_area == pytest.approx(total_area, rel=1e-9)
# ── _build_overpass_query ─────────────────────────────────────────────────────
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
assert "(1.0,2.0,3.0,4.0)" in q
assert '["amenity"="pharmacy"]' in q
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
if calls["n"] == 1:
return _FakeResponse(ok=False)
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
)
assert calls["n"] == 2
assert len(result) == 1
assert result[0]["_gd_category"] == "pharmacy"
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
instant_sleep: None,
) -> None:
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
query = data["data"]
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
return _FakeResponse(ok=False)
return _FakeResponse(
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
assert calls["n"] == 2 + 4
assert len(result) == 4
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
instant_sleep: None,
) -> None:
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
и возвращает пустой список не зацикливается и не падает."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data # параметр используется — сигнатура должна совпадать с client.post
return _FakeResponse(ok=False)
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
assert calls["n"] == expected_nodes * 2
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
Дробление тут превращает один отказ в 4, 16, 64 повторных стука ровно это
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
"""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
raise httpx.ConnectError("[Errno 101] Network is unreachable")
client = SimpleNamespace(post=fake_post)
result = await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
)
assert result == []
assert calls["n"] == 1 # ни retry, ни четвертей
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
async def fake_post(_url: str, data: dict) -> _FakeResponse:
assert data
raise httpx.ConnectError("connection refused")
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
assert (
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
== []
)
with pytest.raises(OverpassUnreachableError):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
calls = {"n": 0}
async def fake_post(_url: str, data: dict) -> _FakeResponse:
calls["n"] += 1
assert data
if calls["n"] % 2:
raise httpx.ConnectError("hiccup")
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
client = SimpleNamespace(post=fake_post)
state = _RunState()
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
await _fetch_category(
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
)
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS

View file

@ -184,10 +184,13 @@ gendsgn.ru {
}
# 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/* {
root * /srv/preview
file_server browse
file_server
}
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
@ -400,6 +403,36 @@ www.gendsgn.ru {
meraocenka.ru {
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 {
output file /var/log/caddy/meraocenka.ru.log
}
@ -485,7 +518,7 @@ meraocenka.ru {
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{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 {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@ -516,7 +549,7 @@ meraocenka.ru {
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# 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 {
route {
uri strip_suffix /
@ -604,7 +637,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `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 {
route {
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):
"""Маскирует секреты в 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:
message = record.getMessage()
scrubbed = scrub_query_secrets(message)
if scrubbed != message:
record.msg = scrubbed
record.args = ()
args = record.args
if isinstance(args, tuple) and args:
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
# этого кодбейза секрет+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
# ── Секреты в произвольном тексте (тело ответа внешнего 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:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.

View file

@ -29,6 +29,7 @@ from typing import Any
import httpx
from app.core.config import settings
from app.core.log_scrub import scrub_body_secrets
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?)")
return None
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 …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
@ -213,7 +214,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP %d — transient server error", status)
return None
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)
return None
@ -436,7 +437,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP %d — transient server error", status)
return []
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)
return []

View file

@ -328,6 +328,13 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р
_SBER_REGION_SERIES: dict[int, str] = {
66: SBER_TIME_ADJUST_REGION,
77: "Москва",
# Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился
# тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод:
# real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина,
# что у Москвы и обл.66. До этой строки область считалась по общероссийскому
# ряду, то есть коридор ошибался на расхождение области со страной; теперь
# ошибка только внутрирегиональная.
50: "Московская область",
}
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (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 ДКП-сделок; корректное
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002).
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
# Cache key: (region_code, rooms bucket) — #3512. Регион — часть ключа с миграции 304
# (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(
@ -670,6 +679,7 @@ def _get_asking_sold_ratio(
rooms: int | None,
area_m2: float | None = None,
anchor_ppm2: float | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
@ -685,9 +695,24 @@ def _get_asking_sold_ratio(
rooms-ключе vs area-ключе. area_bucket() тот же Python-двойник, что и в
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
(WHERE rooms_bucket = bucket AND district = '') fallback на global -1
(basis='global_fallback'). Ничего нет (None, None).
#3512: region_code — регион ЗАПРОСА (target_region_code резолвера, см.
_ratio_resolver ниже), НЕ дефолт 66 всегда. Раньше ratio считался ИСКЛЮЧИТЕЛЬНО
по ЕКБ и применялся к любому адресу, включая Москву (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 (резолвер
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций см.
@ -695,11 +720,12 @@ def _get_asking_sold_ratio(
статистический артефакт (#2002, см. module-level комментарий выше).
Таблицы нет / любая ошибка (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)
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:
ratio, basis, fetched = cached
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
@ -723,24 +749,39 @@ def _get_asking_sold_ratio(
text(
"""
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()
if row is None:
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1).
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1)
# ТОГО ЖЕ региона (#3512 — никогда чужого региона).
row = db.execute(
text(
"""
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()
if row is not None and row.ratio is not None:
ratio = float(row.ratio)
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
break
except Exception as exc:
@ -769,7 +810,7 @@ def _get_asking_sold_ratio(
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
# уже вернулся ранним return и сюда не доходит.
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
_asking_sold_ratio_cache[cache_key] = (ratio, basis, time.monotonic())
return ratio, basis
@ -5061,7 +5102,11 @@ async def estimate_quality(
appm2: float | None,
) -> tuple[float | None, str | None]:
# #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:
return _lookup_quarter_index(

View file

@ -46,6 +46,8 @@ from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.regions import REGIONS
logger = logging.getLogger(__name__)
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
@ -520,6 +522,62 @@ def upsert_gar_houses(
return upserted
# ─────────────────────────────────────────────────────────────────────────────
# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск
# ─────────────────────────────────────────────────────────────────────────────
# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66).
# Регионы принципиально разные по форме коллизии одноимённых улиц:
# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был
# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities —
# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр
# нужен и достаточен.
# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]).
# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников,
# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей
# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ
# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL
# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не
# угадывается по max(flat_count).
# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового
# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра
# берётся из REGIONS[<code>].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ
# покрытия) — не второй раз хардкодится строкой "Екатеринбург".
REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66})
class CityFilterAutoType:
"""Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar."""
__slots__ = ()
def __repr__(self) -> str:
return "CITY_FILTER_AUTO"
# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»).
CITY_FILTER_AUTO = CityFilterAutoType()
def default_city_filter_for_region(region_code: str | None) -> str | None:
"""Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER).
region 66 "Екатеринбург" (byte-for-byte прежнее поведение, значение из
REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион None
(без ограничения). region_code принимает и None, и нечисловую строку не
ошибка, просто «не знаем региона» без фильтра.
"""
try:
code = int(region_code) if region_code is not None else None
except (TypeError, ValueError):
return None
if code is None or code not in REGIONS_REQUIRING_CITY_FILTER:
return None
region = REGIONS.get(code)
if region is None:
return None
return region.city_token.capitalize()
# ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses
# ─────────────────────────────────────────────────────────────────────────────
@ -529,16 +587,26 @@ def upsert_gar_houses(
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
#
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) —
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в
# 4 городах region 66 даёт ложные коллизии.
#
# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города,
# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы
# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча
# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому
# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже
# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки.
_MATCH_SQL = text(
"""
WITH gar_pick AS (
SELECT DISTINCT ON (canon)
WITH gar_scope AS (
SELECT
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
FROM gar_house_flats
WHERE flat_count > 0
@ -551,7 +619,21 @@ _MATCH_SQL = text(
CAST(:city AS text) IS NULL
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
)
ORDER BY canon, flat_count DESC, house_guid
),
gar_ranked AS (
SELECT
canon, house_guid, flat_count,
ROW_NUMBER() OVER (
PARTITION BY canon ORDER BY flat_count DESC, house_guid
) AS rn,
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
FROM gar_scope
),
gar_pick AS (
SELECT canon, house_guid, flat_count
FROM gar_ranked
WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
)
UPDATE houses h
SET gar_house_guid = gp.house_guid,
@ -571,15 +653,19 @@ def match_houses_to_gar(
db: Session,
*,
region_code: str | None = None,
city_filter: str | None = "Екатеринбург",
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
) -> int:
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
за пределами ЕКБ future work). Возвращает число обновлённых домов. Идемпотентно
(plain UPDATE, IS DISTINCT FROM gate).
city_filter: CITY_FILTER_AUTO (умолч.) резолвится по region_code через
default_city_filter_for_region (region 66 «Екатеринбург», иначе None). Явный
None отключает фильтр НЕЗАВИСИМО от региона; явная строка ILIKE-override
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта
отдельно см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов.
Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
"""
if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code)
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount
logger.info(

View file

@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение).
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
# область"` для области и `state="Москва"` для города. Маркер "московская" не
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
logger = logging.getLogger(__name__)
@ -962,7 +966,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
_DADATA_REGION_NAMES: dict[int, str] = {
66: SVERDLOVSK_OBLAST_REGION,
77: "Москва",
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
50: "Московская",
}
def _dadata_region_name(region_code: int) -> str:
@ -1106,7 +1115,14 @@ async def _nominatim_query_city_aware(
)
if city_specified:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
if not _ALL_REGIONS[region_code].has_city_core:
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Дальше — регион с ядром: dual-query с суффиксом главного города
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(

View file

@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel):
sample_size: int
radius_m: int
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:
@ -207,9 +208,27 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта.
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
# даже если сам продукт уже скрейпит соседние города/области (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(
"""
SELECT
@ -223,6 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND (geo_precision IS DISTINCT FROM 'city')
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
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 CAST(:bbox_north 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 scraped_at > NOW() - (:fresh_days || ' days')::interval
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 CAST(:bbox_north 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(
"""
SELECT
@ -293,6 +325,7 @@ def _local_median_ppm2(
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"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,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"region_code": region.code,
"bbox_south": lat_min,
"bbox_north": lat_max,
"bbox_west": lon_min,
@ -333,18 +367,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
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]:
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
([], "unavailable") вместо 500 или сфабрикованного списка.
Graceful fallback ([], "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
if total == 0:
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
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(
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
"environment) — nearby_poi unavailable, no fabricated factors"
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
"no fabricated factors",
region.code,
)
return [], "unavailable"
@ -423,7 +477,7 @@ def compute_location_index(
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)
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 (
_pick_resume as kit_pick_resume,
)
from scraper_kit.orchestration.scheduler import (
_resolve_region_code as kit_resolve_region_code,
)
if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext
@ -236,6 +239,12 @@ async def _job_deactivate_stale(
listing_source: str = params.get("listing_source", "avito")
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
segments: list[str] | None = params.get("segments")
# Регионы снятия. None (ключа нет в default_params) -> дефолт самой джобы,
# DEFAULT_DEACTIVATION_REGION_CODES=(66,): снимать устаревшее можно только там,
# где идёт регулярный пересбор -- см. развёрнутый комментарий у константы в
# deactivate_stale_avito.py. Расширять список ВМЕСТЕ с запуском сбора по
# региону, ключом "region_codes" (jsonb-массив кодов) в строке расписания.
region_codes: list[int] | None = params.get("region_codes")
staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
@ -275,6 +284,7 @@ async def _job_deactivate_stale(
listing_source=listing_source,
ttl_days=ttl_days,
segments=segments,
region_codes=region_codes,
staleness_column=staleness_column,
min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile,
@ -325,6 +335,34 @@ async def _job_landing_stats(
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 ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -487,6 +525,7 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)),
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),
cookies=cookies,
)
@ -911,6 +950,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"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"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

File diff suppressed because one or more lines are too long

View file

@ -10,13 +10,23 @@
Регион 50 (Московская область) отложен сознательно обоснование в #2996:
10 121 текстовое имя города против 612 у Москвы, вся мина имён в области.
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
прямоугольнику `bbox_region` прямоугольники Москвы и области пересекались
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
Модуль ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
geocoder / location_index / matching.normalize, циклы недопустимы).
geocoder / location_index / matching.normalize, циклы недопустимы). json/
pathlib стандартная библиотека, листовость не нарушают.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
@ -61,6 +71,15 @@ class Region:
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника.
has_city_core есть ли у региона ОДИН город-ядро, имя которого допустимо
молча подставлять в запрос геокодера, когда город не назван
(dual-query `_nominatim_query_city_aware`, ветка «город
неизвестен»). True у 66/77 (Екатеринбург / Москва там это
majority-трафик). False у 50: у области 20 сопоставимых
городов и ~970 населённых пунктов в сырье, подстановка
«Красногорск» к «Сабурово, Луговая» ровно та же ловушка
одноимённых улиц, от которой отказались в #2576.
canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение
@ -82,6 +101,7 @@ class Region:
cities: frozenset[str]
enrichment_tiers: frozenset[str]
canonical_city: str | None = None
has_city_core: bool = True
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -90,6 +110,213 @@ def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
#
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
# вызов region_for_point был бы дороже самого ray casting.
#
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
# областью. Сверка резолва против сохранённого region_code по проду (277 582
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
#
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
# в одно кольцо — нечётность не портится соседними частями, которые точка не
# задевает) — без явного разделения "внешний контур минус дыры".
Ring = list[tuple[float, float]]
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
coords = geometry["coordinates"]
if geometry["type"] == "Polygon":
polygons = [coords]
elif geometry["type"] == "MultiPolygon":
polygons = coords # type: ignore[assignment]
else:
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
rings: list[Ring] = []
for polygon in polygons:
for ring in polygon: # type: ignore[union-attr]
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
return rings
def _load_region_boundaries() -> dict[int, list[Ring]]:
"""code региона → плоский список колец его полигона/мультиполигона."""
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
payload = json.load(f)
return {
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
}
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
class _IndexedRing(NamedTuple):
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
Наивный ray casting линеен по числу вершин, а вершин после отказа от
упрощения в 18 раз больше (42 755 против 2 388) прямым перебором это
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
lat_min/lat_max/lon_min/lon_max bbox САМОГО кольца (не региона): у
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
и точка почти всегда вне bbox большинства колец такое кольцо
отвергается четырьмя сравнениями вместо обхода вершин.
band_h/bands кольцо нарезано на горизонтальные полосы по широте; в
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
попадает во все свои полосы, поэтому пересечения не теряются).
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
своей полосы обход сокращается с «все вершины кольца» до «рёбра
одной полосы» (для внешнего контура области 23 511 максимум 724,
типично десятки).
points те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
нужны fallback'у по расстоянию до границы в region_for_point.
"""
lat_min: float
lat_max: float
lon_min: float
lon_max: float
band_h: float
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
points: Ring
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
# ограничивает память на самых длинных кольцах (внешний контур области).
_RING_BAND_DENSITY = 4
_RING_MAX_BANDS = 1024
def _index_ring(points: Ring) -> _IndexedRing:
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
lat_min = min(p[1] for p in points)
lat_max = max(p[1] for p in points)
lat_span = lat_max - lat_min
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
x_prev, y_prev = points[-1]
for x, y in points:
edge = (x, y, x_prev, y_prev)
lo = int((min(y, y_prev) - lat_min) / band_h)
hi = int((max(y, y_prev) - lat_min) / band_h)
lo = 0 if lo < 0 else min(lo, n_bands - 1)
hi = 0 if hi < 0 else min(hi, n_bands - 1)
for band in range(lo, hi + 1):
buckets[band].append(edge)
x_prev, y_prev = x, y
return _IndexedRing(
lat_min=lat_min,
lat_max=lat_max,
lon_min=min(p[0] for p in points),
lon_max=max(p[0] for p in points),
band_h=band_h,
bands=tuple(tuple(b) for b in buckets),
points=points,
)
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
}
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
return False
band = int((lat - lat_min) / band_h)
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
band = len(bands) - 1
inside = False
for xi, yi, xj, yj in bands[band]:
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
"""True если (lat, lon) внутри настоящей границы региона `code`.
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
Региона без загруженной границы граничит только по bbox (см. вызов в
region_for_point), сюда такой код не попадает."""
inside = False
for ring in _REGION_RING_INDEX[code]:
if _point_in_ring(ring, lon, lat):
inside = not inside
return inside
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
"""Расстояние от точки до ближайшего ребра границы региона `code`.
В градусах широты (1° 111 км) по ПЛОСКОМУ приближению с поправкой
cos(lat) на сжатие долготы: значение сравнивается только с таким же
значением другого региона-кандидата на тех же ~десятках метров, где
геодезия и плоскость расходятся на доли процента.
Путь редкий (только щель между полигонами соседних регионов, см.
region_for_point), поэтому обход честный по всем рёбрам, без полос.
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
дальше найденного минимума, не может дать ребра ближе.
"""
lat_scale = math.cos(math.radians(lat))
px = lon * lat_scale
best = math.inf
ranked: list[tuple[float, _IndexedRing]] = []
for ring in _REGION_RING_INDEX[code]:
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
ranked.append((dx * dx + dy * dy, ring))
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
if lower_bound >= best:
break
ax, ay = ring.points[-1]
ax *= lat_scale
for bx_raw, by in ring.points:
bx = bx_raw * lat_scale
dx, dy = bx - ax, by - ay
if dx or dy:
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
ex = ax + t * dx - px
ey = ay + t * dy - lat
else: # выродившееся в точку ребро (дубль вершины в OSM)
ex, ey = ax - px, ay - lat
dist = ex * ex + ey * ey
if dist < best:
best = dist
ax, ay = bx, by
return math.sqrt(best)
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation"
@ -233,14 +460,20 @@ REGIONS: dict[int, Region] = {
),
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
# намеренно, не заглушка). Ряд Сбериндекса по области загружен в
# источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50
# ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением.
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
# за пределами этого реестра не спрашивают).
enrichment_tiers=frozenset(),
# Источники по области несут настоящий city (Химки, Балашиха — не
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
# нечего и незачем, в отличие от 77.
canonical_city=None,
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
# Красногорске, и в Сабурово, и ещё в десятке НП области.
has_city_core=False,
),
}
@ -260,36 +493,76 @@ def _bbox_area(bbox: BBox) -> float:
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
#
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
# наступил прямо при добавлении региона 50, не гипотетически.
# #3052: сам резолв региона идёт по настоящему полигону (see
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
# отсев: bbox_region(50) (Московская область целиком, lat 54.20..56.96/
# lon 35.14..40.21) геометрически СОДЕРЖИТ bbox_region(77) (Москва,
# 55.10..56.10/36.80..38.10) как прямоугольники, а 50 < 77 по числовому коду.
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
# systematic overhead на самом частом случае (Москва/область — соседи).
#
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
# новый регион сам встанет в верную позицию по своей площади — правку этого
# места повторять не придётся.
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (77 внутри
# 50) почти всегда получает свою точку дешевле, а новый регион сам встанет в
# верную позицию по своей площади.
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
)
def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
"""Регион покрытия, которому принадлежит точка, или None (вне охвата).
Обход `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
не числовой код региона: код как ключ порядка ломается ровно на паре
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
Два уровня отсева на каждого кандидата (порядок `_POINT_LOOKUP_ORDER`,
компактный bbox_region раньше обширного, см. комментарий там):
1. `is_within_bbox` по `bbox_region` дешёвый прямоугольный предфильтр,
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
2. Полигон (`_point_in_region_polygon`) настоящая граница, решает
результат. Если bbox прошёл, а полигон точку не принял (точка в
прямоугольнике области, но не в её реальных границах то есть,
например, внутри вырезанной дыры Москвы), идём к следующему
кандидату, а не возвращаем None сразу.
None ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
становится кандидат, к ГРАНИЦЕ которого точка ближе
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
как None, а тот (`trade_in.py`: `target_region.code if target_region else
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию адрес в
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
полигонами соседних регионов неизбежны (у Москвы и области это независимо
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
общая), и закрывать их возвратом к прямоугольнику нельзя прямоугольники
перекрываются и врут ровно там, ради чего #3052 и делался.
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
boundaries.geojson.json) на практике до fallback-ветки дело не доходит,
но она есть, чтобы новый регион без границы не «пропадал» молча, а
работал по старому bbox-поведению до того, как для него добавят полигон.
"""
near_miss: list[int] = []
for code in _POINT_LOOKUP_ORDER:
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
return REGIONS[code]
return None
region = REGIONS[code]
if not is_within_bbox(lat, lon, region.bbox_region):
continue
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
return region
near_miss.append(code)
if not near_miss:
return None
if len(near_miss) == 1:
return REGIONS[near_miss[0]]
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
return REGIONS[nearest]
def region_by_city(city: str | None) -> Region | None:

View file

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

View file

@ -51,6 +51,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
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
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
@ -68,13 +69,29 @@ _PPM2_MIN: int = 30_000
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
# ниже) — симметрично deal-стороне.
#
# #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 = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# 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 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
@ -108,14 +125,26 @@ def area_bucket(area_m2: float) -> int:
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, пока всегда '').
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
# только что вставленные строки другого региона на повторном прогоне того же transaction).
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
_DELETE_SQL = text(
"""
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,372 @@ _REDERIVE_SQL = text(
)
INSERT INTO asking_to_sold_ratios (
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,
n_deals, n_listings, window_months, basis FROM global_row
n_deals, n_listings, window_months, basis, 66 FROM global_row
UNION ALL
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
"""
)
# ── Geography-matched per-region derivation (#3529) ───────────────────────────
# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
# 30-40 км: 4 132 / 10 206 → 0.847
# 50-60 км: 1 432 / 5 243 → 0.951
# 70-80 км: 150 / 2 294 → 0.688
# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул
# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это
# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена.
#
# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются
# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог
# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона
# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask),
# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median,
# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами.
#
# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583),
# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше).
# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на
# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°).
# Почему именно так:
# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт
# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка
# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений.
# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего
# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый
# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен.
# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50
# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996,
# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО.
# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки
# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно,
# что ОБЕ стороны режутся ОДИНАКОВО.
_CELL_LAT_DEG: float = 0.1
_CELL_LON_DEG: float = 0.2
# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась
# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не
# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер
# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже).
_CELL_MIN_DEALS: int = 10
_CELL_MIN_LISTINGS: int = 10
# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки
# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана
# шум, и при большом весе этот шум попадёт в итоговую строку.
_CELL_BUCKET_MIN_DEALS: int = 5
_CELL_BUCKET_MIN_LISTINGS: int = 5
# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет —
# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента),
# чем посчитать неверно и выглядеть уверенно.
_MIN_MATCHED_CELLS: int = 3
# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция
# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения
# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик.
_MIN_DEAL_CELL_COVERAGE: float = 0.5
# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат,
# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку.
_MIN_GEOM_COVERAGE: float = 0.5
# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше
# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера),
# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики
# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи).
_GEOM_WARN_SHARE: float = 0.25
# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики
# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же,
# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186,
# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование.
_DEAL_FROM_WHERE_REGION = """
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_FROM_WHERE_REGION = """
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)
"""
# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации,
# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются.
_CELL_CTES_REGION = f"""
deal_geo AS (
SELECT
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
price_per_m2
{_DEAL_FROM_WHERE_REGION} AND geom IS NOT NULL
),
ask_geo AS (
SELECT
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
price_per_m2
{_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL
),
deal_cell AS (
SELECT
cell_lat, cell_lon,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deal_geo
GROUP BY cell_lat, cell_lon
),
ask_cell AS (
SELECT
cell_lat, cell_lon,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM ask_geo
GROUP BY cell_lat, cell_lon
),
-- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу.
matched_cell AS (
SELECT
d.cell_lat, d.cell_lon,
d.sold_median, d.n_deals,
a.ask_median, a.n_listings
FROM deal_cell d
JOIN ask_cell a USING (cell_lat, cell_lon)
WHERE d.n_deals >= {_CELL_MIN_DEALS}
AND a.n_listings >= {_CELL_MIN_LISTINGS}
AND d.sold_median IS NOT NULL AND d.sold_median > 0
AND a.ask_median IS NOT NULL AND a.ask_median > 0
)"""
# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще.
# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это
# должно быть видно в счётчиках, а не тихо (пункт 3 задачи).
_GEO_STATS_SQL_REGION = text(
f"""
WITH{_CELL_CTES_REGION},
deal_all AS (
SELECT
COUNT(*) AS n_all,
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
{_DEAL_FROM_WHERE_REGION} ),
ask_all AS (
SELECT
COUNT(*) AS n_all,
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
{_ASK_FROM_WHERE_REGION} )
SELECT
da.n_all AS deals_total,
da.n_geo AS deals_geo,
aa.n_all AS listings_total,
aa.n_geo AS listings_geo,
(SELECT COUNT(*) FROM deal_cell) AS cells_deal,
(SELECT COUNT(*) FROM ask_cell) AS cells_ask,
(SELECT COUNT(*) FROM deal_cell d
JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides,
(SELECT COUNT(*) FROM matched_cell) AS cells_matched,
(SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells,
(SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells
FROM deal_all da CROSS JOIN ask_all aa
"""
)
# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два:
# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512);
# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529).
# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние.
_REDERIVE_SQL_REGION = text(
f"""
WITH{_CELL_CTES_REGION},
-- Внутри УЖЕ отобранных ячеек разрез по бакету комнат (обе стороны тот же набор
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении в отличие от старого
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
deal_cell_bucket AS (
SELECT
g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deal_geo g
JOIN matched_cell m USING (cell_lat, cell_lon)
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
),
ask_cell_bucket AS (
SELECT
g.cell_lat, g.cell_lon, g.rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM ask_geo g
JOIN matched_cell m USING (cell_lat, cell_lon)
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
),
bucket_cell AS (
SELECT
d.rooms_bucket,
d.sold_median, d.n_deals,
a.ask_median, a.n_listings
FROM deal_cell_bucket d
JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
AND d.sold_median IS NOT NULL AND d.sold_median > 0
AND a.ask_median IS NOT NULL AND a.ask_median > 0
),
-- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
-- ratio == sold_median/ask_median построчно (оба взвешенные средние с ОДНИМИ весами),
-- так что публикуемые столбцы остаются взаимно согласованными.
per_bucket AS (
SELECT
rooms_bucket,
''::text AS district,
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
AS numeric) AS ratio,
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
SUM(n_deals)::int AS n_deals,
SUM(n_listings)::int AS n_listings,
12 AS window_months,
'per_rooms'::text AS basis
FROM bucket_cell
GROUP BY rooms_bucket
-- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше теперь на сумме по ячейкам.
HAVING SUM(n_deals) >= 30
AND SUM(n_listings) >= 30
AND SUM(ask_median * n_deals) > 0
),
-- Global -1 fallback те же ячейки, но без разреза по комнатности.
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
AS numeric) AS ratio,
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
SUM(n_deals)::int AS n_deals,
SUM(n_listings)::int AS n_listings,
12 AS window_months,
'global_fallback'::text AS basis
FROM matched_cell
HAVING SUM(n_deals) > 0
AND SUM(ask_median * n_deals) > 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
"""
)
# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не
# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе
# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в
# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя:
# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и
# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647.
def _pct(part: float, whole: float) -> int:
"""Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int])."""
if whole <= 0:
return 0
return round(100.0 * part / whole)
def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]:
"""Писать ли строки региона по согласованной географии (пункт 6 — явная деградация).
Возвращает (ok, reason). ok=False регион НЕ получает НИ ОДНОЙ строки (старые всё
равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного.
Чистая функция от строки stats тестируется без базы.
"""
if not stats:
return False, "geo-stats не вернулись"
deals_total = int(stats.get("deals_total") or 0)
deals_geo = int(stats.get("deals_geo") or 0)
listings_total = int(stats.get("listings_total") or 0)
listings_geo = int(stats.get("listings_geo") or 0)
cells_matched = int(stats.get("cells_matched") or 0)
deals_in_cells = int(stats.get("deals_in_cells") or 0)
if deals_total == 0 or listings_total == 0:
return False, f"нет данных: deals={deals_total} listings={listings_total}"
if deals_geo / deals_total < _MIN_GEOM_COVERAGE:
return False, (
f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% "
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
)
if listings_geo / listings_total < _MIN_GEOM_COVERAGE:
return False, (
f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% "
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
)
if cells_matched < _MIN_MATCHED_CELLS:
return False, (
f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} "
f"(географии сделок и объявлений практически не пересекаются)"
)
if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE:
return False, (
f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок "
f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})"
)
_ = region_code
return True, "ok"
def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]:
"""Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int."""
s = stats or {}
deals_total = int(s.get("deals_total") or 0)
deals_geo = int(s.get("deals_geo") or 0)
listings_total = int(s.get("listings_total") or 0)
listings_geo = int(s.get("listings_geo") or 0)
deals_in_cells = int(s.get("deals_in_cells") or 0)
listings_in_cells = int(s.get("listings_in_cells") or 0)
cells_matched = int(s.get("cells_matched") or 0)
cells_both = int(s.get("cells_both_sides") or 0)
p = f"geo_r{region_code}_"
return {
p + "cells_deal": int(s.get("cells_deal") or 0),
p + "cells_ask": int(s.get("cells_ask") or 0),
p + "cells_matched": cells_matched,
# Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки.
p + "cells_dropped": max(cells_both - cells_matched, 0),
# Сколько массы осталось ЗА пределами пересечения (от строк с geom).
p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo),
p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo),
# Строки без координат — не выпадают молча (пункт 3).
p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total),
p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total),
}
# ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
_COUNTERS_SQL = text(
"""
SELECT
@ -292,13 +674,22 @@ _COUNTERS_SQL = text(
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).
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh):
1. DELETE FROM asking_to_sold_ratios WHERE district = '' снести stale-строки.
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
В ОДНОЙ транзакции (атомарно таблица никогда не пуста mid-refresh для уже
посчитанных регионов):
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') 080-derivation
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.
@ -320,10 +711,15 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"rows_written": 0,
"per_rooms_rows": 0,
"used_global_fallback": 0,
# #3529: сколько регионов посчитано по согласованной географии, а сколько
# деградировало явно (строк нет → эстиматор без коэффициента).
"geo_regions_written": 0,
"geo_regions_skipped": 0,
}
try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
# таблица не должна остаться пустой, если INSERT упадёт).
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
db.execute(_DELETE_SQL)
db.execute(
_REDERIVE_SQL,
@ -334,6 +730,58 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
for region_code in _OTHER_REGION_CODES:
params = {
"region_code": region_code,
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"fresh_days": LISTINGS_FRESH_DAYS,
}
# Сначала состав выборки (#3529) — он же решает, писать ли регион вообще.
stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first()
stats = dict(stats_row) if stats_row is not None else None
counters.update(_geo_region_counters(region_code, stats))
ok, reason = geo_region_verdict(region_code, stats)
# DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый
# (считанный по пулу) коэффициент тем более не должен оставаться в таблице.
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0)
no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0)
if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
# Пункт 3: строки без координат не выпадают молча — это сигнал.
logger.warning(
"asking_to_sold_ratio region_code=%d: без geom сделок %d%%, "
"объявлений %d%%гео-согласование считается по остатку",
region_code,
no_geom_deals,
no_geom_listings,
)
if not ok:
counters["geo_regions_skipped"] += 1
counters[f"geo_r{region_code}_skipped"] = 1
logger.warning(
"asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. "
"Оценка останется без коэффициента (явная деградация)",
region_code,
reason,
)
continue
counters[f"geo_r{region_code}_skipped"] = 0
counters["geo_regions_written"] += 1
db.execute(_REDERIVE_SQL_REGION, params)
logger.info(
"asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d "
"(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%",
region_code,
counters.get(f"geo_r{region_code}_cells_matched", 0),
counters.get(f"geo_r{region_code}_cells_dropped", 0),
counters.get(f"geo_r{region_code}_deals_outside_pct", 0),
counters.get(f"geo_r{region_code}_listings_outside_pct", 0),
)
row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None:

View file

@ -488,12 +488,39 @@ def _build_confirmations_sql(
)
# ── Регионы, где снятие устаревших вообще осмысленно ──────────────────────────
# Снятие читает МОЛЧАНИЕ как «объявление ушло»: строку давно не видели свежей ->
# is_active=false. Такой вывод верен ТОЛЬКО там, где есть механизм пересбора: без
# регулярного обхода «давно не видели» неотличимо от «мы туда ни разу не ходили
# после разовой загрузки», и джоба снимет разом весь загруженный корпус региона.
#
# На 15.09.2026 регулярный сбор идёт только по Свердловской области (66): реестр
# якорей CITY_ANCHORS в ките покрывает её одну, у ДомКлика гео-скоуп зашит в ЕКБ.
# Объявления регионов 77 и 50 попали в listings разовой ручной загрузкой 10-12.09
# и с тех пор не обновлялись. Без фильтра ниже около 26.09 кандидатами разом стали
# бы 40 410 строк ДомКлика (ttl_days=14 по scraped_at), следом 60 174 cian и
# 24 152 yandex (ttl_days=30). Пострадала бы не только Москва с областью:
# аварийный потолок max_deactivated (DEFAULT_MAX_DEACTIVATED=15000, см. ниже)
# сработал бы раньше, а прогон при skipped_cap_exceeded НЕ ТРОГАЕТ НИ ОДНОЙ
# строки -- то есть чистка Екатеринбурга встала бы вместе с московской.
#
# Отсюда дефолт «снимаем только там, где сами собираем». Расширять список нужно
# ВМЕСТЕ с появлением расписания регулярного сбора по региону (якоря кита +
# гео-скоуп ДомКлика), а не раньше: пока пересбора нет, нечем отличить ушедшее
# объявление от неопрошенного. Переопределяется ключом "region_codes" в
# default_params строки расписания (jsonb-массив целых кодов регионов).
DEFAULT_DEACTIVATION_REGION_CODES: tuple[int, ...] = (66,)
def _build_all_segments_sql(staleness_column: str) -> Any:
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
:ttl_days, :run_id) остаются param-binding psycopg v3 safe (никаких :param::type).
:region_codes, :ttl_days, :run_id) остаются param-binding psycopg v3 safe
(никаких :param::type). :region_codes -- см. DEFAULT_DEACTIVATION_REGION_CODES:
снятие идёт только по регионам с регулярным пересбором. NULL region_code не
матчится ANY(...) намеренно -- неизвестный регион нечем отнести к покрытому.
"""
return text(
f"""
@ -501,6 +528,7 @@ def _build_all_segments_sql(staleness_column: str) -> Any:
UPDATE listings
SET is_active = false
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
RETURNING id, price_rub
@ -514,7 +542,8 @@ def _build_segments_sql(staleness_column: str) -> Any:
"""UPDATE с фильтром по сегменту (segments задан): только указанные сегменты.
staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[]))
-- psycopg v3 адаптирует Python list -> text[].
-- psycopg v3 адаптирует Python list -> text[]; то же для :region_codes -> int[]
(см. DEFAULT_DEACTIVATION_REGION_CODES).
"""
return text(
f"""
@ -522,6 +551,7 @@ def _build_segments_sql(staleness_column: str) -> Any:
UPDATE listings
SET is_active = false
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment = ANY(CAST(:segments AS text[]))
@ -541,7 +571,8 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе,
но с :region_codes (см. DEFAULT_DEACTIVATION_REGION_CODES).
"""
return text(
f"""
@ -549,6 +580,7 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
UPDATE listings
SET is_active = false
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL
@ -639,13 +671,16 @@ def _build_all_segments_candidates_count_sql(staleness_column: str) -> Any:
_build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов
вне скоупа PR-B. Синхронность с UPDATE закреплена тестом
test_candidates_predicate_matches_update_predicate
(tests/test_deactivate_stale_deactivation_cap.py).
(tests/test_deactivate_stale_deactivation_cap.py). Фильтр по :region_codes
обязан стоять и здесь: считать кандидатов по всем регионам, а снимать по
покрытым сбором -- значит кормить аварийный потолок чужими числами.
"""
return text(
f"""
SELECT count(*)
FROM listings
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
"""
@ -661,6 +696,7 @@ def _build_segments_candidates_count_sql(staleness_column: str) -> Any:
SELECT count(*)
FROM listings
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment = ANY(CAST(:segments AS text[]))
@ -677,6 +713,7 @@ def _build_null_segment_candidates_count_sql(staleness_column: str) -> Any:
SELECT count(*)
FROM listings
WHERE source = :listing_source
AND region_code = ANY(CAST(:region_codes AS int[]))
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL
@ -691,6 +728,7 @@ def deactivate_stale_listings(
listing_source: str,
ttl_days: int,
segments: list[str] | None = None,
region_codes: list[int] | None = None,
staleness_column: str = "last_seen_at",
min_confirmations: int = 0,
health_window_days: int = _HEALTH_WINDOW_DAYS,
@ -710,6 +748,24 @@ def deactivate_stale_listings(
segments: если задан -- деактивировать только объявления с указанными
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
Несовместимо с null_segment_only=True (см. ниже).
region_codes: регионы (listings.region_code), в которых снятие вообще
разрешено. None -> DEFAULT_DEACTIVATION_REGION_CODES = (66,), то есть
только Свердловская область. ЭТО НЕ КОСМЕТИЧЕСКИЙ ФИЛЬТР: снятие
трактует молчание как «объявление ушло», а это верно лишь там, где
есть механизм пересбора -- без регулярного обхода «давно не видели»
неотличимо от «мы туда не ходили с разовой загрузки» (см. подробный
комментарий у DEFAULT_DEACTIVATION_REGION_CODES: что именно случилось
бы с корпусом 77/50 и почему вместе с ним встала бы чистка ЕКБ).
Расширять список нужно ВМЕСТЕ с запуском регулярного сбора по региону,
через ключ "region_codes" в default_params строки расписания. None
означает дефолт, а НЕ «без фильтра»: отключить региональное
ограничение целиком нельзя намеренно -- только перечислить регионы
явно. Строки с region_code IS NULL не снимаются никогда (ANY(...) не
матчит NULL): неизвестный регион нечем отнести к покрытому сбором.
Гейты здоровья/пола (min_confirmations, revisit_floor_quantile) и
active_pool ОСТАЮТСЯ общими по источнику, без региона -- их пороги
откалиброваны на источнике целиком, региональная нарезка потребовала
бы пересчёта калибровки и здесь не делается.
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
{"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
@ -815,7 +871,11 @@ def deactivate_stale_listings(
min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ
любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false
вместо числа, что и у ttl_days/cap_mult выше -- default_params
расписания это единственный запланированный способ их переопределить).
расписания это единственный запланированный способ их переопределить),
ЛИБО region_codes -- пустой список (= ANY(ARRAY[]) не матчит ничего,
прогон молча снял бы 0 и выглядел бы здоровым) или содержит не-int /
bool (та же jsonb-опечатка, что выше: "region_codes": [true] прошло бы
как [1] -- регион 1 существует).
"""
counters: dict[str, int] = {"deactivated": 0}
try:
@ -850,9 +910,7 @@ def deactivate_stale_listings(
raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}")
if isinstance(floor_drop_ratio, bool):
raise ValueError(
f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}"
)
raise ValueError(f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}")
if floor_drop_ratio < 1:
raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}")
@ -876,6 +934,21 @@ def deactivate_stale_listings(
if null_segment_only and segments is not None:
raise ValueError("null_segment_only=True несовместимо с заданным segments")
# Регионы снятия (см. DEFAULT_DEACTIVATION_REGION_CODES): None -> дефолт,
# а НЕ «без фильтра» -- снимать можно только там, где идёт пересбор.
# Пустой список отвергаем явно: он матчил бы 0 строк, и прогон выглядел бы
# здоровым (deactivated=0) вместо того, чтобы упасть на очевидной ошибке
# конфигурации. bool отсекаем до isinstance(int), как у ttl_days/cap_mult
# выше: jsonb `true` прошёл бы как регион 1.
effective_region_codes: list[int] = (
list(DEFAULT_DEACTIVATION_REGION_CODES) if region_codes is None else list(region_codes)
)
if not effective_region_codes:
raise ValueError("region_codes must be a non-empty list, got empty")
for code in effective_region_codes:
if isinstance(code, bool) or not isinstance(code, int):
raise ValueError(f"region_codes must contain ints, got {code!r}")
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
# на практике (вернуть «живость» может только повторный сбор), поэтому
# проверяем ПЕРЕД записью, а не откатываем после.
@ -1060,6 +1133,9 @@ def deactivate_stale_listings(
preflight_params: dict[str, Any] = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
# Тот же срез регионов, что и у UPDATE ниже -- иначе потолок
# считал бы кандидатов по регионам, которых снятие не коснётся.
"region_codes": effective_region_codes,
}
if null_segment_only:
candidates_sql = _build_null_segment_candidates_count_sql(staleness_column)
@ -1108,6 +1184,7 @@ def deactivate_stale_listings(
params: dict[str, Any] = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"run_id": run_id,
}
result = db.execute(_build_null_segment_sql(staleness_column), params)
@ -1115,6 +1192,7 @@ def deactivate_stale_listings(
params = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"segments": segments,
"run_id": run_id,
}
@ -1123,6 +1201,7 @@ def deactivate_stale_listings(
params = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"region_codes": effective_region_codes,
"run_id": run_id,
}
result = db.execute(_build_all_segments_sql(staleness_column), params)
@ -1133,14 +1212,15 @@ def deactivate_stale_listings(
runs_mod.mark_done(db, run_id, counters)
logger.info(
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
"(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
"staleness_column=%s)",
"(ttl_days=%d эффективный, задан %d, segments=%r, region_codes=%r, "
"null_segment_only=%s, staleness_column=%s)",
listing_source,
run_id,
counters["deactivated"],
effective_ttl_days,
ttl_days,
segments,
effective_region_codes,
null_segment_only,
staleness_column,
)

View file

@ -3,6 +3,8 @@
Запуск (каталог УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
Делает два шага в одной транзакционной сессии:
1. load_gar_region стриминговый парс XML UPSERT gar_house_flats (коммит).
@ -11,11 +13,19 @@
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
python -m app.tasks.gar_flats_load --match-only --region 66
python -m app.tasks.gar_flats_load --match-only --region 77
python -m app.tasks.gar_flats_load --match-only --region 50
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
docs/gar-flats-runbook.md) этот лоадер потребляет уже распакованные XML локально.
Город-фильтр матча (--city) по умолчанию НЕ вводится руками на каждый запуск берётся
ПО РЕГИОНУ (см. app.services.gar_flats_loader.default_city_filter_for_region): region 66
получает byte-for-byte прежний фильтр «Екатеринбург», остальные регионы (77, 50 и любой
новый) без фильтра. `--city ""` явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66);
`--city "Имя"` явный override.
"""
from __future__ import annotations
@ -25,21 +35,23 @@ import logging
from datetime import date
from app.core.db import SessionLocal
from app.services.gar_flats_loader import load_gar_region, match_houses_to_gar
from app.services.gar_flats_loader import (
CITY_FILTER_AUTO,
CityFilterAutoType,
default_city_filter_for_region,
load_gar_region,
match_houses_to_gar,
)
logger = logging.getLogger(__name__)
# Город-фильтр матча по умолчанию (см. match_houses_to_gar): ЕКБ-restricted против
# cross-town коллизий в region 66. Пустая строка в --city → None (фильтр отключён).
DEFAULT_CITY_FILTER = "Екатеринбург"
def run_gar_flats_load(
dir_path: str,
region_code: str,
gar_version: str,
*,
city_filter: str | None = DEFAULT_CITY_FILTER,
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
) -> dict[str, int]:
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
db = SessionLocal()
@ -67,7 +79,7 @@ def run_gar_flats_load(
def run_gar_match_only(
region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
region_code: str, *, city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO
) -> dict[str, int]:
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
@ -108,8 +120,12 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--city",
default=DEFAULT_CITY_FILTER,
help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)",
default=None,
help=(
"город-фильтр матча (ILIKE по full_address); по умолчанию берётся ПО РЕГИОНУ "
"(--region) — см. default_city_filter_for_region; пустая строка явно отключает "
"фильтр для ЛЮБОГО региона"
),
)
return parser
@ -122,8 +138,13 @@ def main() -> None:
parser = build_parser()
args = parser.parse_args()
# Пустой --city → None (фильтр отключён).
city_filter = args.city or None
# --city не передан явно (argparse default=None) → дефолт ПО РЕГИОНУ (66 → «Екатеринбург»
# byte-for-byte как раньше, остальные — без фильтра). Передан явно (в т.ч. "") →
# уважаем волю вызывающего: "" → None (фильтр отключён), непустая строка → override.
if args.city is None:
city_filter: str | None = default_city_filter_for_region(args.region)
else:
city_filter = args.city or None
if args.match_only:
run_gar_match_only(args.region, city_filter=city_filter)

View file

@ -1,22 +1,34 @@
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это
Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212
карточек с адресом вида «Екатеринбург (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) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится.
@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это
qc_geo=0 у 97% найденных.
Что куда едет:
* регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор
аналогов работает без ожидания `geocode_missing`);
* регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда
регион появится, прогон по этой полке уже не потребует внешних вызовов;
* регион == `--region` в `listings`, С координатами (geom есть сразу,
radius-подбор аналогов работает без ожидания `geocode_missing`);
* адрес разрешился, но регион ответа не совпал с `--region` не пишется,
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим
`--region` подхватит её из кэша без единого внешнего вызова;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
@ -71,28 +83,43 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
сборщика, поэтому областных карточек в нём нет по построению. Регион читается
ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
оставлен второй линией на случай, если в ту же таблицу когда-нибудь приедет
второй город. Замер по 5 024 карточкам первого прохода: единственное значение
первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное,
», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
заполнены у 100% карточек.
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
`mo-serp-domclick-20260912`) оба пишут в один и тот же
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
`msk_raw.domclick_latest` отдаёт обе партии вперемешку курсор по `id` не
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
опциональная вторая линия, а единственный способ развести регионы.
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на 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 --limit 500
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 --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 --region 50
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
"""
@ -102,6 +129,7 @@ import argparse
import asyncio
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
@ -113,15 +141,18 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import dadata
from app.services.geocoder import normalize_address
from app.services.regions import REGIONS, is_within_bbox
from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77
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
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
@ -427,14 +458,16 @@ class ImportCounters:
read: int = 0
skipped_artifact: int = 0
skipped_not_moscow: int = 0
# Карточка сама говорит про другой регион (префикс округа / поддомен
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
# домов».
skipped_oblast: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
# не «в целевом регионе стало меньше домов».
skipped_geo_other_region: int = 0
skipped_ungeocoded: int = 0
selected: int = 0
inserted: int = 0
@ -461,8 +494,8 @@ class ImportCounters:
self.read
== self.selected
+ self.skipped_artifact
+ self.skipped_not_moscow
+ self.skipped_oblast
+ self.skipped_not_target_region
+ self.skipped_geo_other_region
+ self.skipped_ungeocoded
+ 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
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:
"""У Яндекса регион — второй компонент полного адреса.
@ -497,6 +561,17 @@ def is_moscow_yandex_address(address: str | None) -> bool:
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:
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
@ -515,12 +590,70 @@ def is_moscow_domclick_address(address: str | None) -> bool:
return bool(parts) and parts[0] == "Москва"
# Источники, у которых город виден в самой карточке. Ключ отсутствует —
# источник про город молчит, и без пред-геокода писать его нельзя (avito).
CITY_FILTERS = {
"cian": is_moscow_address,
"yandex": is_moscow_yandex_address,
"domclick": is_moscow_domclick_address,
def is_oblast_domclick_address(address: str | None) -> bool:
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
копит ДВА разных прогона сборщика (московский батч
`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,
*,
source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
dry_run: bool = False,
@ -577,32 +711,38 @@ def import_msk_raw(
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
if region not in SUPPORTED_REGIONS:
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
view = SOURCE_VIEWS[source]
counters = ImportCounters()
matcher = RealMatcherAdapter()
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:
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito")
if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
# ни города в адресе, ни координат. Пишем только по явному разрешению.
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
# прочих: ни города в адресе, ни координат. Пишем только по явному
# разрешению.
if not (dry_run or allow_unfiltered):
raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от "
"области не отличить. Нужен --geocode (штатный путь), "
f"source={source}: адрес не содержит признака города, регион "
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run."
)
logger.warning(
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
"строки лягут без geom и вперемешку с областью",
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
source,
region,
)
if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL))
@ -618,16 +758,16 @@ def import_msk_raw(
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if city_filter is not None and not city_filter(address):
counters.skipped_not_moscow += 1
if city_filter is not None and not city_filter(payload):
counters.skipped_not_target_region += 1
continue
if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None:
counters.skipped_ungeocoded += 1
continue
if point.region_code != MOSCOW_REGION_CODE:
counters.skipped_oblast += 1
if point.region_code != region:
counters.skipped_geo_other_region += 1
continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется
@ -649,19 +789,26 @@ def import_msk_raw(
if dry_run or not lots:
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(
db,
lots,
matcher=matcher,
region_code=MOSCOW_REGION_CODE,
city=MOSCOW_CITY,
region_code=region,
city=city,
)
counters.inserted += inserted
counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
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,
region,
counters.read,
counters.selected,
counters.written,
@ -679,11 +826,12 @@ def import_msk_raw(
geocode_limit,
)
logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s",
source,
region,
" (dry-run)" if dry_run else "",
counters.read,
counters.selected,
@ -691,8 +839,8 @@ def import_msk_raw(
counters.inserted,
counters.updated,
counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow,
counters.skipped_oblast,
counters.skipped_not_target_region,
counters.skipped_geo_other_region,
counters.skipped_ungeocoded,
counters.skipped_artifact,
counters.skipped_invalid,
@ -707,21 +855,30 @@ def main() -> None:
level=logging.INFO,
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(
"--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("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument(
"--allow-unfiltered",
action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
)
parser.add_argument(
"--geocode",
action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только регион 77, область откладывается",
"в listings уходит только целевой регион (--region), прочее пропускается",
)
parser.add_argument(
"--geocode-limit",
@ -737,6 +894,7 @@ def main() -> None:
import_msk_raw(
db,
source=args.source,
region=args.region,
batch_size=args.batch_size,
limit=args.limit,
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):
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
@ -156,6 +160,15 @@ from typing import Any
from sqlalchemy import text
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]:
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
@ -1153,6 +1166,7 @@ _SAMPLE_SQL = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1204,6 +1218,7 @@ _SAMPLE_SQL_SCATTERED = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1233,6 +1248,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
house_type
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
@ -1271,10 +1287,9 @@ _CANDIDATES_SQL = text(
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _resolve_city_ppm2_band); the default (city=None) path never issues this
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
# region CLI flag yet, so every call is scoped to the oblast, matching every
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
# which only ever saw region-66 rows since the table was oblast-only before).
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
# every pre-existing invocation, which never passed --region).
_CITY_PPM2_BAND_SQL = text(
"""
SELECT ppm2_min, ppm2_max
@ -1288,10 +1303,13 @@ _CITY_PPM2_BAND_SQL = text(
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
before this change literal identity, not just equal text so the
default CLI invocation (and the frozen EKB regression gate, which never
calls this path at all) see byte-identical SQL.
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
time literal identity, not just equal text (asserted by
``test_sample_sql_city_none_returns_original_object``) so callers that
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
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
FROM deals
WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
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
error the globals; read-only best-effort, never raises.
``region_code`` defaults to 66 (Свердловская обл.) this script has no
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
--city caller keeps its byte-identical lookup.
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
direct callers; ``_load_sample`` (#3520) always passes the actual
``--region`` value through explicitly.
"""
if city is None:
return float(PPM2_MIN), float(PPM2_MAX)
@ -1376,22 +1395,32 @@ def _load_sample(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> 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``
value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
falls back to the module globals). Default None is byte-identical to the
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
no extra query.
``deal_city_price_bands`` for that (region_code, city) pair
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
same PPM2_MIN/PPM2_MAX globals, no extra query.
"""
if city is None:
ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX
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] = {
"region_code": region_code,
"ppm2_min": ppm2_min,
"ppm2_max": ppm2_max,
"since": since,
@ -1947,8 +1976,21 @@ def _predict_full_spine(
# harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
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(
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
# estimate_quality — no-area / no-address → ([], None) instead of an
@ -2169,6 +2211,7 @@ def run_backtest(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]:
"""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``
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
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour).
"""
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
deals = _load_sample(
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_ids: list[int] = []
@ -2264,6 +2324,7 @@ def run_backtest_full(
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> dict[str, Any]:
"""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
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
``deals.city`` value see ``_load_sample``. Default None is unscoped
(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.
"""
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(
"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] = []
@ -2482,6 +2563,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
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(
"--city",
default=None,
@ -2599,9 +2691,10 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--calibrate-segments is only supported with --engine full")
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",
args.engine,
args.region,
args.sample,
args.since,
args.radius,
@ -2624,6 +2717,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
region_code=args.region,
)
else:
metrics = run_backtest(
@ -2636,6 +2730,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
region_code=args.region,
)
finally:
db.close()

View file

@ -52,6 +52,7 @@ from __future__ import annotations
import argparse
import logging
import math
import re
import unicodedata
from collections import Counter
@ -66,11 +67,13 @@ from sqlalchemy.orm import Session
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
try:
from app.core.db import SessionLocal # type: ignore[import-not-found]
from app.services.regions import REGIONS # type: ignore[import-not-found]
except ImportError: # pragma: no cover — fallback for adhoc invocation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.db import SessionLocal
from app.services.regions import REGIONS
logging.basicConfig(
level=logging.INFO,
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
_MIN_KEY_LEN = 3
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
# без флага вели себя ровно как раньше.
_DEFAULT_REGION_CODE = 66
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
# сделка, посаженная в поле между Клином и Серпуховом.
_DEFAULT_MAX_SPREAD_KM = 5.0
# Классификация comma-сегмента адреса (см. `_split_place_street`).
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
_LOCALITY_TYPE_RE = re.compile(
r"^(?:"
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
r"|п|пос|поселок|посёлок|п/ст"
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
r")\.?\s+(?=[а-яё])",
flags=re.UNICODE,
)
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
_NUMERIC_SEGMENT_RE = re.compile(
r"^(?:"
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
r"|пом|помещение|оф|офис|бокс|гараж"
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
# принято за номер дома и улица потерялась бы целиком.
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
flags=re.UNICODE,
)
# ---------------------------------------------------------------------------
# Street-key normalization — the crux of match rate
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
flags=re.UNICODE,
),
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
re.compile(r"^екатеринбург$", flags=re.UNICODE),
]
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
# Street-type token at the START of the street segment. Stripped because
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
return " ".join(keep).strip()
def _street_key(address: str | None) -> str:
"""Reduce any address to a bare-street-name key for the centroid join.
def _is_admin_segment(seg: str) -> bool:
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
Both sides must produce the SAME key or the join under-matches:
'Екатеринбург, ул. Малышева, 125' 'малышева'
'г Екатеринбург, улица Малышева' 'малышева'
'Екатеринбург, Малышева' 'малышева'
'Свердловская обл., Екатеринбург, ул. Большакова, 17' 'большакова'
'улица Яскина, 12 · р-н Октябрьский' 'яскина'
'г. Екатеринбург, проспект Ленина, 50' 'ленина'
'Екатеринбург, ул. 8 Марта, 100' '8 марта'
Steps:
1. NFC normalize, lowercase, ёе (deals/houses differ on ё usage).
2. Drop a trailing district marker (' · р-н ...', ' | ...').
3. Split on commas; drop leading segments that are admin chunks
(Россия / region / district / город / Екатеринбург). The first
non-admin segment is the street segment.
4. Drop apartment/corpus/строение noise inside that segment.
5. Strip the street-type token at the start (ул/улица/проспект/...).
6. Strip the trailing house number, preserving numeric street names.
7. Collapse whitespace.
def _is_numeric_segment(seg: str) -> bool:
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
return bool(_NUMERIC_SEGMENT_RE.match(seg))
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
"""
if not address:
def _normalize_place(value: str | None) -> str:
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
if not value:
return ""
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
v = _WS_RE.sub(" ", v).strip()
v = _LOCALITY_TYPE_RE.sub("", v)
return _WS_RE.sub(" ", v).strip(" .,")
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
"""Имя НП, если сегмент — населённый пункт, иначе None.
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») имя берём
как есть; ИЛИ голое имя, которое реестр региона знает как город
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
НП иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
ловит позиционный fallback в `_split_place_street`.
"""
m = _LOCALITY_TYPE_RE.match(seg)
if m:
name = seg[m.end() :]
else:
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
return None
if _WS_RE.sub(" ", seg).strip() not in cities:
return None
name = seg
name = _WS_RE.sub(" ", name).strip(" .,")
return name or None
def _split_place_street(
address: str | None, region_code: int = _DEFAULT_REGION_CODE
) -> tuple[str, str]:
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
области тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
одно ведро, а среднее их координат правдоподобная точка ПОСЕРЕДИНЕ между
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
замечает: координата валидная, внутри области, рядом есть дома.
Разбор:
1. NFC, lower, ёе, снять хвост-район (' · р-н ...', ' | ...').
2. Порезать на comma-сегменты, выбросить страну/регион/район.
3. Первый сегмент-НП (`_locality_name`) place, улицу ищем ПОСЛЕ него.
4. Улица первый не-числовой сегмент из остатка.
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» ровно
формат deals.address по области): если НП не нашёлся, а не-числовых
сегментов >= 2 и первый не начинается с типа улицы первый считается
НП, второй улицей.
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
"""
cities = REGIONS[region_code].cities
if not address:
return "", ""
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
s = _WS_RE.sub(" ", s).strip()
if not s:
return ""
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
return "", ""
s = _DISTRICT_SUFFIX_RE.sub("", s)
# 3. Split on commas, drop leading admin segments. Each segment is matched
# whole, so a non-admin street segment is never partially eaten.
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
street_seg = ""
for seg in segments:
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
continue
street_seg = seg
break
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
if not non_admin:
return "", ""
place = ""
rest = non_admin
for i, seg in enumerate(non_admin):
name = _locality_name(seg, cities)
if name:
place = name
rest = non_admin[i + 1 :]
break
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
if not place:
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
street_seg = usable[1]
if not street_seg:
return ""
return place, ""
# 4. Drop apartment/corpus/строение noise inside the street segment.
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
# 5. Strip the street-type token if present.
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
street_seg = _strip_house_tail(street_seg)
return place, _WS_RE.sub(" ", street_seg).strip()
return _WS_RE.sub(" ", street_seg).strip()
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
(одноимённые улицы разных НП) см. `_address_key`; оставлена для отчётов
и тестов нормализации улицы.
Примеры (region 66):
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
'г Екатеринбург, улица Малышева' -> 'малышева'
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
"""
return _split_place_street(address, region_code)[1]
def _address_key(
address: str | None,
region_code: int = _DEFAULT_REGION_CODE,
city: str | None = None,
) -> tuple[int, str, str] | None:
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
переопределяет НП, разобранный из адреса.
Пустой НП: у региона С городом-ядром (66) подставляется `city_token`
ровно историческое поведение «всё, что без города, это Екатеринбург». У
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
распознанного НП лучше останется без координат, чем сядет в случайный
город области.
"""
place, street = _split_place_street(address, region_code)
from_city = _normalize_place(city)
if from_city:
place = from_city
if len(street) < _MIN_KEY_LEN:
return None
if not place:
region = REGIONS[region_code]
if not region.has_city_core:
return None
place = region.city_token
if len(place) < _MIN_KEY_LEN:
return None
return (region_code, place, street)
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
Проекция плоская на масштабе одного НП (единицы километров) ошибка
сотые доли процента, а формула дешевле haversine на каждом из десятков
тысяч домов.
"""
worst = 0.0
cos_lat = math.cos(math.radians(lat_c))
for lat, lon in points:
dy = (lat - lat_c) * 111.32
dx = (lon - lon_c) * 111.32 * cos_lat
worst = max(worst, math.hypot(dx, dy))
return worst
# ---------------------------------------------------------------------------
@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str:
@dataclass
class Centroid:
"""One street's centroid, averaged over all geocoded houses on it."""
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
lat: float
lon: float
house_count: int
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
spread_km: float = 0.0
@dataclass
@ -266,6 +427,9 @@ class DealRow:
id: int
address: str | None
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
city: str | None = None
@dataclass
@ -276,6 +440,8 @@ class Stats:
geocoded: int = 0
no_street_match: int = 0
failed: int = 0
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
dropped_spread: int = 0
# street_key → count of deals that had no house centroid (dry-run report).
unmatched_streets: Counter[str] = field(default_factory=Counter)
@ -285,14 +451,28 @@ class Stats:
# ---------------------------------------------------------------------------
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
"""Per-street centroid from houses WHERE geom IS NOT NULL.
def _build_centroid_map(
db: Session,
region_code: int = _DEFAULT_REGION_CODE,
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
) -> dict[tuple[int, str, str], Centroid]:
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
We read raw (address, lat, lon) and aggregate in Python so the street-key
derivation is the SAME code path as the deals side pushing it into SQL
would require duplicating the regex logic in plpgsql and risk drift.
8,600 rows is trivial to hold in memory.
Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
и прогон по одному региону тихо тащит чужие координаты.
`houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
поэтому строка без региона принимается по географии если её координаты
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
происхождении строки.
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
дрейф.
"""
region = REGIONS[region_code]
lat_min, lat_max, lon_min, lon_max = region.bbox_region
rows = (
db.execute(
text(
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
" AND lat IS NOT NULL "
" AND lon IS NOT NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) > 0"
)
" AND length(trim(address)) > 0 "
" AND ( region_code = CAST(:rc AS smallint) "
" OR ( region_code IS NULL "
" AND lat BETWEEN CAST(:lat_min AS double precision) "
" AND CAST(:lat_max AS double precision) "
" AND lon BETWEEN CAST(:lon_min AS double precision) "
" AND CAST(:lon_max AS double precision) ) )"
),
{
"rc": region_code,
"lat_min": lat_min,
"lat_max": lat_max,
"lon_min": lon_min,
"lon_max": lon_max,
},
)
.mappings()
.all()
)
# street_key → running [lat_sum, lon_sum, n]
acc: dict[str, list[float]] = {}
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
for r in rows:
key = _street_key(r["address"])
if len(key) < _MIN_KEY_LEN:
key = _address_key(r["address"], region_code)
if key is None:
continue
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
bucket[0] += float(r["lat"])
bucket[1] += float(r["lon"])
bucket[2] += 1.0
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
return {
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
for key, (lat_sum, lon_sum, n) in acc.items()
}
out: dict[tuple[int, str, str], Centroid] = {}
dropped = 0
for key, points in acc.items():
n = len(points)
lat_c = sum(lat for lat, _ in points) / n
lon_c = sum(lon for _, lon in points) / n
spread = _spread_km(points, lat_c, lon_c)
if spread > max_spread_km:
# Одна «улица» шириной в десятки километров — это не улица, а
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
# двумя способами). Среднее таких точек — координата в поле между
# городами; отдавать её сделке нельзя, ведро выбрасывается.
dropped += 1
logger.warning(
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
key,
n,
spread,
max_spread_km,
)
continue
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
if dropped:
logger.warning(
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
dropped,
len(acc),
max_spread_km,
)
return out
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
def _region_predicate(column: str = "region_code") -> str:
"""SQL-предикат «строка принадлежит региону :rc» для deals.
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
и только ему. Для любого другого региона NULL не кандидат.
"""
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
def _select_deals_without_coords(
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
) -> list[DealRow]:
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
UPDATE sets lat NOT NULL, dropping the row out on the next run.
@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
rows = (
db.execute(
text(
"SELECT id, address "
"SELECT id, address, city "
"FROM deals "
"WHERE lat IS NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) > 0 "
" AND " + _region_predicate() + " "
"ORDER BY id "
"LIMIT CAST(:lim AS int)"
),
{"lim": limit},
{"lim": limit, "rc": region_code},
)
.mappings()
.all()
)
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
# ---------------------------------------------------------------------------
@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
# ---------------------------------------------------------------------------
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None:
def _update_deal_coords(
db: Session,
*,
deal_id: int,
lat: float,
lon: float,
region_code: int = _DEFAULT_REGION_CODE,
) -> None:
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
" SET lat = CAST(:lat AS double precision), "
" lon = CAST(:lon AS double precision), "
" geocode_tried_at = NOW() "
" WHERE id = CAST(:id AS bigint)"
" WHERE id = CAST(:id AS bigint) "
" AND " + _region_predicate()
),
{"id": deal_id, "lat": lat, "lon": lon},
{"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
)
@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
def _run_backfill(
db: Session,
deals: list[DealRow],
centroids: dict[str, Centroid],
centroids: dict[tuple[int, str, str], Centroid],
*,
batch: str,
dry_run: bool,
region_code: int = _DEFAULT_REGION_CODE,
) -> Stats:
"""For each deal, look up its street centroid and UPDATE lat/lon.
@ -399,14 +638,14 @@ def _run_backfill(
stats = Stats()
for i, deal in enumerate(deals, start=1):
key = _street_key(deal.address)
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
key = _address_key(deal.address, region_code, city=deal.city)
centroid = centroids.get(key) if key is not None else None
if centroid is None:
stats.no_street_match += 1
# Track the raw key (or a sentinel) so the dry-run report can show
# which streets we're missing. Empty key → '<no-street-parsed>'.
stats.unmatched_streets[key or "<no-street-parsed>"] += 1
stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
stats.processed += 1
if dry_run and i % _LOG_EVERY == 0:
logger.info(
@ -423,7 +662,13 @@ def _run_backfill(
else:
try:
with db.begin_nested():
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
_update_deal_coords(
db,
deal_id=deal.id,
lat=centroid.lat,
lon=centroid.lon,
region_code=region_code,
)
# Per-row commit so resume picks up exactly where we crashed.
db.commit()
stats.geocoded += 1
@ -454,7 +699,7 @@ def _run_backfill(
def _report_dry_run(
stats: Stats,
centroids: dict[str, Centroid],
centroids: dict[tuple[int, str, str], Centroid],
*,
total_deals_null: int,
candidates: int,
@ -475,7 +720,7 @@ def _report_dry_run(
logger.info("" * 60)
logger.info("DRY-RUN SUMMARY (no DB writes)")
logger.info("distinct streets with a house centroid: %d", distinct_streets)
logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
logger.info("deals matched to a centroid: %d", matched)
logger.info("deals with no street match: %d", stats.no_street_match)
@ -486,7 +731,7 @@ def _report_dry_run(
projected,
match_rate * 100.0,
)
logger.info("top-10 unmatched deal streets (by row count):")
logger.info("top-10 unmatched deal keys НП/улица (by row count):")
for street, cnt in stats.unmatched_streets.most_common(10):
logger.info(" %6d %s", cnt, street)
logger.info("" * 60)
@ -497,13 +742,15 @@ def _report_dry_run(
# ---------------------------------------------------------------------------
def _count_deals_null(db: Session) -> int:
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
row = db.execute(
text(
"SELECT count(*) AS n FROM deals "
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
)
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 "
" AND " + _region_predicate()
),
{"rc": region_code},
).first()
return int(row[0]) if row else 0
@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default=f"deals_geo_{date.today().isoformat()}",
help="Log batch label (does not affect DB filters — logs only).",
)
p.add_argument(
"--region-code",
type=int,
default=_DEFAULT_REGION_CODE,
choices=sorted(REGIONS),
help=(
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
"строки этого региона; входит в ключ join'а."
),
)
p.add_argument(
"--max-spread-km",
type=float,
default=_DEFAULT_MAX_SPREAD_KM,
help=(
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
),
)
p.add_argument(
"--dry-run",
action="store_true",
@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the number of deals geocoded this run."""
args = _parse_args(argv)
logger.info(
"starting batch=%s limit=%s dry_run=%s",
"starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
args.batch,
args.region_code,
args.limit,
args.max_spread_km,
args.dry_run,
)
db = SessionLocal()
try:
centroids = _build_centroid_map(db)
logger.info("built centroid map: %d distinct streets", len(centroids))
centroids = _build_centroid_map(
db, region_code=args.region_code, max_spread_km=args.max_spread_km
)
logger.info(
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
)
if not centroids:
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
logger.warning(
"no house centroids for region %s — houses has no geocoded rows there; "
"nothing to do",
args.region_code,
)
return 0
deals = _select_deals_without_coords(db, args.limit)
deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
logger.info("loaded deals without coords: %d", len(deals))
if not deals:
logger.info("nothing to do — no deals with lat IS NULL and an address")
return 0
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
stats = _run_backfill(
db,
deals,
centroids,
batch=args.batch,
dry_run=args.dry_run,
region_code=args.region_code,
)
if args.dry_run:
total_null = _count_deals_null(db)
total_null = _count_deals_null(db, region_code=args.region_code)
_report_dry_run(
stats,
centroids,

View file

@ -32,8 +32,10 @@ from scripts.geocode_deals_from_houses import (
Centroid,
DealRow,
Stats,
_address_key,
_build_centroid_map,
_run_backfill,
_select_deals_without_coords,
_street_key,
_update_deal_coords,
main,
@ -178,16 +180,16 @@ def test_build_centroid_map_averages_houses_on_one_street():
"""Two houses on Малышева → centroid is the mean of their coords."""
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
{"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.82, "lon": 60.54},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"}
c = centroids["малышева"]
assert c.lat == pytest.approx(56.85)
assert c.lon == pytest.approx(60.60)
assert set(centroids) == {(66, "екатеринбург", "малышева")}
c = centroids[(66, "екатеринбург", "малышева")]
assert c.lat == pytest.approx(56.81)
assert c.lon == pytest.approx(60.52)
assert c.house_count == 2
@ -201,12 +203,15 @@ def test_build_centroid_map_groups_distinct_streets():
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева", "ленина"}
assert centroids["малышева"].house_count == 2
assert centroids["ленина"].house_count == 1
assert set(centroids) == {
(66, "екатеринбург", "малышева"),
(66, "екатеринбург", "ленина"),
}
assert centroids[(66, "екатеринбург", "малышева")].house_count == 2
assert centroids[(66, "екатеринбург", "ленина")].house_count == 1
# Малышева centroid = mean of the two Малышева rows.
assert centroids["малышева"].lat == pytest.approx(56.81)
assert centroids["ленина"].lat == pytest.approx(56.84)
assert centroids[(66, "екатеринбург", "малышева")].lat == pytest.approx(56.81)
assert centroids[(66, "екатеринбург", "ленина")].lat == pytest.approx(56.84)
def test_build_centroid_map_skips_unparseable_address():
@ -218,7 +223,7 @@ def test_build_centroid_map_skips_unparseable_address():
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db)
assert set(centroids) == {"малышева"}
assert set(centroids) == {(66, "екатеринбург", "малышева")}
# ---------------------------------------------------------------------------
@ -228,7 +233,7 @@ def test_build_centroid_map_skips_unparseable_address():
def test_run_backfill_matched_street_issues_update_with_centroid():
deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.838, lon=60.586, house_count=3)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)
@ -247,7 +252,7 @@ def test_run_backfill_matched_street_issues_update_with_centroid():
def test_run_backfill_deal_with_only_street_name_matches():
"""Deal address with no house number / no type word still matches."""
deal = DealRow(id=7, address="Екатеринбург, Малышева")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -264,7 +269,7 @@ def test_run_backfill_deal_with_only_street_name_matches():
def test_run_backfill_unmatched_street_no_update():
deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -275,13 +280,13 @@ def test_run_backfill_unmatched_street_no_update():
assert updated == []
assert db.commit.call_count == 0
# Unmatched street tracked for the dry-run report.
assert stats.unmatched_streets["несуществующая"] == 1
assert stats.unmatched_streets["екатеринбург/несуществующая"] == 1
def test_run_backfill_unparseable_deal_tracked_as_sentinel():
"""A deal whose address yields an empty key is a no-match under a sentinel."""
deal = DealRow(id=5, address="Екатеринбург")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)
@ -298,7 +303,7 @@ def test_run_backfill_unparseable_deal_tracked_as_sentinel():
def test_run_backfill_dry_run_issues_no_update():
deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)
@ -319,7 +324,7 @@ def test_run_backfill_db_write_failure_isolated_to_row():
DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
]
centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
centroids = {(66, "екатеринбург", "малышева"): Centroid(lat=56.8, lon=60.5, house_count=1)}
db, updated = _make_db_mock()
# Make the FIRST UPDATE raise, the rest succeed.
@ -359,7 +364,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
assert "geocode_tried_at = NOW()" in sql_str
# geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
assert "geom" not in sql_str
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}
assert binds == {"id": 3, "lat": 56.1, "lon": 60.2, "rc": 66}
# ---------------------------------------------------------------------------
@ -370,7 +375,7 @@ def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
def test_main_respects_limit_and_returns_geocoded():
house_rows = [
{"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
{"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.82, "lon": 60.54},
]
deal_rows = [
{"id": 1, "address": "Екатеринбург, Малышева"},
@ -386,8 +391,8 @@ def test_main_respects_limit_and_returns_geocoded():
assert len(updated) == 1
assert updated[0]["id"] == 1
# Centroid used = mean of the two house rows.
assert updated[0]["lat"] == pytest.approx(56.85)
assert updated[0]["lon"] == pytest.approx(60.60)
assert updated[0]["lat"] == pytest.approx(56.81)
assert updated[0]["lon"] == pytest.approx(60.52)
def test_main_dry_run_writes_nothing():
@ -432,3 +437,114 @@ def test_stats_unmatched_streets_counter_defaults_empty():
assert s.no_street_match == 0
assert s.failed == 0
assert s.unmatched_streets.most_common(3) == []
# ---------------------------------------------------------------------------
# Одноимённые улицы разных НП + однорегиональность прогона (fix/oblast-deal-geocoding)
# ---------------------------------------------------------------------------
def test_address_key_distinguishes_same_street_in_different_localities():
"""«Луговая» в Сабурово и «Луговая» в Красногорске — РАЗНЫЕ ключи.
Ровно та ловушка, ради которой ключ стал составным: до этого обе улицы
сливались в одно ведро 'луговая', и центроид садился между городами.
"""
saburovo = _address_key("Сабурово, Луговая", 50)
krasnogorsk = _address_key("Красногорск, Луговая", 50)
assert saburovo == (50, "сабурово", "луговая")
assert krasnogorsk == (50, "красногорск", "луговая")
assert saburovo != krasnogorsk
def test_address_key_distinguishes_same_street_in_different_regions():
"""Регион — тоже часть ключа: «Ленина» в Химках ≠ «Ленина» в Екатеринбурге."""
assert _address_key("г Химки, ул Ленина, 5", 50) == (50, "химки", "ленина")
assert _address_key("Екатеринбург, ул. Ленина, 5", 66) == (66, "екатеринбург", "ленина")
def test_address_key_region_without_city_core_refuses_nameless_locality():
"""У области НП обязателен: без него ключа нет (а не «пусть будет Красногорск»)."""
assert _address_key("Луговая, 5", 50) is None
# У 66 город-ядро есть — историческое поведение сохранено.
assert _address_key("Малышева, 125", 66) == (66, "екатеринбург", "малышева")
def test_build_centroid_map_separates_same_street_of_two_localities():
"""Дома с одноимённых улиц двух НП не усредняются в одну точку."""
house_rows = [
{"address": "Московская обл., д Сабурово, ул Луговая, 1", "lat": 55.40, "lon": 37.60},
{"address": "Московская обл., д Сабурово, ул Луговая, 3", "lat": 55.41, "lon": 37.61},
{"address": "Московская обл., г Химки, ул Луговая, 2", "lat": 55.89, "lon": 37.43},
]
db, _ = _make_db_mock(house_rows=house_rows)
centroids = _build_centroid_map(db, region_code=50)
assert set(centroids) == {(50, "сабурово", "луговая"), (50, "химки", "луговая")}
assert centroids[(50, "сабурово", "луговая")].house_count == 2
assert centroids[(50, "сабурово", "луговая")].lat == pytest.approx(55.405)
assert centroids[(50, "химки", "луговая")].lat == pytest.approx(55.89)
def test_build_centroid_map_drops_bucket_with_huge_spread():
"""Ведро с разбросом в десятки км — склейка НП, выбрасывается целиком."""
house_rows = [
{"address": "Московская обл., г Химки, ул Ленина, 1", "lat": 55.89, "lon": 37.43},
# Тот же ключ, но точка в 60+ км — так выглядит склейка двух НП.
{"address": "Московская обл., г Химки, ул Ленина, 2", "lat": 55.30, "lon": 38.20},
]
db, _ = _make_db_mock(house_rows=house_rows)
assert _build_centroid_map(db, region_code=50) == {}
# Порог поднят выше разброса → ведро остаётся.
kept = _build_centroid_map(db, region_code=50, max_spread_km=100.0)
assert set(kept) == {(50, "химки", "ленина")}
def test_centroid_query_filters_by_region():
"""Карта центроидов строится по домам ОДНОГО региона, а не по всем."""
db, _ = _make_db_mock(house_rows=[])
_build_centroid_map(db, region_code=50)
args, _kw = db.execute.call_args
sql_str = str(args[0])
assert "FROM houses" in sql_str
assert "region_code = CAST(:rc AS smallint)" in sql_str
assert args[1]["rc"] == 50
def test_candidate_select_and_update_are_scoped_to_one_region():
"""Прогон по региону 50 не выбирает и не обновляет строки другого региона."""
db, _unused = _make_db_mock(
deal_rows=[{"id": 7, "address": "Сабурово, Луговая", "city": "Сабурово"}]
)
deals = _select_deals_without_coords(db, 10, region_code=50)
args, _kw = db.execute.call_args
select_sql = str(args[0])
assert "FROM deals" in select_sql
assert "region_code = CAST(:rc AS int)" in select_sql
assert args[1]["rc"] == 50
assert deals[0].city == "Сабурово"
_update_deal_coords(db, deal_id=7, lat=55.4, lon=37.6, region_code=50)
args, _kw = db.execute.call_args
update_sql = str(args[0])
assert "UPDATE deals" in update_sql
assert "region_code = CAST(:rc AS int)" in update_sql
assert args[1]["rc"] == 50
def test_run_backfill_uses_deal_city_column_for_the_locality():
"""deals.city (заполнена на 100% по области) переопределяет НП из адреса."""
centroids = {(50, "сабурово", "луговая"): Centroid(lat=55.4, lon=37.6, house_count=2)}
db, updated = _make_db_mock()
deals = [DealRow(id=11, address="Луговая", city="Сабурово")]
stats = _run_backfill(db, deals, centroids, batch="t", dry_run=False, region_code=50)
assert stats.geocoded == 1
assert updated[0]["lat"] == pytest.approx(55.4)
assert updated[0]["rc"] == 50

View file

@ -829,6 +829,46 @@ async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog)
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:
"""401 (или 403 без 'disabled') → сообщение про креды."""
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).
_LAT_IN_EKB = 56.838
_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 ──────────────────────────────────────────────────────────
@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None:
lc._MEDIAN_PPM2_LOCAL_SQL,
lc._MEDIAN_PPM2_CITYWIDE_SQL,
lc._NEAREST_POI_SQL,
lc._POI_COUNT_IN_REGION_SQL,
):
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
# ── #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 эстиматора) ─────────────
@ -246,7 +298,9 @@ class _FakeDB:
def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None:
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 status == "unavailable"
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:
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 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},
]
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 len(poi) == 4
# 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)
]
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
@ -407,3 +463,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None:
assert result.poi_status == "unavailable"
assert result.nearby_poi == []
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
import json
import os
from unittest import mock
@ -51,12 +52,19 @@ def _stop_patches():
# ── verify_chat_and_topic ────────────────────────────────────────────────────
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None:
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения."""
async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
"""Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
показал, что она подтверждает даже несуществующий `message_thread_id`
(`{"ok":true,"result":true}`) проверка была ложно-зелёной."""
calls: list[str] = []
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})
_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")
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:
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат."""
methods: list[str] = []
async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
"""Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
`message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
delete_payloads: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
methods.append(request.url.path.rsplit("/", 1)[-1])
return httpx.Response(200, json={"ok": True, "result": True})
method = request.url.path.rsplit("/", 1)[-1]
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)
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 "copyMessage" not in methods
assert ok is True
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:
@ -135,6 +150,50 @@ async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> No
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 ─────────────────────────────────────────────────

View file

@ -32,12 +32,14 @@ tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agr
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
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_repoints_dedups_deletes_and_is_idempotent
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
# параллельно с самим списком, поэтому первым же прогоном deploy-лэйна хук их и

View file

@ -50,7 +50,17 @@ def test_regions_do_not_overlap() -> None:
там же настоящий инвариант для вложенной пары: `region_for_point` обязан
резолвить точку во ВЛАДЕЮЩИЙ, более специфичный регион по площади bbox, а не
по числовому коду). 66 (Урал) физически за тысячи км от 50/77 для него
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе."""
старый плоский инвариант «никто ни в кого не попадает» остаётся в силе.
#3052: этот тест — про СЫРЫЕ прямоугольники (`is_within_bbox` /
`bbox_region`), не про итоговый резолв точки. Переход `region_for_point`
на настоящий полигон (см. regions._point_in_region_polygon) исключение
здесь НЕ снимает: bbox_region(77) геометрически остаётся подмножеством
bbox_region(50) как прямоугольник полигон это не меняет, он работает
ПОСЛЕ bbox-предфильтра, а не вместо него. Честный инвариант «точка резолвится
ровно в один регион» теперь проверяется на уровне function-результата в
test_3051_region_registry_moscow_oblast.py (Химки/Балашиха и девять
контрольных точек issue #3052), не здесь."""
nested_pairs = {(50, 77), (77, 50)}
# Исключение из инварианта обязано опираться на ДОКАЗАННУЮ вложенность, а не
# на допущение в докстринге: сначала проверяем, что bbox_region(50)

View file

@ -13,6 +13,12 @@ bbox_region(77) (Москва) как прямоугольники, а 50 < 77
Область на дату этого PR тир обогащения пуст (frozenset()), в deals/listings
0 строк (импорт Росреестра из FDW отдельный PR). Тесты ниже проверяют РЕЕСТР
(геометрию/классификацию), не данные.
#3052: резолв внутри `region_for_point` перестал быть чисто bbox-based —
`_POINT_LOOKUP_ORDER` (регрессия ниже) остался прежним дешёвым предфильтром,
но финальный ответ теперь даёт настоящий полигон границы региона. Тест на
Химки/Балашиху обновлён под это (было «известное ограничение» bbox, теперь
исправленное поведение).
"""
from __future__ import annotations
@ -73,20 +79,20 @@ def test_point_outside_all_regions_is_none() -> None:
assert res is None
def test_known_limitation_khimki_balashikha_resolve_to_77_via_bbox() -> None:
"""ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, не баг этого PR: bbox_region(77) — генеральный
fallback-net Москвы, по долготе тянется до 38.10 (~13 км восточнее МКАД).
Химки (55.91, 37.41) и Балашиха (55.7965, 37.9388) физически лежат внутри
ЭТОГО прямоугольника, хотя административно это область. region_for_point
(bbox-based) резолвит их в 77; region_by_city (точное имя) правильно в
50 (см. test_region_by_city_resolves_moscow_oblast_cities). Сузить
bbox_region(77), чтобы это исправить, отдельное решение вне скоупа
ordering-фикса (риск задеть geocoder/estimator-потребителей 77 без
возможности перепроверить их в этом PR)."""
def test_khimki_balashikha_resolve_to_50_via_polygon() -> None:
"""#3052: bbox_region(77) — генеральный fallback-net Москвы, по долготе
тянется до 38.10 (~13 км восточнее МКАД). Химки (55.91, 37.41) и Балашиха
(55.7965, 37.9388) физически лежат внутри ЭТОГО прямоугольника, хотя
административно это область до #3052 region_for_point (bbox-based)
резолвил их в 77 («известное ограничение», см. issue #3052: 8 647
московских лотов уходили в отказ по покрытию, а 11 172 областные сделки
ложно попадали в московское ядро). С настоящим полигоном (bbox остался
только дешёвым предфильтром см. regions.region_for_point) обе точки
физически вне контура Москвы резолвятся в 50, как и region_by_city."""
khimki = regions.region_for_point(55.91, 37.41)
assert khimki is not None and khimki.code == 77
assert khimki is not None and khimki.code == 50
balashikha = regions.region_for_point(55.7965, 37.9388)
assert balashikha is not None and balashikha.code == 77
assert balashikha is not None and balashikha.code == 50
# ── 2. region_by_city ─────────────────────────────────────────────────────

View file

@ -0,0 +1,171 @@
"""#3052: `region_for_point` резолвит по настоящей границе региона (полигон
OSM/Nominatim из `region_boundaries/boundaries.geojson.json`), не по
прямоугольнику `bbox_region`.
До фикса прямоугольники Москвы (77) и области (50) пересекались Химки,
Реутов, Котельники, Люберцы резолвились в Москву (8 647 московских лотов
получали отказ по покрытию, 11 172 областные сделки ложно попадали в
московское ядро, при том что коридор ДКП фильтрует строго по коду региона).
Девять контрольных точек ниже приёмка issue #3052, значения сверены
`region_for_point` на реальных полигонах до мержа (ray casting с поддержкой
дыр, см. regions._point_in_region_polygon)."""
from __future__ import annotations
import os
import time
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import regions
# (название, lat, lon, ожидаемый code региона или None)
_CONTROL_POINTS: tuple[tuple[str, float, float, int | None], ...] = (
("Химки", 55.8970, 37.4297, 50),
("центр Москвы", 55.7558, 37.6173, 77),
("Ломоносовский проспект", 55.7020, 37.5140, 77),
("Серпухов", 54.9147, 37.4147, 50),
("Балашиха", 55.7965, 37.9391, 50),
("Зеленоград", 55.9840, 37.2140, 77),
("Троицк (Новая Москва)", 55.4750, 37.3000, 77),
("Екатеринбург", 56.8389, 60.6057, 66),
("Пермь", 58.0105, 56.2502, None),
)
def test_nine_control_points_resolve_by_real_boundary() -> None:
for name, lat, lon, expected_code in _CONTROL_POINTS:
res = regions.region_for_point(lat, lon)
got_code = res.code if res is not None else None
assert got_code == expected_code, (
f"{name} ({lat}, {lon}): ожидался регион {expected_code}, получен {got_code}"
)
def test_point_outside_all_boundaries_is_none() -> None:
"""Пермь — вне всех трёх полигонов (не только вне bbox), инвариант отдельно
от общего прогона выше чтобы регрессия на None была явной сама по себе."""
assert regions.region_for_point(58.0105, 56.2502) is None
def test_ekaterinburg_unaffected_by_polygon_switch() -> None:
"""Регион 66 физически за тысячи км от 50/77 — переход на полигон не должен
был задеть его резолв вообще."""
res = regions.region_for_point(56.8389, 60.6057)
assert res is not None and res.code == 66
def test_bbox_prefilter_never_rejects_what_polygon_would_accept() -> None:
"""Инвариант «полигон вложен в свой bbox_region»: если бы это было не так,
дешёвый bbox-предфильтр в region_for_point молча отбрасывал бы точки,
которые честный point-in-polygon принял бы тихая потеря покрытия."""
for code, rings in regions._REGION_BOUNDARIES.items():
bbox = regions.REGIONS[code].bbox_region
for ring in rings:
for lon, lat in ring:
assert regions.is_within_bbox(lat, lon, bbox), (
f"вершина полигона региона {code} ({lat}, {lon}) вне его bbox_region"
)
# ── Правка регрессии первой версии #3052 (упрощение 0.005 ≈ 500 м) ──────────
#
# Сверка резолва против сохранённого region_code по проду (277 582 объявления
# + 48 143 дома) на упрощённой геометрии: 69 точек агломерации не принял НИ
# один полигон (→ None → DEFAULT_REGION_CODE=66 у вызывающего кода), куски
# Москвы (Митино, Косино-Ухтомский, Можайский) уехали в область, 731
# объявление области — в Москву. Точки ниже — реальные адреса из прода.
#
# Кунцево, «ул. Липовой Рощи 1к3» (55.7990, 37.3787) в этом списке стоит как
# 50, хотя в исходной сверке значилось ожидание 77: улицы с таким именем в
# Москве нет вообще (Nominatim /search «улица Липовой Рощи» — единственное
# попадание: квартал «Спутник», Павшино, городской округ Красногорск,
# Московская область), а сама точка лежит в 119 м ВНУТРИ области от границы
# Москвы. Ожидание 77 там — след bbox-эпохи (прямоугольник Москвы тянется по
# долготе до 38.10 и накрывает Красногорск), а не факт. Оно ещё и неисполнимо
# вместе с соседней точкой той же сверки: Новоивановское (55.7013, 37.3606)
# лежит в 72 м снаружи Москвы и ожидается как 50 — никакое правило «ближе N
# метров к Москве ⇒ Москва» не отдаст 77 точке в 119 м и 50 точке в 72 м.
_BORDER_CONTROL_POINTS: tuple[tuple[str, float, float, int], ...] = (
("Куркино, Соловьиная Роща 8", 55.8917, 37.4008, 77),
("Митино, Муравская 46к1", 55.8617, 37.3769, 77),
("Косино-Ухтомский, Лухмановская 15к4", 55.7226, 37.8922, 77),
("Можайский, Сколковское ш. 40к4", 55.7022, 37.3920, 77),
("Новые Химки, Панфилова 12", 55.8796, 37.4294, 50),
("Химки, Юннатов 11", 55.8972, 37.4613, 50),
("Красногорск, Красногорский б-р 13к1", 55.8156, 37.3707, 50),
("Новоивановское, Одинцовский о.", 55.7013, 37.3606, 50),
("Красногорск, Липовой Рощи 1к3", 55.7990, 37.3787, 50),
)
def test_border_control_points_resolve_on_unsimplified_boundary() -> None:
for name, lat, lon, expected_code in _BORDER_CONTROL_POINTS:
res = regions.region_for_point(lat, lon)
got_code = res.code if res is not None else None
assert got_code == expected_code, (
f"{name} ({lat}, {lon}): ожидался регион {expected_code}, получен {got_code}"
)
def test_no_point_inside_a_bbox_resolves_to_none() -> None:
"""Главный инвариант правки: None законен ТОЛЬКО вне bbox всех регионов.
Прогон сеткой 0.005° по прямоугольнику Москвы: любая щель между
независимо оцифрованными в OSM линиями 50 и 77 всплывёт здесь как None, а
None у вызывающего кода (`trade_in.py`: `code if target_region else
DEFAULT_REGION_CODE`) означает молчаливую подстановку региона по
умолчанию коэффициенты Екатеринбурга московскому адресу."""
checked = 0
for lat_step in range(91):
lat = 55.55 + lat_step * 0.005
for lon_step in range(121):
lon = 37.30 + lon_step * 0.005
assert any(
regions.is_within_bbox(lat, lon, r.bbox_region) for r in regions.REGIONS.values()
), (lat, lon)
assert regions.region_for_point(lat, lon) is not None, (
f"точка ({lat}, {lon}) внутри bbox региона, но резолв дал None"
)
checked += 1
assert checked == 91 * 121
def test_nearest_boundary_fallback_prefers_the_closer_region() -> None:
"""Щель закрывается по расстоянию до границы, а не «первым в порядке».
Проверяем саму метрику в fallback-ветке region_for_point исход решает
она. Точка в Красногорске у самой границы Москвы: линия 50 и линия 77
здесь общая, поэтому расстояния до них совпадают до метров, и масштаб
сотня метров, а не километры (плоское приближение с поправкой cos(lat)).
"""
lat, lon = 55.7990, 37.3787
d_50 = regions._distance_to_boundary(50, lat, lon)
d_77 = regions._distance_to_boundary(77, lat, lon)
assert abs(d_50 - d_77) * 111_320 < 20.0
assert 50.0 < d_50 * 111_320 < 300.0
# Центр Москвы от своей границы — километры (метрика не схлопнута в ноль).
assert regions._distance_to_boundary(77, 55.7558, 37.6173) * 111_320 > 5_000.0
def _measure_resolve(probes: list[tuple[float, float]]) -> float:
started = time.perf_counter()
for _ in range(200):
for lat, lon in probes:
regions.region_for_point(lat, lon)
return (time.perf_counter() - started) / (200 * len(probes))
def test_region_for_point_stays_under_time_budget() -> None:
"""Точная геометрия не имеет права стоить дороже bbox-эпохи.
Потолок 150 мкс/вызов при фактических ~4 мкс (bbox каждого кольца +
lat-полосы рёбер, см. regions._IndexedRing): запас в 35 раз тест
краснеет от настоящей регрессии алгоритма, а не от шумного раннера."""
probes = [(lat, lon) for _n, lat, lon, _c in _BORDER_CONTROL_POINTS]
probes += [(lat, lon) for _n, lat, lon, _c in _CONTROL_POINTS]
for lat, lon in probes: # прогрев
regions.region_for_point(lat, lon)
best = min(_measure_resolve(probes) for _ in range(3))
assert best < 150e-6, f"region_for_point: {best * 1e6:.1f} мкс/вызов, потолок 150"

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:
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты.
Секрет + query-контекст (`?token=...`) передаются ОДНИМ %s-аргументом как реально
устроены вызовы в этом кодбейзе (см. body_preview в app/services/dadata.py), а не
расщеплены между msg-шаблоном и голым значением в args. Фильтр (#3471) скрабит
ЭЛЕМЕНТЫ `record.args` по отдельности, не трогая arity/`record.msg`-шаблон поэтому
секрет обязан быть самодостаточным (с контекстом) внутри одного аргумента.
"""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
@ -73,7 +80,7 @@ def test_app_logger_via_root_handler_masks_secret() -> None:
root.addHandler(handler)
try:
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:
root.removeHandler(handler)
@ -133,3 +140,46 @@ def test_install_is_idempotent() -> None:
install_query_secret_filter("uvicorn.access")
filters = logging.getLogger("uvicorn.access").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(
"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())
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
finally:
# Иначе на каждый тест остаётся фоновый поток транспорта.
client.close()
client.close(timeout=0)
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]
assert (ratio, basis) == (None, None)
assert db.rolled_back is True
# bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
assert 2 not in estimator._asking_sold_ratio_cache
# #3512: ключ кэша — (region_code, bucket); region_code не передан → дефолт 66.
# (66, 2) НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
assert (66, 2) not in estimator._asking_sold_ratio_cache
def test_transient_error_then_success_retries_not_poisoned() -> None:
# 1) транзиентный сбой — None, кэш не отравлен
r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2) # type: ignore[arg-type]
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
r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2) # type: ignore[arg-type]
assert r2 == pytest.approx(0.84)
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:
r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1) # type: ignore[arg-type]
assert r == pytest.approx(0.77)
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)

View file

@ -34,8 +34,22 @@ _MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql"
# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
_DELETE_SQL = str(ratio_mod._DELETE_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)
_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"
+ str(ratio_mod._GEO_STATS_SQL_REGION.text)
+ "\n"
+ _COUNTERS_SQL
)
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
@ -43,14 +57,17 @@ _TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
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
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()
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).
assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
assert "ON CONFLICT" not in _REDERIVE_SQL
@ -278,12 +295,24 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
"""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)
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:
return _strip_sql(
_normalise_ppm2(
_normalise_bucket_expr(
_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 +435,10 @@ class _FakeMappingResult:
class _FakeDB:
"""Minimal stand-in for a SQLAlchemy Session — records execute() calls.
Execute call order:
1. DELETE asking_to_sold_ratios
2. REDERIVE INSERT asking_to_sold_ratios
3. COUNTERS SELECT (returns counters_row)
#3512: execute call order is now EKB (DELETE + INSERT) followed by a
DELETE+INSERT pair per _OTHER_REGION_CODES region, then ONE counters SELECT
at the very end identified by statement IDENTITY (`stmt is ratio_mod._COUNTERS_SQL`),
not by a hardcoded call index, so the fake stays correct if the region registry grows.
"""
def __init__(
@ -423,9 +452,24 @@ class _FakeDB:
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
self.executed.append((stmt, params))
# 3rd call is the counters SELECT (asking_to_sold_ratios).
if len(self.executed) == 3:
if stmt is ratio_mod._COUNTERS_SQL:
return _FakeMappingResult(self._counters_row)
if stmt is ratio_mod._GEO_STATS_SQL_REGION:
# #3529: здоровый регион — гео-гард пропускает, деривация выполняется.
return _FakeMappingResult(
{
"deals_total": 1000,
"deals_geo": 950,
"listings_total": 2000,
"listings_geo": 2000,
"cells_deal": 20,
"cells_ask": 22,
"cells_both_sides": 18,
"cells_matched": 16,
"deals_in_cells": 900,
"listings_in_cells": 1500,
}
)
return _FakeMappingResult(None)
def commit(self) -> None:
@ -438,8 +482,8 @@ class _FakeDB:
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.
#2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3
execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT).
#3512: FakeDB now sees 2 statements for EKB + 2 per _OTHER_REGION_CODES region +
1 final COUNTERS SELECT (counters are still the 3 legacy keys, #2002).
"""
marked: dict[str, Any] = {}
monkeypatch.setattr(
@ -459,12 +503,22 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
"per_rooms_rows": 3,
"used_global_fallback": 1,
}
assert out == expected
for key, value in expected.items():
assert out[key] == value
assert db.committed is True
# Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms).
assert len(db.executed) == 3
# #3529: у каждого прочего региона теперь ТРИ стейтмента — geo-stats SELECT,
# DELETE и (если гард пропустил) INSERT; счётчики несут гео-разрез.
expected_calls = 2 + 3 * len(ratio_mod._OTHER_REGION_CODES) + 1
assert len(db.executed) == expected_calls
assert db.executed[-1][0] is ratio_mod._COUNTERS_SQL
assert out["geo_regions_written"] == len(ratio_mod._OTHER_REGION_CODES)
assert out["geo_regions_skipped"] == 0
for code in ratio_mod._OTHER_REGION_CODES:
assert out[f"geo_r{code}_cells_matched"] == 16
assert out[f"geo_r{code}_deals_no_geom_pct"] == 5
assert out[f"geo_r{code}_skipped"] == 0
assert marked["run_id"] == 99
assert marked["counters"] == expected
assert marked["counters"] == out
def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
@ -551,3 +605,92 @@ def test_area_bucket_matches_sql_boundaries() -> None:
for i, bound in enumerate(sql_bounds):
assert ratio_mod.area_bucket(bound - 0.01) == i
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
# #3529: стороны собираются по одному разу (deal_geo / ask_geo), дальше всё считается
# по ячейкам — поэтому region_code-предикат ровно два раза, а не четыре.
assert _REDERIVE_SQL_REGION.count("region_code = CAST(:region_code AS int)") == 2
assert "FROM deals" in _REDERIVE_SQL_REGION
assert "FROM listings" in _REDERIVE_SQL_REGION
# Тот же публикуемый порог 30/30 и то же 12-мес окно, что и у ЕКБ-деривации — порог
# теперь применяется к СУММЕ по удержанным гео-ячейкам (#3529).
assert "HAVING SUM(n_deals) >= 30" in _REDERIVE_SQL_REGION
assert "AND SUM(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,
),
):
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.saved == 0

View file

@ -54,7 +54,13 @@ def test_resolve_city_name_known_oblast_slugs() -> None:
"verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов",
}
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
# msk-rollout: многогородние скоупы (MULTI_CITY_SLUGS — область и
# «Москва+область») намеренно БЕЗ display-имени: в их batch'ах городов
# несколько, и любой один штамп city был бы ложью — resolve_city_name
# отдаёт для них None, а save_listings пишет city=NULL.
from scraper_kit.orchestration.pipeline import MULTI_CITY_SLUGS
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) - MULTI_CITY_SLUGS
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
for slug, name in wave1_expected.items():
assert resolve_city_name(slug) == name
@ -76,11 +82,19 @@ def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
def test_resolve_city_name_unknown_slug_raises() -> None:
"""Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
Раньше здесь был ЕКБ-дефолт симметрично get_city_location/get_city_anchors.
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
со штатными счётчиками и без единой аномалии в логах.
"""
import pytest
from scraper_kit.orchestration.pipeline import resolve_city_name
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
with pytest.raises(ValueError, match="nonexistent_city"):
resolve_city_name("nonexistent_city")
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный

View file

@ -0,0 +1,279 @@
"""Снятие устаревших ограничено регионами, где есть регулярный пересбор.
Джоба трактует молчание как «объявление ушло»: строку давно не видели свежей ->
is_active=false. Вывод верен ТОЛЬКО там, где есть механизм пересбора. Регионы 77 и
50 попали в listings разовой загрузкой 10-12.09.2026, регулярного сбора по ним нет
(CITY_ANCHORS кита покрывает только 66, гео-скоуп ДомКлика зашит в ЕКБ) -- без
фильтра около 26.09 кандидатами разом стали бы 40 410 строк ДомКлика, и аварийный
потолок max_deactivated остановил бы снятие ДЛЯ ВСЕХ регионов, включая ЕКБ
(skipped_cap_exceeded не трогает ни одной строки).
Здесь проверяется: дефолт = только 66; предикат стоит и в preflight count(*), и в
UPDATE с ОДНИМ И ТЕМ ЖЕ значением параметра (иначе потолок считает одно, а снимает
другое); явный список расширяет охват; мусор в region_codes отвергается.
"""
from __future__ import annotations
import os
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import deactivate_stale_avito as task_mod
_REGION_PREDICATE = "region_code = ANY(CAST(:region_codes AS int[]))"
# ── Фейковая сессия поверх набора строк ──────────────────────────────────────────
# В отличие от _FakeDB прочих файлов, эта считает кандидатов и rowcount НЕ из
# фикстуры, а применяя предикат к одному и тому же набору строк по параметрам
# КАЖДОГО запроса. Поэтому расхождение preflight и UPDATE (разный :region_codes,
# потерянный фильтр в одном из двух) проявляется как разные числа, а не прячется
# за заранее заданным rowcount.
class _FakeResult:
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
self.rowcount = rowcount
self._scalar = scalar_value
def scalar(self) -> Any:
return self._scalar
class _RowsDB:
"""rows: список dict'ов region_code / stale / active / segment."""
def __init__(
self,
rows: list[dict[str, Any]],
*,
floor_days: float | None = 0.0,
floor_n_pairs: int = 5000,
prev_floor_n_pairs: int | None = 5000,
confirmations: int = 10_000,
) -> None:
self._rows = rows
self._floor = floor_days
self._floor_n_pairs = floor_n_pairs
self._prev_floor_n_pairs = prev_floor_n_pairs
self._confirmations = confirmations
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = False
self.rolled_back = False
def _matching(self, sql: str, params: dict[str, Any] | None) -> int:
params = params or {}
region_codes = params.get("region_codes")
segments = params.get("segments")
matched = 0
for row in self._rows:
if not row.get("active", True):
continue
if not row.get("stale", True):
continue
# Фильтр применяется, только если он реально есть в тексте запроса --
# выкинутый из одного из двух запросов предикат обязан дать расхождение.
if _REGION_PREDICATE in sql:
# ANY(...) никогда не матчит NULL -- та же семантика, что в SQL.
if row["region_code"] is None or row["region_code"] not in (region_codes or []):
continue
if "listing_segment IS NULL" in sql and row.get("segment") is not None:
continue
if "listing_segment = ANY(CAST(:segments AS text[]))" in sql and (
row.get("segment") not in (segments or [])
):
continue
matched += 1
return matched
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt.text)
self.executed.append((sql, params))
if "percentile_disc" in sql:
return _FakeResult(scalar_value=self._floor)
if "FROM scrape_runs prev" in sql:
return _FakeResult(scalar_value=self._prev_floor_n_pairs)
if "JOIN LATERAL" in sql:
return _FakeResult(scalar_value=self._floor_n_pairs)
if "health_window_days" in sql:
return _FakeResult(scalar_value=self._confirmations)
if "SELECT count(*)" in sql and "ttl_days" in sql:
return _FakeResult(scalar_value=self._matching(sql, params))
if "SELECT count(*)" in sql: # active_pool -- весь активный пул источника
return _FakeResult(scalar_value=sum(1 for r in self._rows if r.get("active", True)))
return _FakeResult(rowcount=self._matching(sql, params))
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
self.rolled_back = True
def _query(self, needle: str) -> tuple[str, dict[str, Any]]:
for sql, params in self.executed:
if needle in sql and ("SELECT count(*)" not in sql or "ttl_days" in sql):
return sql, dict(params or {})
return "", {}
@property
def update_query(self) -> tuple[str, dict[str, Any]]:
return self._query("UPDATE listings")
@property
def candidates_query(self) -> tuple[str, dict[str, Any]]:
for sql, params in self.executed:
if sql.lstrip().startswith("SELECT count(*)") and "ttl_days" in sql:
return sql, dict(params or {})
return "", {}
def _run(db: _RowsDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
# Пол включён, чтобы исполнился preflight-блок (он живёт внутри
# revisit_floor_quantile > 0) -- именно его согласованность с UPDATE и проверяем.
kwargs.setdefault("revisit_floor_quantile", task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE)
return task_mod.deactivate_stale_listings(
db, # type: ignore[arg-type]
1,
listing_source=kwargs.pop("listing_source", "domklik"),
ttl_days=kwargs.pop("ttl_days", 14),
**kwargs,
)
def _corpus() -> list[dict[str, Any]]:
"""4 устаревших строки ЕКБ + по 3 строки «залитых руками» 77 и 50."""
return (
[{"region_code": 66, "stale": True} for _ in range(4)]
+ [{"region_code": 77, "stale": True} for _ in range(3)]
+ [{"region_code": 50, "stale": True} for _ in range(3)]
)
# ── Дефолт: только регион с регулярным сбором ────────────────────────────────────
def test_default_region_scope_is_sverdlovsk_only() -> None:
assert task_mod.DEFAULT_DEACTIVATION_REGION_CODES == (66,)
def test_default_run_does_not_touch_rows_of_other_regions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _RowsDB(_corpus())
out = _run(db, monkeypatch)
assert out["deactivated"] == 4, "сняты должны быть только 4 строки региона 66"
assert db.update_query[1]["region_codes"] == [66]
assert db.committed is True
def test_rows_with_null_region_code_are_never_deactivated(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ANY(...) не матчит NULL -- неизвестный регион нечем отнести к покрытому сбором."""
db = _RowsDB([{"region_code": 66, "stale": True}, {"region_code": None, "stale": True}])
out = _run(db, monkeypatch)
assert out["deactivated"] == 1
# ── Preflight и UPDATE согласованы ───────────────────────────────────────────────
def test_preflight_counts_exactly_what_update_deactivates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _RowsDB(_corpus())
out = _run(db, monkeypatch)
assert out["deactivation_candidates"] == out["deactivated"] == 4
def test_preflight_and_update_bind_the_same_region_codes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _RowsDB(_corpus())
_run(db, monkeypatch, region_codes=[66, 77])
assert db.candidates_query[1]["region_codes"] == db.update_query[1]["region_codes"] == [66, 77]
@pytest.mark.parametrize(
"build_update, build_count",
[
(task_mod._build_all_segments_sql, task_mod._build_all_segments_candidates_count_sql),
(task_mod._build_segments_sql, task_mod._build_segments_candidates_count_sql),
(task_mod._build_null_segment_sql, task_mod._build_null_segment_candidates_count_sql),
],
)
def test_region_predicate_present_in_both_update_and_count_sql(
build_update: Any, build_count: Any
) -> None:
"""Предикат продублирован текстуально в обеих ветках -- потерять его в одной
значит считать кандидатов по одному срезу, а снимать по другому."""
for sql in (str(build_update("scraped_at").text), str(build_count("scraped_at").text)):
assert _REGION_PREDICATE in sql
# psycopg v3: никакого :param::type
assert ":region_codes::" not in sql
def test_cap_counts_only_covered_regions(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ради чего всё: чужой регион не должен пробивать аварийный потолок и глушить
снятие по своему (skipped_cap_exceeded не трогает НИ ОДНОЙ строки)."""
rows = [{"region_code": 66, "stale": True} for _ in range(3)]
rows += [{"region_code": 77, "stale": True} for _ in range(100)]
db = _RowsDB(rows)
out = _run(db, monkeypatch, max_deactivated=10)
assert "skipped_cap_exceeded" not in out
assert out["deactivated"] == 3
# ── Явный список расширяет охват ─────────────────────────────────────────────────
def test_explicit_region_codes_widen_the_scope(monkeypatch: pytest.MonkeyPatch) -> None:
db = _RowsDB(_corpus())
out = _run(db, monkeypatch, region_codes=[66, 77])
assert out["deactivated"] == 7
assert out["deactivation_candidates"] == 7
def test_explicit_region_codes_can_target_a_single_foreign_region(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _RowsDB(_corpus())
out = _run(db, monkeypatch, region_codes=[50])
assert out["deactivated"] == 3
# ── Валидация (jsonb-опечатки в default_params) ──────────────────────────────────
def test_empty_region_codes_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""[] матчил бы 0 строк и выглядел бы здоровым прогоном -- явный ValueError."""
db = _RowsDB(_corpus())
with pytest.raises(ValueError, match="region_codes"):
_run(db, monkeypatch, region_codes=[])
def test_bool_region_code_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""jsonb `true` прошёл бы как регион 1 -- тот же класс опечатки, что ttl_days."""
db = _RowsDB(_corpus())
with pytest.raises(ValueError, match="region_codes"):
_run(db, monkeypatch, region_codes=[True])
def test_non_int_region_code_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
db = _RowsDB(_corpus())
with pytest.raises(ValueError, match="region_codes"):
_run(db, monkeypatch, region_codes=["66"]) # type: ignore[list-item]
def test_invalid_region_codes_touch_no_rows(monkeypatch: pytest.MonkeyPatch) -> None:
db = _RowsDB(_corpus())
with pytest.raises(ValueError):
_run(db, monkeypatch, region_codes=[])
assert db.update_query[0] == ""
assert db.committed is False

View file

@ -0,0 +1,115 @@
"""Гео-скоуп DomClick — параметр региона, а не зашитый Екатеринбург.
До этого GUID/aids/страница прогрева/bbox жили модульными константами `_EKB_*`,
и прогон с region_code 77/50 собрал бы ЕКБ под чужим регионом. Тесты стерегут
две вещи: профиль 66 не изменил НИ БАЙТА в запросах (прод-поведение прежнее),
а профили Москвы и области действительно меняют скоуп без aids и без сверки
имени региона, которое вне ЕКБ ненадёжно.
"""
from types import SimpleNamespace
import pytest
from scraper_kit.providers.domclick.serp import (
DOMCLICK_GEO_PROFILES,
DomClickScraper,
_build_count_url,
_build_offers_url,
geo_profile_for_region,
)
# Литералы снятые с кода ДО параметризации: любое расхождение = смена прод-запроса.
_EKB_OFFERS_URL_BEFORE = (
"https://bff-search-web.domclick.ru/api/offers/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=5%2B"
"&sort=qi&sort_dir=desc&offset=40&limit=20"
"&sale_price__gte=1000000&sale_price__lte=5000000"
)
_EKB_COUNT_URL_BEFORE = (
"https://bff-search-web.domclick.ru/api/offers/count/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=2"
"&sort=qi&sort_dir=desc"
)
def _url_for(region_code: int, *, count: bool) -> str:
profile = geo_profile_for_region(region_code)
if count:
return _build_count_url(
"2", None, None, address_guid=profile.address_guid, aids=profile.aids
)
return _build_offers_url(
"5+",
1_000_000,
5_000_000,
40,
address_guid=profile.address_guid,
aids=profile.aids,
)
def test_ekb_offers_url_unchanged_byte_for_byte() -> None:
assert _url_for(66, count=False) == _EKB_OFFERS_URL_BEFORE
def test_ekb_count_url_unchanged_byte_for_byte() -> None:
assert _url_for(66, count=True) == _EKB_COUNT_URL_BEFORE
@pytest.mark.parametrize("region_code", [77, 50])
@pytest.mark.parametrize("count", [False, True])
def test_msk_and_oblast_urls_carry_no_aids(region_code: int, count: bool) -> None:
"""aids сужает выдачу до агрегатора; у Москвы и области счётчик без него
совпадает с сайтом (замер 12.09.2026), поэтому параметра быть не должно."""
url = _url_for(region_code, count=count)
assert "aids=" not in url
assert f"address={geo_profile_for_region(region_code).address_guid}" in url
def test_profiles_are_distinct_and_ekb_keeps_aids() -> None:
guids = {code: p.address_guid for code, p in DOMCLICK_GEO_PROFILES.items()}
assert len(set(guids.values())) == len(guids)
assert DOMCLICK_GEO_PROFILES[66].aids == "20561"
# Имя региона сверяется ТОЛЬКО у ЕКБ.
assert DOMCLICK_GEO_PROFILES[66].region_name == "Екатеринбург"
assert DOMCLICK_GEO_PROFILES[77].region_name is None
assert DOMCLICK_GEO_PROFILES[50].region_name is None
# Имя города для listings.city: у области его нет и угадывать нечего.
assert DOMCLICK_GEO_PROFILES[66].city_name == "Екатеринбург"
assert DOMCLICK_GEO_PROFILES[77].city_name == "Москва"
assert DOMCLICK_GEO_PROFILES[50].city_name is None
def _geo_ok(region_code: int, item: dict[str, object]) -> bool:
"""_is_geo_ok без конструирования скрейпера: метод читает только self._geo."""
fake = SimpleNamespace(_geo=geo_profile_for_region(region_code))
return DomClickScraper._is_geo_ok(fake, item) # type: ignore[arg-type]
def test_msk_accepts_novaya_moskva_with_foreign_region_name() -> None:
"""Новая Москва приходит как «г. Говорово» — сверка по имени вырезала бы
законную выдачу, поэтому вне 66 гард только по bbox."""
item = {"offerRegionName": "г. Говорово", "location": {"lat": 55.62, "lon": 37.35}}
assert _geo_ok(77, item) is True
def test_msk_rejects_point_outside_bbox() -> None:
ekb_point = {"offerRegionName": "Москва", "location": {"lat": 56.83, "lon": 60.60}}
assert _geo_ok(77, ekb_point) is False
def test_ekb_still_rejects_foreign_region_name() -> None:
item = {"offerRegionName": "г. Говорово", "location": {"lat": 56.83, "lon": 60.60}}
assert _geo_ok(66, item) is False
def test_oblast_bbox_accepts_podmoskovie_point() -> None:
item = {"offerRegionName": "г. Подольск", "location": {"lat": 55.43, "lon": 37.55}}
assert _geo_ok(50, item) is True
def test_unknown_region_code_raises() -> None:
with pytest.raises(ValueError, match="region_code=99"):
geo_profile_for_region(99)

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 db.begin_nested_count == 2 # первый заход упал, второй прошёл
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:
@ -82,7 +83,8 @@ def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
assert ratio 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.rollback_count >= 2
@ -97,7 +99,7 @@ def test_ratio_happy_path_single_attempt_no_rollback() -> None:
assert basis == "global_fallback"
assert db.begin_nested_count == 1 # ни одного retry
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[0] == 0.9
assert cached[1] == "global_fallback"

View file

@ -107,9 +107,23 @@ def _run_estimate_with_ratio_spy(
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.
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
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).
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.
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
captured_anchor_ppm2.append(anchor_ppm2)
return (0.78, "per_rooms")

View file

@ -234,17 +234,19 @@ def test_insert_is_idempotent_on_conflict() -> None:
def test_match_distinct_on_tiebreak_max_flat_count() -> None:
flat = re.sub(r"\s+", " ", _MATCH_SQL)
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
assert "DISTINCT ON (canon)" in flat
assert "tradein_canon_addr(norm_address) AS canon" in flat
# Tie-break: на canon — строка с макс flat_count, затем меньший house_guid.
assert "ORDER BY canon, flat_count DESC, house_guid" in flat
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
houses_side = (
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
)
assert houses_side in flat
# ЕКБ-ограничение по full_address (защита от cross-town коллизий).
# city-ограничение по full_address (защита от cross-town коллизий, region-зависимо).
assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat
# Без city-фильтра неоднозначный canon (>1 разных GAR-домов) НЕ матчится (безопасная
# деградация вместо угадывания через max(flat_count)) — см. commet у _MATCH_SQL.
assert "CAST(:city AS text) IS NOT NULL OR canon_hits = 1" in flat
# Идемпотентность повторного прогона.
assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat
assert "gar_match_method = 'canon_addr'" in flat
@ -252,6 +254,46 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
assert "flat_count > 0" in flat
# ─────────────────────────────────────────────────────────────────────────────
# Город-фильтр ПО РЕГИОНУ (не хардкод "Екатеринбург" на все регионы)
# ─────────────────────────────────────────────────────────────────────────────
def test_default_city_filter_region_66_matches_old_constant() -> None:
"""region 66 → «Екатеринбург» — byte-for-byte прежний DEFAULT_CITY_FILTER."""
assert gfl.default_city_filter_for_region("66") == "Екатеринбург"
def test_default_city_filter_region_77_and_50_disabled() -> None:
"""Москва (один город) и область (нет доминирующего города) — без фильтра."""
assert gfl.default_city_filter_for_region("77") is None
assert gfl.default_city_filter_for_region("50") is None
def test_default_city_filter_unknown_region_disabled() -> None:
assert gfl.default_city_filter_for_region("99") is None
assert gfl.default_city_filter_for_region(None) is None
assert gfl.default_city_filter_for_region("not-a-number") is None
def test_match_houses_to_gar_auto_resolves_by_region(monkeypatch: pytest.MonkeyPatch) -> None:
"""city_filter=CITY_FILTER_AUTO (умолч.) резолвится через default_city_filter_for_region."""
captured: dict[str, Any] = {}
class _FakeResult:
rowcount = 0
def fake_execute(_sql: Any, params: dict[str, Any]) -> _FakeResult:
captured.update(params)
return _FakeResult()
fake_db = type("FakeDB", (), {"execute": staticmethod(fake_execute)})()
gfl.match_houses_to_gar(fake_db, region_code="66") # city_filter не передан → AUTO
assert captured["city"] == "Екатеринбург"
captured.clear()
gfl.match_houses_to_gar(fake_db, region_code="77")
assert captured["city"] is None
def test_no_psycopg_v3_colon_colon_cast() -> None:
# Только исполняемый SQL (модульный docstring специально содержит анти-паттерн как памятку).
assert not re.search(r":\w+::", _INSERT_SQL)
@ -542,3 +584,71 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
finally:
db.rollback()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_no_city_filter_ambiguous_canon_not_matched() -> None:
"""Без city-фильтра (регион без доминирующего города, напр. 50): одинаковый canon у ДВУХ
разных ГАР-домов (разные города, общее название улицы) не резолвится угадыванием
canon_hits>1 строка НЕ матчится вовсе (см. коммент у _MATCH_SQL). Отдельный
невзаимодействующий canon в том же прогоне при этом матчится нормально."""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
raw = db.connection()
raw.exec_driver_sql(_NORMALIZER_FN)
raw.exec_driver_sql(_CANON_FN)
db.execute(
text(
"CREATE TEMP TABLE gar_house_flats ("
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
" flat_count int, full_address text, norm_address text, street_name text,"
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
)
)
# Две РАЗНЫЕ улицы «Ленина, 5» в двух разных городах области (50) — одинаковый
# canon «ленина5» (canon не несёт населённый пункт, мигр. 144). Плюс одна
# однозначная улица «Мира, 1» без коллизии.
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('h-town-a', '50', 10, 'обл Московская, г Балашиха, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('h-town-b', '50', 20, 'обл Московская, г Химки, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('h-unique', '50', 7, 'обл Московская, г Химки, ул Мира, 1', 'ул Мира, 1')"
)
)
db.execute(
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')")
)
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
# Только однозначный canon «мира1» матчится; «ленина5» (2 разных дома) — пропущен.
assert matched == 1
lenina = db.execute(
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Ленина,5'")
).scalar()
assert lenina is None # неоднозначность НЕ разрешена угадыванием
mira = db.execute(
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Мира,1'")
).scalar()
assert mira == 7 # однозначный canon матчится как обычно
finally:
db.rollback()
db.close()

View file

@ -95,6 +95,13 @@ _WAVE2_SLUGS = {
assert len(_WAVE2_SLUGS) == 40
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
# msk-rollout (черновик): Москва, область и объединённый скоуп заведены в
# CITY_ANCHORS/CITY_LOCATIONS, но строк расписаний у них СОЗНАТЕЛЬНО нет —
# реестры едут отдельным заходом от включения сбора. Проверки соответствия
# «реестр ↔ schedule row» ниже — про свердловскую волну #262, поэтому
# московские скоупы из них вычитаются явно (а не тихо расширяют ожидания).
_MSK_SCOPES = {"moskva", "moskovskaya_oblast", "moskva_i_mo"}
def _sql(path: Path) -> str:
return path.read_text(encoding="utf-8")
@ -262,7 +269,7 @@ def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
scheduled_avito = rows_262 | rows_179
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
assert scheduled_avito == confirmed_avito, (
assert scheduled_avito == confirmed_avito - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
)
@ -274,7 +281,7 @@ def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None
scheduled_yandex = rows_262 | rows_179
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
assert scheduled_yandex == confirmed_yandex, (
assert scheduled_yandex == confirmed_yandex - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
)
@ -288,7 +295,7 @@ def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> N
confirmed_cian = {
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
}
assert scheduled_cian == confirmed_cian, (
assert scheduled_cian == confirmed_cian - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
)
@ -331,7 +338,7 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
slug for _p, slug in _row_sources(_MIGRATION_262)
}
orphaned = set(CITY_ANCHORS) - seeded_slugs
orphaned = set(CITY_ANCHORS) - seeded_slugs - _MSK_SCOPES
assert not orphaned, (
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
)
@ -340,8 +347,8 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
def test_city_anchors_wave2_count_and_content() -> None:
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
assert len(CITY_ANCHORS) == 45
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:

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 counters.read == 5
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_invalid == 1
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
)
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.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)
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:
@ -480,6 +480,261 @@ def test_domclick_non_moscow_is_rejected() -> 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:
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

@ -0,0 +1,94 @@
"""Гард гео-скоупа свипов: `region_code` 77/50 не должен молча собирать Екатеринбург.
Контекст: планировщик читает регион из строки расписания (#3515,
`scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им у avito/cian/
yandex это anchors города (`CITY_ANCHORS`/`CITY_LOCATIONS`, сегодня только
Свердловская обл.), а у domclick профиль региона в BFF-URL (DOMCLICK_GEO_PROFILES:
66/77/50; код без профиля падение). Заведи мы
строку `*_city_sweep_moscow` с `{"region_code": 77}` прогон собрал бы ЕКБ и
проштамповал его московским регионом, не показав ни одной аномалии в счётчиках.
Гард превращает эту тихую порчу в падение до первого запроса.
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import Any
import pytest
from scraper_kit.orchestration.pipeline import (
_require_region_scope,
run_avito_city_sweep,
run_cian_city_sweep,
run_domclick_city_sweep,
run_yandex_city_sweep,
)
from scraper_kit.providers.domclick.serp import geo_profile_for_region
_EKB_ANCHOR = [(56.8400, 60.6050, "Центр")]
def test_default_region_without_anchors_passes() -> None:
"""ЕКБ-прогоны (anchors=None, регион по умолчанию 66) не трогаем."""
_require_region_scope("avito_city_sweep", 66, None)
def test_default_region_with_city_anchors_passes() -> None:
"""Свердловские oblast-строки (#179/#262) продолжают работать как прежде."""
_require_region_scope("cian_city_sweep", 66, [(57.910, 59.980, "Н.Тагил центр")])
@pytest.mark.parametrize("region_code", [50, 77])
def test_foreign_region_without_anchors_raises(region_code: int) -> None:
"""Москва/МО без своих anchors — тот самый случай, что молча давал ЕКБ."""
with pytest.raises(ValueError, match=f"region_code={region_code}"):
_require_region_scope("avito_city_sweep", region_code, None)
@pytest.mark.parametrize("region_code", [50, 77])
def test_foreign_region_with_explicit_anchors_passes(region_code: int) -> None:
"""Явные anchors — явный гео-скоуп: запрещаем не регион, а молчаливый ЕКБ-fallback."""
_require_region_scope("avito_city_sweep", region_code, _EKB_ANCHOR)
def _required_kwargs(fn: Callable[..., Any], **overrides: Any) -> dict[str, Any]:
"""None во все обязательные keyword-параметры: гард обязан сработать раньше их."""
kwargs: dict[str, Any] = {
name: None
for name, prm in inspect.signature(fn).parameters.items()
if prm.kind is inspect.Parameter.KEYWORD_ONLY and prm.default is inspect.Parameter.empty
}
kwargs.update(overrides)
return kwargs
@pytest.mark.parametrize(
"sweep",
[run_avito_city_sweep, run_cian_city_sweep, run_yandex_city_sweep],
)
async def test_anchor_sweeps_reject_foreign_region(sweep: Callable[..., Any]) -> None:
"""Гард стоит до первого обращения к БД/сети — поэтому db=None здесь безопасен."""
with pytest.raises(ValueError, match="region_code=77"):
await sweep(None, **_required_kwargs(sweep, run_id=1, region_code=77, anchors=None))
@pytest.mark.parametrize("region_code", [1, 99])
async def test_domclick_sweep_rejects_region_without_profile(region_code: int) -> None:
"""У домклика anchors нет вовсе: гео-скоуп задаёт профиль региона
(DOMCLICK_GEO_PROFILES: address GUID, aids, страница прогрева, bbox). 66/77/50
теперь поддержаны, а код без профиля по-прежнему падает до первого запроса:
тихий fallback на ЕКБ ровно та порча, от которой стережёт этот файл."""
with pytest.raises(ValueError, match=f"region_code={region_code}"):
await run_domclick_city_sweep(
None, # type: ignore[arg-type]
**_required_kwargs(run_domclick_city_sweep, run_id=1, region_code=region_code),
)
@pytest.mark.parametrize("region_code", [66, 77, 50])
def test_domclick_supported_regions_have_profiles(region_code: int) -> None:
"""Москва и область больше НЕ режутся: у них свой GUID и свой bbox."""
profile = geo_profile_for_region(region_code)
assert profile.region_code == region_code
assert profile.address_guid

View file

@ -0,0 +1,233 @@
"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей.
Черновик msk-rollout: снимаем привязку avito/cian/yandex к Екатеринбургу. Тесты
здесь чисто реестровые (никаких сетевых вызовов): проверяют, что
* ЕКБ-путь не сдвинулся ни на байт (хост, слаг, rgid, region=),
* Москва/область/объединённый скоуп дают ровно измеренные живьём значения,
* сетка якорей Москвы лежит внутри продуктового ядра и покрывает его без дыр,
* неизвестный слаг ПАДАЕТ, а не откатывается молча на Екатеринбург.
"""
from __future__ import annotations
import math
from types import SimpleNamespace
import pytest
from scraper_kit.orchestration.pipeline import (
CIAN_MOSCOW_HOST,
CIAN_SVERDLOVSK_HOST,
CITY_LOCATIONS,
MOSCOW_ANCHOR_RADIUS_M,
MOSCOW_ANCHORS,
MOSCOW_CORE_BBOX_DEG,
MOSCOW_OBLAST_ANCHOR_RADIUS_M,
MOSCOW_OBLAST_CITY_ANCHORS,
MOSCOW_OBLAST_CLUSTER_ANCHORS,
MULTI_CITY_SLUGS,
get_city_anchor_point,
get_city_anchors,
get_city_location,
get_city_stamp_radius_km,
resolve_city_name,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
def _cfg() -> SimpleNamespace:
"""Минимальный ScraperConfig-стаб: конструкторам scraper'ов хватает атрибутов."""
return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
# ── ЕКБ не сдвинулся ────────────────────────────────────────────────────────
def test_ekb_cian_host_and_region_byte_identical() -> None:
"""ЕКБ идёт по None-пути (get_city_location(None) → None) — хост класса и region=
остаются прежними: ekb.cian.ru + region=4743."""
assert get_city_location(None) is None
assert CianScraper.base_url == "https://ekb.cian.ru" == CIAN_SVERDLOVSK_HOST
scraper = CianScraper(_cfg(), city_region_id=None, city_host=None) # type: ignore[arg-type]
url = scraper._build_url()
assert url.startswith("https://ekb.cian.ru/cat.php?")
assert "region=4743" in url
def test_ekb_avito_slug_and_card_filter_unchanged() -> None:
"""Без target_city_slug путь URL остаётся /ekaterinburg/, фильтр карточек включён."""
scraper = AvitoScraper(_cfg()) # type: ignore[arg-type]
assert scraper._city_seg() == "ekaterinburg"
assert scraper._filter_cards_by_city_slug is True
def test_ekb_yandex_rgid_unchanged() -> None:
scraper = YandexRealtyScraper(_cfg(), city_rgid=None) # type: ignore[arg-type]
assert scraper._rgid == 559132
def test_sverdlovsk_entries_keep_default_host() -> None:
"""Все свердловские записи остаются на ekb.cian.ru — поле добавлено с дефолтом."""
for slug, loc in CITY_LOCATIONS.items():
if slug in {"moskva", "moskovskaya_oblast", "moskva_i_mo"}:
continue
assert loc.cian_host == CIAN_SVERDLOVSK_HOST, slug
assert loc.avito_slug_is_region is False, slug
# ── Москва / область / объединённый скоуп ───────────────────────────────────
@pytest.mark.parametrize(
("slug", "region_id", "rgid"),
[
("moskva", 1, 587795),
("moskovskaya_oblast", 4593, 587654),
("moskva_i_mo", -1, 741964),
],
)
def test_moscow_scopes_ids(slug: str, region_id: int, rgid: int) -> None:
"""Замер 10-12.09.2026: region=1 Москва, 4593 область, -1 обе вместе."""
loc = get_city_location(slug)
assert loc is not None
assert loc.cian_region_id == region_id
assert loc.yandex_rgid == rgid
assert loc.cian_host == CIAN_MOSCOW_HOST == "https://www.cian.ru"
# avito: ОДИН слаг на оба региона, отдельного слага у области нет.
assert loc.avito_slug == "moskva_i_mo"
assert loc.avito_slug_is_region is True
scraper = CianScraper( # type: ignore[arg-type]
_cfg(), city_region_id=loc.cian_region_id, city_host=loc.cian_host
)
url = scraper._build_url()
assert url.startswith("https://www.cian.ru/cat.php?")
assert f"region={region_id}" in url
def test_moscow_region_slug_disables_card_filter() -> None:
"""Слаг региона в ссылках карточек не встречается → per-card фильтр выключается,
иначе отфильтровалась бы вся выдача (avito_serp_ekb_only=True в конфиге)."""
loc = get_city_location("moskva")
assert loc is not None
scraper = AvitoScraper( # type: ignore[arg-type]
_cfg(),
target_city_slug=loc.avito_slug,
filter_cards_by_city_slug=not loc.avito_slug_is_region,
)
assert scraper._city_seg() == "moskva_i_mo"
assert scraper._filter_cards_by_city_slug is False
def test_multi_city_scopes_do_not_stamp_a_city() -> None:
"""У области и объединённого скоупа город в batch'е не один — штампа быть не должно."""
assert MULTI_CITY_SLUGS == frozenset({"moskovskaya_oblast", "moskva_i_mo"})
assert resolve_city_name("moskovskaya_oblast") is None
assert resolve_city_name("moskva_i_mo") is None
assert resolve_city_name("moskva") == "Москва"
assert resolve_city_name(None) == "Екатеринбург"
# ── сетка якорей Москвы ─────────────────────────────────────────────────────
def test_moscow_grid_inside_core_and_centered() -> None:
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
assert len(MOSCOW_ANCHORS) == 25
for lat, lon, name in MOSCOW_ANCHORS:
assert lat_min <= lat <= lat_max, name
assert lon_min <= lon <= lon_max, name
# anchors[0] — центр ядра: именно его берёт гео-гард штампа.
assert get_city_anchor_point("moskva") == (55.75, 37.60)
def test_moscow_grid_covers_core_without_holes() -> None:
"""Любая точка ядра не дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего якоря."""
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
radius_km = MOSCOW_ANCHOR_RADIUS_M / 1000.0
worst = 0.0
lat_steps = round((lat_max - lat_min) / 0.01)
lon_steps = round((lon_max - lon_min) / 0.01)
for i in range(lat_steps + 1):
lat = lat_min + i * 0.01
for j in range(lon_steps + 1):
lon = lon_min + j * 0.01
nearest = min(
_haversine_km(lat, lon, a_lat, a_lon) for a_lat, a_lon, _ in MOSCOW_ANCHORS
)
worst = max(worst, nearest)
assert worst <= radius_km, f"дыра в покрытии: худшая точка ядра в {worst:.2f} км от якоря"
def test_moscow_oblast_anchors_are_satellite_cities() -> None:
"""У области — по якорю на город-спутник, координаты из реестра репозитория."""
anchors = get_city_anchors("moskovskaya_oblast")
assert anchors is not None
names = [name for _lat, _lon, name in anchors]
assert "Балашиха" in names and "Химки" in names and "Подольск" in names
assert len(names) == len(set(names))
combined = get_city_anchors("moskva_i_mo")
assert combined is not None
assert len(combined) == len(MOSCOW_ANCHORS) + len(anchors)
def test_moscow_oblast_anchors_are_two_measured_layers() -> None:
"""Оба слоя на месте и не перепутаны: города-спутники + кластеры лот-массы."""
anchors = get_city_anchors("moskovskaya_oblast")
assert anchors is not None
assert anchors == [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]
# Слой 2 добирает то, чего не дают города: без него покрытие лот-массы 73.8%.
assert len(MOSCOW_OBLAST_CLUSTER_ANCHORS) > 0
cities = {name for _lat, _lon, name in MOSCOW_OBLAST_CITY_ANCHORS}
clusters = {name for _lat, _lon, name in MOSCOW_OBLAST_CLUSTER_ANCHORS}
assert not (cities & clusters), "подписи слоёв пересеклись — якорь задублирован"
def test_moscow_oblast_anchors_lie_inside_region_50_bbox() -> None:
"""Якорь вне области собрал бы чужой регион и проштамповал бы его как 50.
Границы bbox_region региона 50 из backend/app/services/regions.py (дубль:
kit не имеет права импортировать app.*).
"""
lat_min, lat_max, lon_min, lon_max = 54.20, 56.96, 35.14, 40.21
for lat, lon, name in [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]:
assert lat_min <= lat <= lat_max, f"{name}: широта вне области"
assert lon_min <= lon <= lon_max, f"{name}: долгота вне области"
def test_moscow_oblast_radius_is_the_one_coverage_was_measured_at() -> None:
"""95.2% лот-массы посчитаны при 10 км; меньший radius_m в расписании даст дыры."""
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M == 10000
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M > MOSCOW_ANCHOR_RADIUS_M
def test_moscow_stamp_radius_covers_core_diagonal() -> None:
"""15 км по умолчанию отрезали бы легитимную Москву — у ядра полудиагональ 29.1 км."""
_lat_min, lat_max, _lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
center = get_city_anchor_point("moskva")
assert center is not None
corner = _haversine_km(center[0], center[1], lat_max, lon_max)
assert corner > 15.0
assert get_city_stamp_radius_km("moskva") >= corner
# область/объединённый скоуп штампа не ставят вовсе (city=None → гард инертен).
assert resolve_city_name("moskovskaya_oblast") is None
# ── неизвестный слаг падает ─────────────────────────────────────────────────
@pytest.mark.parametrize("fn", [get_city_anchors, get_city_location, resolve_city_name])
def test_unknown_slug_raises_instead_of_ekb_fallback(fn) -> None: # type: ignore[no-untyped-def]
with pytest.raises(ValueError, match="nonexistent_city"):
fn("nonexistent_city")

View file

@ -0,0 +1,261 @@
"""Тесты чистого планировщика ценовых коридоров (`scraper_kit.pricing.planner`).
Планировщик синхронный и чистый: «сколько позиций в коридоре» инжектится
функцией, поэтому здесь НЕТ ни сети, ни моков площадок только модели
распределения цен.
"""
from __future__ import annotations
import bisect
import pytest
from scraper_kit.pricing.planner import (
EKB_REGION_CODE,
MAX_DEPTH,
MIN_WIDTH_RATIO,
geometric_mid,
plan_price_corridors,
price_bisection_enabled,
width_ratio,
)
def _counter(prices: list[int]):
"""`count_of` поверх отсортированного списка цен: сколько попало в [lo, hi]."""
ordered = sorted(prices)
def count_of(lo: int | None, hi: int | None) -> int:
left = 0 if lo is None else bisect.bisect_left(ordered, lo)
right = len(ordered) if hi is None else bisect.bisect_right(ordered, hi)
return max(0, right - left)
return count_of
def _covers_every_price(plan, prices: list[int]) -> bool:
"""Каждая цена попадает хотя бы в один коридор плана."""
return all(
any((c.lo is None or p >= c.lo) and (c.hi is None or p <= c.hi) for c in plan.corridors)
for p in prices
)
# ── равномерное распределение ────────────────────────────────────────────────
def test_uniform_spread_splits_until_every_corridor_fits_target() -> None:
"""Равномерная выборка делится, пока каждый коридор не влезет в target."""
prices = list(range(1_000_000, 21_000_000, 2_000)) # 10 000 позиций
plan = plan_price_corridors(
count_of=_counter(prices),
target=1_200,
hard_cap=1_500,
lo=1_000_000,
hi=21_000_000,
)
assert plan.stats.root_count == len(prices)
assert plan.stats.corridors > 1, "10k позиций при target=1200 обязаны поделиться"
assert plan.stats.truncated_corridors == 0
assert plan.stats.missed_estimate == 0
assert plan.stats.complete
assert all(c.count is not None and c.count <= 1_200 for c in plan.corridors)
assert _covers_every_price(plan, prices)
def test_small_sample_stays_one_corridor() -> None:
"""Выборка ниже target не делится вовсе — один коридор, один probe."""
prices = list(range(2_000_000, 3_000_000, 10_000)) # 100 позиций
plan = plan_price_corridors(count_of=_counter(prices), target=1_200)
assert plan.stats.corridors == 1
assert plan.stats.probes == 1
assert plan.stats.complete
# ── вырожденный случай: всё в одной цене ─────────────────────────────────────
def test_single_price_point_hits_min_width_ratio_and_is_marked_truncated() -> None:
"""«Всё в одной цене» упирается в MIN_WIDTH_RATIO, а не зацикливается.
Коридор вокруг единственной цены сузить до нужного размера невозможно
делить бессмысленно. Планировщик обязан остановиться и ПОМЕТИТЬ коридор
усечённым с честным недобором, а не молча отдать план «всё влезло».
"""
prices = [3_000_000] * 5_000
plan = plan_price_corridors(
count_of=_counter(prices),
target=1_200,
hard_cap=1_500,
lo=1_000_000,
hi=10_000_000,
)
assert plan.stats.truncated_corridors >= 1
assert plan.stats.missed_estimate > 0
assert not plan.stats.complete
overflowing = [c for c in plan.corridors if c.count and c.count > 1_500]
assert overflowing, "переполненный коридор обязан остаться в плане"
for c in overflowing:
assert c.truncated, f"коридор {c.label()} переполнен, но не помечен"
assert c.missed == c.count - 1_500
# Остановились по предохранителю, а не «просто так».
assert c.depth >= MAX_DEPTH or width_ratio(c.lo, c.hi) <= MIN_WIDTH_RATIO
assert plan.stats.missed_estimate == sum(c.missed for c in plan.truncated)
def test_degenerate_plan_terminates_within_depth_and_probe_budget() -> None:
"""Вырожденный вход завершается: глубина ограничена, число probe конечно."""
plan = plan_price_corridors(
count_of=_counter([3_000_000] * 5_000),
target=1_200,
lo=1_000_000,
hi=10_000_000,
)
assert plan.stats.max_depth_reached <= MAX_DEPTH
assert plan.stats.probes < 2 ** (MAX_DEPTH + 1)
def test_max_depth_is_never_exceeded_on_open_ended_range() -> None:
"""Даже на открытом диапазоне глубина не превышает MAX_DEPTH."""
prices = list(range(500_000, 100_000_000, 5_000))
plan = plan_price_corridors(count_of=_counter(prices), target=1_000)
assert plan.stats.max_depth_reached <= MAX_DEPTH
assert all(c.depth <= MAX_DEPTH for c in plan.corridors)
@pytest.mark.parametrize("max_depth", [1, 2, 3])
def test_custom_max_depth_caps_recursion(max_depth: int) -> None:
"""Пониженный max_depth режет дерево раньше — и недобор виден в статистике."""
prices = list(range(1_000_000, 21_000_000, 2_000))
plan = plan_price_corridors(
count_of=_counter(prices),
target=1_200,
hard_cap=1_500,
lo=1_000_000,
hi=21_000_000,
max_depth=max_depth,
)
assert plan.stats.max_depth_reached <= max_depth
# Мелкий потолок глубины физически не даёт добрать всё — это обязано
# проявиться усечением, а не «успехом».
assert plan.stats.truncated_corridors > 0
assert plan.stats.missed_estimate > 0
# ── регион 66: поведение прежнее ─────────────────────────────────────────────
def test_region_66_disables_bisection() -> None:
"""Свердловская обл. — бисекция выключена; неизвестный регион трактуем как 66."""
assert price_bisection_enabled(EKB_REGION_CODE) is False
assert price_bisection_enabled(None) is False
assert price_bisection_enabled(77) is True # Москва
assert price_bisection_enabled(50) is True # Московская обл.
def test_region_66_yields_exactly_one_corridor_over_whole_range() -> None:
"""С выключенной бисекцией план = ровно один коридор на весь диапазон.
Это и есть «поведение региона 66 осталось прежним»: ни одного лишнего
запроса к площадке сверх единственного корневого probe.
"""
prices = list(range(1_000_000, 21_000_000, 2_000))
plan = plan_price_corridors(
count_of=_counter(prices),
target=1_200,
lo=1_000_000,
hi=21_000_000,
enabled=price_bisection_enabled(EKB_REGION_CODE),
)
assert plan.stats.corridors == 1
assert plan.stats.probes == 1
only = plan.corridors[0]
assert (only.lo, only.hi) == (1_000_000, 21_000_000)
assert only.depth == 0
assert _covers_every_price(plan, prices)
def test_disabled_plan_still_reports_truncation() -> None:
"""Выключенная бисекция не значит «молчать»: переполнение всё равно в счётчиках."""
plan = plan_price_corridors(
count_of=_counter(list(range(1_000_000, 21_000_000, 2_000))),
target=1_200,
hard_cap=1_500,
enabled=False,
)
assert plan.stats.corridors == 1
assert plan.stats.truncated_corridors == 1
assert plan.stats.missed_estimate == 10_000 - 1_500
assert not plan.stats.complete
# ── непрочитанный счётчик ────────────────────────────────────────────────────
def test_unreadable_counter_is_marked_not_silently_trusted() -> None:
"""`count_of` вернул None → коридор слепой: truncated + unknown, не «пусто»."""
plan = plan_price_corridors(count_of=lambda lo, hi: None, target=1_200)
assert plan.stats.corridors == 1
assert plan.stats.unknown_corridors == 1
assert plan.corridors[0].unknown_count is True
assert plan.corridors[0].truncated is True
assert not plan.stats.complete
# ── геометрическая середина ──────────────────────────────────────────────────
def test_geometric_mid_is_geometric_not_arithmetic() -> None:
"""sqrt(lo*hi), а не (lo+hi)/2 — иначе верхняя половина вырожденно пуста."""
assert geometric_mid(1_000_000, 100_000_000) == 10_000_000
assert geometric_mid(1_000_000, 100_000_000) != (1_000_000 + 100_000_000) // 2
def test_geometric_mid_stays_strictly_inside_the_corridor() -> None:
"""При hi-lo>=2 середина строго внутри — обе половины непусты."""
for lo, hi in ((1_000_000, 1_000_002), (1_000_000, 100_000_000), (None, 700_000)):
mid = geometric_mid(lo, hi)
assert mid < hi
if lo is not None:
assert mid > lo
def test_atomic_corridor_is_not_split_and_is_marked_truncated() -> None:
"""Коридор шириной в 1 ₽ делить нечем: предохранитель, а не рекурсия по себе.
`geometric_mid` при hi-lo<=1 строгой внутренней точки дать не может, и
половина совпала бы с родителем. Планировщик обязан остановиться сам, не
полагаясь на MAX_DEPTH как на единственный тормоз поэтому
min_width_ratio здесь занижен до 1.0, чтобы снять штатный предохранитель.
"""
plan = plan_price_corridors(
count_of=_counter([5_000_000] * 3_000),
target=100,
hard_cap=100,
lo=5_000_000,
hi=5_000_001,
min_width_ratio=1.0,
)
assert plan.stats.corridors == 1
only = plan.corridors[0]
assert (only.lo, only.hi) == (5_000_000, 5_000_001)
assert only.truncated is True
assert only.missed == 3_000 - 100
assert plan.stats.max_depth_reached == 0
def test_width_ratio_open_top_is_infinite() -> None:
assert width_ratio(1_000_000, None) == float("inf")
assert width_ratio(1_000_000, 2_000_000) == pytest.approx(2.0)

View file

@ -0,0 +1,249 @@
"""Гео-согласованный коэффициент asking→sold вне ЕКБ (#3529).
Прод-замер (регион 50): пул даёт 0.891 при 0.73 у Москвы и 0.73 у ЕКБ, потому что обе
стороны фильтровались только по region_code и соединялись только по бакету комнат
объявления смещены к дальней дешёвой периферии сильнее, чем сделки. Разложение по
кольцам 10 км показывает 0.808 / 0.847 / 0.951 / 0.688 ВНУТРИ колец, причём 76% сделок
лежат в ближних кольцах с 0.81-0.85.
Тесты: (1) арифметика перекоса состава и то, что взвешивание по географии сделок его
снимает; (2) SQL реально соединяет стороны по гео-ключу; (3) путь ЕКБ не тронут;
(4) регион с непересекающейся географией деградирует (строк нет), а не пишет мусор.
"""
import os
import re
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import asking_to_sold_ratio as ratio_mod
_REGION_SQL = str(ratio_mod._REDERIVE_SQL_REGION.text)
_STATS_SQL = str(ratio_mod._GEO_STATS_SQL_REGION.text)
_EKB_SQL = str(ratio_mod._REDERIVE_SQL.text)
# ── 1. Синтетика: пул завышает, согласованная география — нет ─────────────────
# Ячейки из прод-разложения области 50 по кольцам 10 км (сделки 12 мес vs активные
# объявления): (n_deals, sold_median, n_listings, ask_median).
_PROD_RINGS = [
(12_189, 175_000, 23_292, 216_666),
(4_132, 147_026, 10_206, 173_684),
(1_432, 150_916, 5_243, 158_675),
(150, 84_390, 2_294, 122_727),
]
def _pooled_ratio(cells: list[tuple[int, int, int, int]]) -> float:
"""СТАРАЯ схема: каждая сторона усредняется по СВОЕЙ популяции, общего ключа нет.
Ровно то, что делал `FROM deal_side d JOIN ask_side a USING (rooms_bucket)`
sold взвешен сделками, ask взвешен объявлениями, география не совпадает.
"""
sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(n_d for n_d, _, _, _ in cells)
ask = sum(n_l * a for _, _, n_l, a in cells) / sum(n_l for _, _, n_l, _ in cells)
return sold / ask
def _geo_matched_ratio(cells: list[tuple[int, int, int, int]]) -> float:
"""НОВАЯ схема: обе стороны взвешены ОДНИМИ весами — числом сделок ячейки."""
w = [n_d for n_d, _, _, _ in cells]
sold = sum(n_d * s for n_d, s, _, _ in cells) / sum(w)
ask = sum(n_d * a for n_d, _, _, a in cells) / sum(w)
return sold / ask
def test_pool_inflates_and_geo_matching_fixes_it() -> None:
"""Пул завышает коэффициент; та же выборка на согласованной географии — нет.
Числа взяты из прод-разложения (см. _PROD_RINGS): внутри ячеек 0.69-0.95, а общий
пул около 0.89. Перевзвешивание ask-стороны на географию сделок возвращает
результат в диапазон ближних колец, где лежит 76% сделок.
"""
pooled = _pooled_ratio(_PROD_RINGS)
matched = _geo_matched_ratio(_PROD_RINGS)
per_cell = [s / a for _, s, _, a in _PROD_RINGS]
# Пул — завышен. NB: 0.858 здесь — НИЖНЯЯ оценка перекоса: пул восстановлен из
# ячеечных медиан взвешенными средними, тогда как реальный запрос брал МЕДИАНУ
# пулированной популяции, которая уезжает в дальнюю (объявлений там втрое больше
# сделок) периферию ещё глубже — прод показывал 0.891 при 0.73 у Москвы и ЕКБ.
assert pooled > 0.85
# Согласованная география — внутри диапазона ячеечных отношений, ближе к ближним
# кольцам (0.81-0.85), где сосредоточена масса сделок.
assert min(per_cell) <= matched <= max(per_cell)
assert 0.80 < matched < 0.86
# И это не косметика: даже по нижней оценке расхождение больше 3 процентных пунктов
# выкупной цены, по прод-замеру — около 7.
assert pooled - matched > 0.03
def test_geo_matched_ratio_is_weighted_by_deals_not_listings() -> None:
"""Вес ячейки — число СДЕЛОК: дальняя периферия с горой объявлений не перетягивает.
Утраиваем объявления в самой дальней (дешёвой) ячейке согласованный коэффициент
не двигается вообще, пул уезжает.
"""
skewed = [
(n_d, s, n_l * 3 if i == len(_PROD_RINGS) - 1 else n_l, a)
for i, (n_d, s, n_l, a) in enumerate(_PROD_RINGS)
]
assert _geo_matched_ratio(skewed) == _geo_matched_ratio(_PROD_RINGS)
assert _pooled_ratio(skewed) != _pooled_ratio(_PROD_RINGS)
def test_sql_implements_the_same_weighted_formula() -> None:
"""Формула из теста выше — ровно то, что считает SQL (иначе двойник разъедется).
ratio = Σ(w·sold) / Σ(w·ask), w = n_deals; оба медианных столбца взвешенные
средние с ТЕМИ ЖЕ весами, поэтому ratio == sold_median/ask_median построчно.
"""
flat = re.sub(r"\s+", " ", _REGION_SQL)
ratio_expr = "CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals) AS numeric)"
assert ratio_expr + " AS ratio" in flat
assert "round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median" in flat
assert "round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median" in flat
# Веса — сделки, не объявления.
assert "SUM(sold_median * n_listings)" not in flat
assert "SUM(ask_median * n_listings)" not in flat
# ── 2. Гео-ключ есть И в фильтре, И в соединении ──────────────────────────────
def test_region_sql_joins_both_sides_on_the_same_cells() -> None:
"""Старое соединение было ТОЛЬКО по бакету комнат — гео-ключа в нём не было вовсе."""
flat = re.sub(r"\s+", " ", _REGION_SQL)
# Ячейка строится ОДИНАКОВО с обеих сторон (одни и те же градусные шаги).
assert flat.count(f"FLOOR(ST_Y(geom) / {ratio_mod._CELL_LAT_DEG}) AS cell_lat") == 2
assert flat.count(f"FLOOR(ST_X(geom) / {ratio_mod._CELL_LON_DEG}) AS cell_lon") == 2
# Соединение сторон несёт гео-ключ.
assert "JOIN ask_cell a USING (cell_lat, cell_lon)" in flat
assert "JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)" in flat
# Прежних «слепых» соединений не осталось.
assert "JOIN ask_side a USING (rooms_bucket)" not in flat
assert "CROSS JOIN ask_global" not in flat
# Порог ячейки — на ОБЕИХ сторонах.
assert f"d.n_deals >= {ratio_mod._CELL_MIN_DEALS}" in flat
assert f"a.n_listings >= {ratio_mod._CELL_MIN_LISTINGS}" in flat
# Публикуемый барьер 30/30 сохранён (теперь на сумме по удержанным ячейкам).
assert "HAVING SUM(n_deals) >= 30 AND SUM(n_listings) >= 30" in flat
def test_region_sql_stays_psycopg_v3_safe_and_region_scoped() -> None:
for sql in (_REGION_SQL, _STATS_SQL):
assert re.search(r":[a-z_]+::[a-z]", sql) is None
assert "city ILIKE" not in sql
# Деривация собирает каждую сторону по разу; stats-запрос дополнительно считает
# ВСЕ строки региона (включая без geom) — отсюда четыре предиката вместо двух.
assert _REGION_SQL.count("region_code = CAST(:region_code AS int)") == 2
assert _STATS_SQL.count("region_code = CAST(:region_code AS int)") == 4
assert "CAST(:region_code AS int) FROM global_row" in _REGION_SQL
assert "CAST(:region_code AS int) FROM per_bucket" in _REGION_SQL
def test_district_column_stays_empty() -> None:
"""Идентификатор ячейки в district НЕ пишется — ячейка промежуточная, не публикуемая.
Потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''`; строка
на выходе одна на (регион, бакет) агрегат по всем ячейкам.
"""
assert _REGION_SQL.count("''::text") == 2
assert "cell_lat AS district" not in _REGION_SQL
assert "AS district" in _REGION_SQL
# ── 3. Путь ЕКБ (66) не тронут ────────────────────────────────────────────────
def test_ekb_path_untouched_by_geo_matching() -> None:
"""Регион 66 обязан остаться байт-в-байт прежним — там своя калибровка (#C2/#2583)."""
assert "cell_lat" not in _EKB_SQL
assert "ST_X(" not in _EKB_SQL and "ST_Y(" not in _EKB_SQL
assert _EKB_SQL.count("AND city ILIKE :asking_city") == 2
assert _EKB_SQL.count("AND (city IS NULL OR city ILIKE :asking_city)") == 2
assert "FROM deal_side d\n JOIN ask_side a USING (rooms_bucket)" in _EKB_SQL
assert "CROSS JOIN ask_global a" in _EKB_SQL
assert "basis, 66 FROM global_row" in _EKB_SQL
# Гео-статистика считается только для прочих регионов — 66 её не касается.
assert "_GEO_STATS_SQL_REGION" in ratio_mod.__dict__ or True
assert 66 not in ratio_mod._OTHER_REGION_CODES
# ── 4. Гард: непересекающаяся география → деградация, а не мусор ──────────────
def _stats(**over: int) -> dict[str, int]:
base = {
"deals_total": 1000,
"deals_geo": 900,
"listings_total": 2000,
"listings_geo": 2000,
"cells_deal": 20,
"cells_ask": 25,
"cells_both_sides": 18,
"cells_matched": 15,
"deals_in_cells": 800,
"listings_in_cells": 1200,
}
base.update(over)
return base
def test_verdict_ok_on_healthy_region() -> None:
ok, reason = ratio_mod.geo_region_verdict(50, _stats())
assert ok is True
assert reason == "ok"
def test_verdict_degrades_on_disjoint_geography() -> None:
"""Сделки и объявления в разных местах — регион не получает НИ ОДНОЙ строки."""
ok, reason = ratio_mod.geo_region_verdict(50, _stats(cells_matched=1, deals_in_cells=30))
assert ok is False
assert "не пересекаются" in reason
# Пересечение есть, но в него попала меньшая часть сделок — тоже деградация.
ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_in_cells=100))
assert ok is False
assert "11% сделок" in reason
def test_verdict_degrades_when_a_side_has_no_coordinates() -> None:
ok, reason = ratio_mod.geo_region_verdict(50, _stats(deals_geo=100))
assert ok is False
assert "сделки без geom" in reason
ok, reason = ratio_mod.geo_region_verdict(50, _stats(listings_geo=10))
assert ok is False
assert "объявления без geom" in reason
ok, reason = ratio_mod.geo_region_verdict(50, None)
assert ok is False
def test_verdict_degrades_on_empty_region() -> None:
ok, _ = ratio_mod.geo_region_verdict(50, _stats(deals_total=0, deals_geo=0))
assert ok is False
def test_counters_expose_composition_and_missing_geom() -> None:
"""Пункты 3 и 5: строки без geom и масса вне пересечения — в счётчиках, не в тишине."""
c = ratio_mod._geo_region_counters(50, _stats())
assert c["geo_r50_cells_matched"] == 15
assert c["geo_r50_cells_dropped"] == 3 # есть обе стороны, но одна тоньше порога
assert c["geo_r50_deals_no_geom_pct"] == 10 # 100 из 1000 сделок без координат
assert c["geo_r50_listings_no_geom_pct"] == 0
assert c["geo_r50_deals_outside_pct"] == 11 # 100 из 900 геокодированных сделок
assert c["geo_r50_listings_outside_pct"] == 40 # дальняя периферия отброшена намеренно
assert all(isinstance(v, int) for v in c.values())
def test_skipped_region_still_deletes_stale_rows() -> None:
"""DELETE идёт ДО решения писать/не писать — старый пуловый коэффициент не остаётся."""
import inspect
body = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)
del_pos = body.index('db.execute(_DELETE_SQL_REGION, {"region_code": region_code})')
guard_pos = body.index("if not ok:")
insert_pos = body.index("db.execute(_REDERIVE_SQL_REGION, params)")
assert del_pos < guard_pos < insert_pos
assert "continue" in body[guard_pos:insert_pos]

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:
"""Имя ряда у загрузчика и у эстиматора обязано совпадать буква в букву.
Одновременно проверяем, что заведение ряда НЕ включает область в оценку и НЕ
поднимает тревогу монитора свежести: обе величины считаются от карты
эстиматора (_SBER_REGION_SERIES), а не от карты загрузчика.
Загрузчик завёл REF_AREA=50 раньше (#3498), когда карта эстиматора кода 50
ещё не знала и область считалась по «России». 12.09.2026 ряд приехал на прод
(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
assert SBER_REF_AREAS["50"] == "Московская область"
assert "Московская область" not in SBER_REQUIRED_REGIONS
assert "Московская область" not in SBER_MONITORED_REGIONS
assert sber_region_series_name(50) == SBER_REF_AREAS["50"]
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
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:
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
est.SBER_TIME_ADJUST_FALLBACK_REGION
@ -275,8 +290,15 @@ def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatc
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(
@ -352,7 +374,7 @@ def test_missing_fallback_series_does_not_fail_the_monitor(
) -> None:
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
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]
assert calls["failed"] is None
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:
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского,
а найденный ряд не попадал в age_days_max вовсе.
Пропажа ВСЕХ обязательных рядов была неотличима от пропажи одного
свердловского, а найденный ряд не попадал в age_days_max вовсе. Счётчик
сверяем с длиной карты, а не с литералом: новый регион в ней не повод
этому тесту краснеть.
"""
calls = _patch_runs(monkeypatch)
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]
assert calls["done"] is 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["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
# прежняя семантика раннего выхода не тронута
@ -492,9 +516,7 @@ class _MonDBDriverFault(_MonDB):
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
if self._deactivated:
raise PendingRollbackError(
"Can't reconnect until invalid transaction is rolled back"
)
raise PendingRollbackError("Can't reconnect until invalid transaction is rolled back")
params = params or {}
if params.get("city") == self._broken:
self._deactivated = True

View file

@ -25,10 +25,12 @@ import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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.pipeline import CITY_ANCHORS
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, DEFAULT_REGION_CODE
from scraper_kit.orchestration.scheduler import (
Handler,
SchedulerContext,
@ -38,7 +40,10 @@ from scraper_kit.orchestration.scheduler import (
_job_avito_full_load,
_job_avito_full_load_exhaustive,
_job_cian_city_sweep,
_job_cian_full_load,
_job_domclick_city_sweep,
_job_yandex_city_sweep,
_resolve_region_code,
build_registry,
reap_zombies,
reschedule_after_minutes,
@ -545,17 +550,20 @@ async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> N
assert kwargs["anchors"] is None
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
async def test_job_avito_city_sweep_unknown_city_raises_instead_of_sweeping() -> None:
"""Неизвестный city slug (опечатка в расписании) обязан упасть, а не собрать ЕКБ.
НЕ падает с KeyError.
Раньше `get_city_anchors` отдавал None, и свип уходил на EKB_ANCHORS: опечатка в
`default_params.city` тихо давала прогон по Екатеринбургу, проштампованный чужим
городом. Теперь реестр падает с ValueError; прогон крашится, а тик планировщика
это ловит (`logger.exception` в `_run`, scheduler.py) цикл не умирает, но и
мусора в корпус не попадает.
"""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
with pytest.raises(ValueError, match="nonexistent_town"):
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
mock_run.assert_not_awaited()
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────
@ -623,3 +631,101 @@ def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
assert resolve_handler("avito_city_sweep", registry) is 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

@ -11,27 +11,45 @@
- `tradein-mvp/backend/app/services/gar_flats_loader.py` — стриминговый парсер + upsert + матчер.
- `tradein-mvp/backend/app/tasks/gar_flats_load.py` — CLI (`python -m app.tasks.gar_flats_load`).
## 1. Получить ГАР-дамп региона 66 (ops-шаг, вручную)
**Текущее состояние (замер 13.09.2026, до загрузки 77/50):** `gar_house_flats` наполнена
ТОЛЬКО по региону 66 (919 341 строка). По домам: 66 — 10 159 домов, `gar_house_guid` у 7 779,
`flat_count` у 4 821 (47%); 77 — 17 453 дома, `gar_house_guid` у 0 (ГАР ещё не загружен),
`flat_count` у 388 (2,2%, вероятно другой источник знаменателя); 50 — 8 964 дома,
`gar_house_guid` у 0, `flat_count` у 32. Команды загрузки 77/50 — § 2.
## 1. Получить ГАР-дамп региона (ops-шаг, вручную)
Лоадер потребляет **уже распакованные** XML локально — многогигабайтную загрузку/распаковку
делаем отдельно (это не часть лоадера).
делаем отдельно (это не часть лоадера). Поддерживаемые сейчас регионы продукта: **66**
(Свердловская обл.), **77** (Москва), **50** (Московская обл.) — см. `app.services.regions.REGIONS`.
1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/
(раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, `gar_xml.zip`.
- Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужен **только регион 66** (Свердловская обл.).
- Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только папку `66/`**.
Пример (извлечь одну папку без распаковки всего архива):
- **URL меняется с каждым обновлением ФИАС (примерно еженедельно) — НЕ хардкодить.**
Актуальный прямой URL отдаёт `GET https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo`
в поле `GarXMLFullURL` (JSON). На дату написания раздела (проверено 13.09.2026):
`https://fias-file.nalog.ru/downloads/2026.09.11/gar_xml.zip`, версия дампа ФИАС
**2026-09-11**, размер **53,6 ГБ** — используй это значение только как пример формата
URL, перед реальным запуском запроси эндпоинт заново.
- Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужны только папки нужных регионов.
- Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только нужную
папку(и)** (можно за один проход, если качаем сразу под несколько регионов):
```bash
unzip gar_xml.zip '66/*' -d /data/gar
# → /data/gar/66/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
unzip gar_xml.zip '66/*' -d /data/gar # Свердловская обл.
unzip gar_xml.zip '77/*' -d /data/gar # Москва
unzip gar_xml.zip '50/*' -d /data/gar # Московская обл.
# → /data/gar/<regionN>/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
# AS_MUN_HIERARCHY_*.XML, ...
```
- Размер распакованной папки `66/`: ориентир — несколько ГБ (крупнейшие файлы —
`AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Поэтому парсер
стримит (`lxml.etree.iterparse` + очистка элементов), а не грузит дерево целиком.
- Размер распакованной папки региона: ориентир для 66 — несколько ГБ (крупнейшие файлы —
`AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Регион 77 (Москва)
и особенно 50 (область, ~10 121 текстовое имя города против 612 у Москвы — #2996) —
папки СУЩЕСТВЕННО крупнее 66 (на порядок больше домов/помещений), закладывай запас по
диску и времени парса. Парсер стримит (`lxml.etree.iterparse` + очистка элементов), а не
грузит дерево целиком — память не зависит от размера файла.
Альтернатива: ГАР обновляется примерно еженедельно дельтами; нам достаточно полного
среза. Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок.
ГАР обновляется примерно еженедельно дельтами; нам достаточно полного среза на регион.
Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок.
Нужные файлы в папке региона (остальные лоадер игнорирует):
- `AS_ADDR_OBJ_*.XML` — адресные объекты (регион/город/улица).
@ -48,6 +66,14 @@
# в контейнере tradein-backend (DATABASE_URL уже выставлен)
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
# регион 77 (Москва) — без city-фильтра по умолчанию (город один, фильтровать нечем)
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
# регион 50 (Московская обл.) — без city-фильтра по умолчанию (нет доминирующего города)
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
```
Локально:
@ -62,15 +88,22 @@ DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
- `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`.
- `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`).
- `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше).
- `--city` (умолч. `Екатеринбург`) — город-фильтр матча (`ILIKE` по `full_address`); пусто = без ограничения.
- `--city` — город-фильтр матча (`ILIKE` по `full_address`). **По умолчанию берётся ПО
РЕГИОНУ**, не константой (см. `app.services.gar_flats_loader.default_city_filter_for_region`):
регион **66**`Екатеринбург` (byte-for-byte прежнее поведение — много сопоставимых по
названиям городов внутри области, см. `REGIONS[66].cities`); регионы **77/50** → без
фильтра (77 — город один, фильтровать нечем; 50 — нет доминирующего города, фильтр по
одному городу отрезал бы почти весь регион). `--city ''` явно отключает фильтр для
ЛЮБОГО региона (в т.ч. 66); `--city "Имя"` — явный override.
Что делает (две фазы, обе коммитятся):
1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес →
UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address`
считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер».
2. **match**`UPDATE houses SET gar_flat_count = …` по КАНОНИЧЕСКОМУ ключу адреса
`tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), ЕКБ-restricted
(`full_address ILIKE '%Екатеринбург%'`), идемпотентно (`IS DISTINCT FROM` gate).
`tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), с city-фильтром по
умолчанию для 66 (`full_address ILIKE '%Екатеринбург%'`), без фильтра для 77/50 (ambiguity
одноимённых улиц закрыта иначе, см. § 4), идемпотентно (`IS DISTINCT FROM` gate).
В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`.
@ -84,12 +117,16 @@ DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
# в контейнере tradein-backend — только ре-матч, --dir НЕ требуется
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 66
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 77
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 50
```
Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` (умолч.
`Екатеринбург`) задаёт город-фильтр ГАР-стороны; пустой `--city ''` отключает фильтр
(матч за пределами ЕКБ, см. § 4).
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` без явного значения
резолвится ПО РЕГИОНУ (см. § 2 выше: 66 → `Екатеринбург`, 77/50 → без фильтра); пустой
`--city ''` отключает фильтр для любого региона.
## 3. Проверка результата
@ -127,14 +164,24 @@ LIMIT 20;
- **Литера/корпус — намеренно НЕ схлопываются.** `6Б``6`, `5к1``5` — литера и корпус
остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания.
Это сознательное поведение, не баг матча.
- **ЕКБ-ограничение (`city_filter='Екатеринбург'` по умолчанию).** Матч фильтрует ГАР-сторону
по `full_address ILIKE '%Екатеринбург%'` — без этого канон ашиностроителей6` совпал бы с
- **City-фильтр по умолчанию — ПО РЕГИОНУ, не хардкод.** Для 66 матч фильтрует ГАР-сторону по
`full_address ILIKE '%Екатеринбург%'` — без этого канон ашиностроителей6` совпал бы с
«Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные
cross-town коллизии. **City-aware матч за пределами ЕКБ** (по нескольким городам без ложных
коллизий) — **future work**; временно отключить фильтр можно `--city ''` (на свой риск).
- **Tie-break.** Если несколько ГАР-строк дают один канон, матчер берёт строку с
**максимальным `flat_count`** (при равенстве — лексикографически меньший `house_guid`),
через `DISTINCT ON (canon) ORDER BY canon, flat_count DESC, house_guid`.
cross-town коллизии. Для 77 (Москва) фильтр не нужен — город один. Для 50 (область) фильтр
по одному городу был бы вреден (отрезал бы почти весь регион) — ambiguity одноимённых улиц
РАЗНЫХ городов там закрыта не фильтром, а следующим пунктом.
- **Ambiguity-guard без city-фильтра (canon_hits).** Когда `city_filter IS NULL` (77, 50 и
любой будущий регион без one-city ограничения), `tradein_canon_addr` НЕ несёт населённый
пункт (режет всё, кроме улицы и номера — мигр. 144) — «Ленина 5» существует в десятках
городов области. Матчер СЧИТАЕТ, сколько РАЗНЫХ ГАР-домов дают один canon в
отфильтрованной (по региону) выборке (`COUNT(*) OVER (PARTITION BY canon)`); если больше
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
закрыта сужением по городу, поведение byte-for-byte прежнее.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
BY flat_count DESC, house_guid)`.
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.

View file

@ -40,7 +40,10 @@ ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
# соответствующий script/meta отсутствует в разметке) — так решает
# frontend-код, потребляющий эти env (Dockerfile сам их не читает).
#
# СЧЁТЧИКОВ ПОКА НЕТ — владелец заведёт их позже. Когда появятся номера:
# ЗАВЕДЕНЫ (15.09.2026, аккаунт info@meraocenka.ru): NEXT_PUBLIC_YM_ID —
# счётчик Метрики, NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав
# в Вебмастере. Пустыми остаются GA_ID и GOOGLE_VERIFICATION.
# Когда появятся остальные номера — порядок тот же:
# 1) вписать литералом в build-args джоба build-frontend
# (.forgejo/workflows/deploy-tradein.yml) — В ОБОИХ блоках, основном
# И retry-без-кеша, иначе ретрай молча уедет без счётчика;

View file

@ -330,6 +330,8 @@ describe("гейт витринных чисел (#2904)", () => {
"estimate/page.tsx",
"articles/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",
"business/page.tsx",
];

View file

@ -97,6 +97,41 @@ describe("витрина лэндинга v3 без данных", () => {
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 вне полосы,
* худшая +75,71 %. Утверждение «показана полоса 5+20 %» и строка +75,7 %
* под ним хуже, чем отсутствие утверждения.

View file

@ -86,6 +86,10 @@ export function AnalyticsScripts() {
(1) отдельная фраза про запись действий в п. 9.1
`privacy/page.tsx`, (2) маскировка полей ввода адреса и
телефона через `ym-hide-content` / `data-ym-disable-keys`. */}
{/* `ssr:true` страницы публичного контура рендерятся на сервере,
и без этого флага Метрика считает первый хит по состоянию DOM на
момент гидратации, а не по отданной сервером разметке. Флаг
выставлен и в самом счётчике на стороне Метрики. */}
<Script id="ym-init" strategy="afterInteractive">
{`
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
@ -95,6 +99,7 @@ export function AnalyticsScripts() {
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(${Number(YM_ID)}, "init", {
ssr:true,
clickmap:true,
trackLinks:true,
accurateTrackBounce:true,

View file

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

View file

@ -45,7 +45,7 @@ export interface ArticleCardMeta {
* второй статьи не может увести её на первую (так и было, пока
* `to="articleHowTo"` стоял константой в JSX).
*/
readonly route: "articleHowTo" | null;
readonly route: "articleHowTo" | "articlePrices" | "articleWaiting" | null;
readonly rubric: string;
readonly title: string;
/** Подводка карточки — дословно из макета. */
@ -61,16 +61,16 @@ export const ARTICLE_CARDS: readonly ArticleCardMeta[] = [
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
},
{
route: null,
rubric: "СРОКИ · 5 МИН",
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
lead: "Средние сроки по типам квартир и главные причины простоя.",
route: "articlePrices",
rubric: "ЦЕНЫ СДЕЛОК · 5 МИН",
title: "Сколько стоит метр в Екатеринбурге: цены сделок, а не объявлений",
lead: "Медиана по 24 535 сделкам Росреестра и почему метр в двушке самый дешёвый.",
},
{
route: null,
rubric: "ТОРГ · 7 МИН",
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
lead: "Считаем на данных, сколько теряет «висящая» квартира каждый месяц.",
route: "articleWaiting",
rubric: "ЦЕНА И ОЖИДАНИЕ · 5 МИН",
title: "Почему квартира не продаётся: что происходит с ценой, пока вы ждёте",
lead: "Кто снижает цену, на сколько и за какой срок — по 49 620 объявлениям.",
},
];
@ -181,3 +181,262 @@ export const HOWTO = {
summary:
"Смотрите на цены сделок, а не объявлений. Берите 514 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
} 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>
</div>
<div className={styles.artGrid}>
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map(
(c) => (
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").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>

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: раздел статей и хаб документов.
articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru",
articlePrices: "/articles/skolko-stoit-metr-v-ekaterinburge",
articleWaiting: "/articles/pochemu-kvartira-ne-prodaetsya",
docs: "/docs",
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
// взамен ссылки на `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 {
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
@ -52,6 +52,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly",
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),
lastModified: LAST_MODIFIED,

View file

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

View file

@ -72,7 +72,11 @@ from scraper_kit.providers.cian.newbuilding import (
save_newbuilding_enrichment,
)
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
from scraper_kit.providers.domclick.serp import (
ROOM_BUCKETS,
DomClickScraper,
geo_profile_for_region,
)
from scraper_kit.providers.yandex.serp import (
DEFAULT_PRICE_RANGES,
ROOM_PATH,
@ -278,6 +282,146 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8650, 60.6200, "Пионерский"),
]
# ── Москва (77) и Московская область (50): якоря развёрток ──────────────────
#
# Продуктовое ядро Москвы — то же окно, что у REGIONS[77].bbox_product_core в
# backend/app/services/regions.py: lat 55.55..55.95, lon 37.30..37.90. Это ~44.5 км
# с юга на север и ~37.6 км с запада на восток (на широте 55.75° градус долготы =
# 62.6 км). Один центральный anchor, как у компактных городов области, покрыл бы
# отсюда круг радиусом 8 км — меньше пятой части ядра. Поэтому сетка.
MOSCOW_CORE_BBOX_DEG: tuple[float, float, float, float] = (55.55, 55.95, 37.30, 37.90)
# Радиус ОДНОГО anchor-запроса московских развёрток. 8 км — не оценка на глаз:
# столько намерено на прод-корпусе (35 552 активных вторичных лота region_code=77)
# у московской сетки центроидов, см. большой комментарий над `_MOSCOW_GRID_DEG` в
# backend/app/api/v1/trade_in.py (R=5..8 дают одинаковые 0.00% вне радиуса, при
# R=10 внутрь входит уже Наро-Фоминск). Кит не имеет права импортировать app.*
# (гейтится тестом), поэтому здесь осознанный дубль, а не второе независимое число.
#
# ВАЖНО: `radius_m` у run_avito_city_sweep/run_cian_city_sweep по умолчанию 1500 —
# строка расписания московской развёртки обязана передать 8000 явно, иначе круги
# соседних узлов не сомкнутся и между ними останутся непокрытые полосы.
MOSCOW_ANCHOR_RADIUS_M = 8000
# Шаг сетки согласован именно с этим радиусом: любая точка ядра должна лежать не
# дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего узла. Худшая точка — центр ячейки,
# расстояние до узла = hypot(шаг_lat/2, шаг_lon/2). При 0.10° по широте (11.13 км)
# и 0.15° по долготе (9.39 км) это hypot(5.56, 4.69) = 7.27 км < 8 км — с запасом
# 0.7 км. Узлы стоят по границам окна включительно (55.55/55.65/…/55.95 ×
# 37.30/37.45/…/37.90): 5 × 5 = 25 якорей, все внутри ядра, ни один не свисает
# наружу. Крупнее (0.15° × 0.20°) дало бы полудиагональ 10.5 км — дыры в покрытии;
# мельче — лишние прогоны страниц без нового покрытия.
_MOSCOW_GRID_LAT_STEP_DEG = 0.10
_MOSCOW_GRID_LON_STEP_DEG = 0.15
# Центр ядра сам является узлом сетки, и он же ПЕРВЫЙ в списке: get_city_anchor_point
# берёт anchors[0] опорной точкой гео-гарда штампа (см. её docstring), а из угла ядра
# гард мерил бы диагональ в 58 км и перестал бы что-либо отсекать.
_MOSCOW_CENTER_DEG: tuple[float, float] = (55.75, 37.60)
def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]:
"""Сетка якорей ядра Москвы: центр первым, остальные узлы — с юго-запада на север."""
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
lat_steps = round((lat_max - lat_min) / _MOSCOW_GRID_LAT_STEP_DEG)
lon_steps = round((lon_max - lon_min) / _MOSCOW_GRID_LON_STEP_DEG)
grid: list[tuple[float, float, str]] = []
for i in range(lat_steps + 1):
lat = round(lat_min + i * _MOSCOW_GRID_LAT_STEP_DEG, 4)
for j in range(lon_steps + 1):
lon = round(lon_min + j * _MOSCOW_GRID_LON_STEP_DEG, 4)
grid.append((lat, lon, f"Москва {lat:.2f}/{lon:.2f}"))
# sort стабилен: центр уезжает в начало, порядок остальных не меняется.
grid.sort(key=lambda a: (a[0], a[1]) != _MOSCOW_CENTER_DEG)
return grid
MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid()
# Московская область. Двухслойная сетка якорей; оба слоя измерены, ни одна координата
# не выдумана.
#
# Слой 1 — города-спутники (22). Координаты первых 12 продублированы из
# `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py (тот же запрет
# на импорт app.* — править нужно оба места); остальные 10 (Домодедово … Наро-Фоминск)
# взяты прямым запросом к Nominatim /search (city=<НП>, state=Московская область)
# 15.09.2026, все вернулись как place=city/town.
#
# Слой 2 — кластеры лот-массы (22). Это НЕ центры НП, а центроиды активных объявлений
# области в ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший
# НП по обратному геокодированию (zoom=14), она справочная. Слой нужен потому, что одних
# городов мало: замер на проде 15.09.2026 по 70 996 активным объявлениям region_code=50
# показал, что 22 городских якоря радиусом 10 км накрывают лишь 73.8% лот-массы, а вместе
# со вторым слоем — 95.2%.
#
# Порядок в обоих слоях — по УБЫВАНИЮ измеренной лот-массы (в скобках у каждой строки:
# слой 1 — активных лотов в 10 км от якоря, слой 2 — прирост покрытия, который этот якорь
# дал жадному алгоритму). Это и есть замер, которого раньше не было.
#
# ВАЖНО: покрытие считано при радиусе MOSCOW_OBLAST_ANCHOR_RADIUS_M. Расписание, которое
# передаст меньший radius_m, получит дыры — 95.2% относятся именно к 10 км.
#
# Оговорка о смещении: лот-масса взята из УЖЕ собранного корпуса (разовая загрузка
# 10-12.09.2026). Там, где разовый сбор не добрал, масса занижена, поэтому 95.2% —
# оценка сверху по тому, что видно, а не доказательство полноты по генеральной
# совокупности. Первый регулярный прогон обязан пересчитать покрытие по своему улову.
MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000
MOSCOW_OBLAST_CITY_ANCHORS: list[tuple[float, float, str]] = [
(55.6767, 37.8931, "Люберцы"), # 7429
(55.7614, 37.8564, "Реутов"), # 6824
(55.7444, 38.0128, "Железнодорожный"), # 6465
(55.7969, 37.9386, "Балашиха"), # 6429
(55.8317, 37.3300, "Красногорск"), # 5556
(55.9142, 37.8256, "Королёв"), # 5208
(55.9116, 37.7308, "Мытищи"), # 4805
(55.8894, 37.4450, "Химки"), # 4701
(55.5519, 37.7133, "Видное"), # 4574
(55.6789, 37.2639, "Одинцово"), # 4077
(55.9206, 37.9916, "Щёлково"), # 4031
(55.9386, 37.5100, "Долгопрудный"), # 3506
(55.8554, 38.4412, "Ногинск"), # 2820
(55.7845, 38.4449, "Электросталь"), # 2462
(55.4312, 37.5450, "Подольск"), # 2453
(55.4368, 37.7681, "Домодедово"), # 2059
(56.3153, 38.1360, "Сергиев Посад"), # 1889
(55.5709, 38.2282, "Раменское"), # 1344
(55.0939, 38.7670, "Коломна"), # 1118
(54.9155, 37.4196, "Серпухов"), # 1059
(55.8062, 38.9810, "Орехово-Зуево"), # 824
(55.3843, 36.7235, "Наро-Фоминск"), # 802
]
MOSCOW_OBLAST_CLUSTER_ANCHORS: list[tuple[float, float, str]] = [
(55.9395, 37.1805, "Рузино"), # +1528
(56.0837, 37.9229, "Лесной"), # +1253
(55.2779, 38.7415, "Воскресенск"), # +1002
(55.5588, 37.0775, "Апрелевка"), # +974
(55.7860, 36.9874, "Писково"), # +928
(56.2883, 37.4825, "Яхрома"), # +887
(56.3704, 36.6872, "Клин"), # +865
(55.6113, 37.9765, "Октябрьский"), # +825
(56.1460, 37.0592, "Солнечногорск"), # +616
(55.8732, 38.1862, "Осеево"), # +597
(55.3705, 39.0622, "Егорьевск"), # +587
(54.8393, 38.1760, "Кашира"), # +585
(55.7768, 38.7096, "Павловский Посад"), # +494
(55.1608, 37.4732, "Чехов"), # +493
(56.0920, 37.4972, "Некрасовский"), # +440
(55.5773, 39.5299, "Шатура"), # +397
(56.7358, 37.1643, "Дубна"), # +392
(55.9134, 36.8293, "Истра"), # +389
(56.4933, 38.1792, "Богородское"), # +312
(54.9619, 39.0273, "Луховицы"), # +228
(55.7715, 37.2308, "Ильинское-Усово"), # +224
(56.2552, 37.9754, "Хотьково"), # +207
]
MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [
*MOSCOW_OBLAST_CITY_ANCHORS,
*MOSCOW_OBLAST_CLUSTER_ANCHORS,
]
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
@ -342,6 +486,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
"talitsa": [(57.0148, 63.7302, "Талица центр")],
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
# ── Москва (77) и Московская область (50) ────────────────────────────────
# Три записи вместо одной, чтобы строка расписания могла выбрать скоуп:
# только Москва, только область, либо обе сразу (у cian/yandex под это есть
# отдельные идентификаторы, у avito один слаг на всё — см. CITY_LOCATIONS).
"moskva": MOSCOW_ANCHORS,
"moskovskaya_oblast": MOSCOW_OBLAST_ANCHORS,
"moskva_i_mo": [*MOSCOW_ANCHORS, *MOSCOW_OBLAST_ANCHORS],
}
@ -355,7 +506,14 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
"""
if city_slug is None:
return None
return CITY_ANCHORS.get(city_slug)
try:
return CITY_ANCHORS[city_slug]
except KeyError:
raise ValueError(
f"get_city_anchors: неизвестный city_slug={city_slug!r}. Раньше здесь был "
"тихий откат на ЕКБ (None → EKB_ANCHORS у вызывающей стороны) — развёртка "
"чужого города молча собирала Екатеринбург. Заведи слаг в CITY_ANCHORS."
) from None
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
@ -383,6 +541,17 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
return (lat, lon)
# Хост Циана — РЕГИОНАЛЬНЫЙ поддомен, а не городской: ekb.cian.ru общий для всей
# Свердловской области (все 45 городов ниже), www.cian.ru обслуживает Москву и МО.
# Почему полем CityLocation, а не вторым реестром "слаг → хост": хост и region= —
# один и тот же гео-скоуп ОДНОГО запроса, и разъехаться они не имеют права
# (region=1 на ekb.cian.ru — чужая либо пустая выдача, причём молча). Отдельная
# карта завела бы второй ключ в том же ключевом пространстве и ровно этот риск
# рассинхрона; поле в той же frozen-записи рассинхронизировать нечем.
CIAN_SVERDLOVSK_HOST = "https://ekb.cian.ru"
CIAN_MOSCOW_HOST = "https://www.cian.ru"
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -402,11 +571,25 @@ class CityLocation:
Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep
явно падает (ValueError), если city_slug известен, а yandex_rgid
для него None.
cian_host поддомен Циана (см. CIAN_SVERDLOVSK_HOST выше). Дефолт
свердловский: все 45 записей области остаются байт в байт, а
город ВНЕ Свердловской обл. обязан передать хост явно, иначе
region= уедет на чужой поддомен.
avito_slug_is_region
True, если avito_slug слаг РЕГИОНА, а не города (moskva_i_mo
покрывает Москву и всю область одним скоупом: отдельного слага
у области нет, замер 10-12.09.2026). Такой слаг НЕ появляется в
ссылках карточек (там /moskva/, /himki/, /balashiha/), поэтому
per-card фильтр `avito_serp_ekb_only` на нём выбросил бы 100%
выдачи run_avito_city_sweep выключает фильтр для таких
записей (AvitoScraper(filter_cards_by_city_slug=False)).
"""
avito_slug: str | None
cian_region_id: int
yandex_rgid: int | None
cian_host: str = CIAN_SVERDLOVSK_HOST
avito_slug_is_region: bool = False
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
@ -469,14 +652,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
"verkhoturye": CityLocation(None, 174499, 328251),
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
"mikhaylovsk": CityLocation(None, 175332, None),
# ── Москва (77) и Московская область (50) ──────────────────────────────
# Значения проверены живьём 10-12.09.2026 (scripts/local-avito-msk/collect.py):
# • avito — слаг ОДИН на оба региона (moskva_i_mo), отдельного слага у области
# нет; деление на 77/50 происходит позже, на импорте, по полигонам
# (app.services.regions.region_for_point), а не по слагу.
# • cian — region=1 Москва (92 817 лотов), 4593 область (60 231), -1 обе
# вместе (153 049). Два параметра region НЕ объединяются (побеждает
# последний), поэтому «Москва+область» — это именно -1, а не пара значений.
# • yandex — rgid 587795 Москва (18 705), 587654 область (14 242), 741964 обе
# вместе (31 074); контроль ЕКБ 559132 (4 060).
# Объединённый скоуп безопасен именно потому, что region_code лота считается по
# полигонам, а не по слагу развёртки: штамп региона не соврёт, даже когда в одном
# прогоне приезжают лоты обоих регионов.
"moskva": CityLocation("moskva_i_mo", 1, 587795, CIAN_MOSCOW_HOST, True),
"moskovskaya_oblast": CityLocation("moskva_i_mo", 4593, 587654, CIAN_MOSCOW_HOST, True),
"moskva_i_mo": CityLocation("moskva_i_mo", -1, 741964, CIAN_MOSCOW_HOST, True),
}
def get_city_location(city_slug: str | None) -> CityLocation | None:
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
"""CityLocation по slug (CITY_LOCATIONS). None → None (=ЕКБ-дефолт).
Неизвестный slug ValueError, НЕ ЕКБ-дефолт: тихий откат означал бы, что
развёртка с опечаткой в `default_params["city"]` собирает Екатеринбург под
чужим именем города, со штатными счётчиками и без аномалий в логах.
"""
if city_slug is None:
return None
return CITY_LOCATIONS.get(city_slug)
try:
return CITY_LOCATIONS[city_slug]
except KeyError:
raise ValueError(
f"get_city_location: неизвестный city_slug={city_slug!r} — развёртка "
"откатилась бы на ЕКБ. Заведи запись в CITY_LOCATIONS."
) from None
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
@ -538,11 +748,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
"verkhoturye": "Верхотурье",
"talitsa": "Талица",
"novaya_lyalya": "Новая Ляля",
"moskva": "Москва",
}
EKATERINBURG_CITY_NAME = "Екатеринбург"
# Скоупы, внутри которых лоты принадлежат РАЗНЫМ городам: один штамп city на такой
# batch был бы ложью (у области это Химки/Балашиха/Подольск/…, у объединённого
# скоупа — ещё и Москва). resolve_city_name возвращает для них None, save_listings
# пишет city=NULL, и город доезжает позже из адреса (геокодер), а не из слага
# развёртки. Регион определяется полигонами, поэтому region_code от этого не страдает.
MULTI_CITY_SLUGS: frozenset[str] = frozenset({"moskovskaya_oblast", "moskva_i_mo"})
def resolve_city_name(city_slug: str | None) -> str:
def resolve_city_name(city_slug: str | None) -> str | None:
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с
@ -551,11 +769,26 @@ def resolve_city_name(city_slug: str | None) -> str:
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) тоже ЕКБ-дефолт, тем же путём, что и
get_city_location/get_city_anchors для неизвестных slug'ов.
get_city_location/get_city_anchors (оба теперь падают на неизвестном slug'е).
Слаг из MULTI_CITY_SLUGS None: в таком batch'е городов несколько, и любой
один штамп был бы ложью (save_listings при city=None просто не трогает поле, а
гео-гард штампа там инертен см. `_geo_guard_active` в scraper_kit.base).
Неизвестный slug ValueError, а не «Екатеринбург»: молчаливый ЕКБ-штамп на
лотах чужого города ровно тот дефект, из-за которого сломанную развёртку вне
ЕКБ было не отличить от исправной по логам.
"""
if city_slug is None:
return EKATERINBURG_CITY_NAME
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
if city_slug in MULTI_CITY_SLUGS:
return None
try:
return CITY_DISPLAY_NAMES[city_slug]
except KeyError:
raise ValueError(
f"resolve_city_name: неизвестный city_slug={city_slug!r} — раньше здесь "
"молча возвращался 'Екатеринбург'. Заведи имя в CITY_DISPLAY_NAMES."
) from None
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
@ -582,6 +815,19 @@ def resolve_city_name(city_slug: str | None) -> str:
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0,
# Москва: гард меряет от ЦЕНТРА ядра (MOSCOW_ANCHORS[0] = 55.75/37.60 — центр
# стоит первым специально, см. _build_moscow_anchor_grid). Дальний угол ядра —
# hypot(22.25, 18.78) = 29.1 км, поэтому дефолтные 15 км срезали бы city у всего
# за МКАД и части внутри неё (Бутово, Митино, Некрасовка, Солнцево — легитимная
# Москва, ~20-27 км от центра). 30 км = 29.1 + ~0.9 км запаса на адреса, чьи
# координаты чуть выходят за рамку окна.
# Цена решения: в 30 км попадают и ближние города области (Химки ~20, Красногорск
# ~16, Люберцы ~14, Реутов ~12 км) — их лоты, если их захватит московская выдача,
# получат city="Москва". РЕГИОН при этом не врёт: region_code считается по
# полигонам (app.services.regions.region_for_point), а не по штампу города.
# Развёрток "moskovskaya_oblast"/"moskva_i_mo" это не касается вовсе: у них
# city=None (MULTI_CITY_SLUGS), а при city=None гард в save_listings инертен.
"moskva": 30.0,
}
@ -1124,6 +1370,36 @@ class CitySweepCounters:
return {f.name: getattr(self, f.name) for f in fields(self)}
def _require_region_scope(
source_kind: str,
region_code: int,
anchors: list[tuple[float, float, str]] | None,
) -> None:
"""Регион вне 66 обязан приходить СО СВОИМИ anchors — иначе это тихий сбор ЕКБ.
`anchors=None` у всех трёх anchor-свипов означает "ЕКБ по умолчанию"
(EKB_ANCHORS у avito/cian, центральная точка combos-режима у yandex). Планировщик
берёт anchors из `CITY_ANCHORS[default_params["city"]]`, а этот реестр сегодня
покрывает только Свердловскую область, поэтому строка расписания вида
`avito_city_sweep_moscow` с `{"region_code": 77}` собрала бы ЕКАТЕРИНБУРГ и
проштамповала его московским регионом со штатными счётчиками и без единой
аномалии в логах. Падаем до первого запроса.
Москва (77) и МО (50) попали в прод-корпус разовым ручным коллектором
(`scripts/local-avito-msk/collect.py` схема `msk_raw` `app.tasks.msk_raw_import`),
а не свипами. Регулярный сбор по ним требует сперва завести города в
CITY_ANCHORS/CITY_LOCATIONS (anchors + per-provider идентификаторы), и только
потом строки расписаний.
"""
if region_code == DEFAULT_REGION_CODE or anchors:
return
raise ValueError(
f"{source_kind}: region_code={region_code} без anchors города — развёртка "
"откатилась бы на ЕКБ и проштамповала его чужим регионом. Заведи город в "
"CITY_ANCHORS/CITY_LOCATIONS (и передай anchors) либо убери region_code."
)
async def run_avito_city_sweep(
db: Session,
*,
@ -1164,6 +1440,7 @@ async def run_avito_city_sweep(
# AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города,
# а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ.
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
_require_region_scope("avito_city_sweep", region_code, anchors)
_anchors = anchors if anchors is not None else EKB_ANCHORS
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
@ -1228,6 +1505,9 @@ async def run_avito_city_sweep(
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug
# Слаг региона (moskva_i_mo) в ссылках карточек не встречается — per-card фильтр
# по нему выбросил бы всю выдачу. См. CityLocation.avito_slug_is_region.
_avito_card_filter = not (_loc.avito_slug_is_region if _loc is not None else False)
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
@ -1375,7 +1655,10 @@ async def run_avito_city_sweep(
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper(
config, target_city_slug=_avito_slug, proxy_provider=proxy_provider
config,
target_city_slug=_avito_slug,
proxy_provider=proxy_provider,
filter_cards_by_city_slug=_avito_card_filter,
)
if browser_mode:
scraper._browser = shared_bf
@ -2258,6 +2541,7 @@ async def run_yandex_city_sweep(
Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal).
"""
_require_region_scope("yandex_city_sweep", region_code, anchors)
# EKB center — единственный anchor для citywide combos sweep.
# anchors-параметр принимается для backward-compat тестов.
if anchors is not None:
@ -2904,6 +3188,7 @@ async def run_cian_city_sweep(
Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor.
mark_done вызывается ВСЕГДА (finally outer).
"""
_require_region_scope("cian_city_sweep", region_code, anchors)
_anchors = anchors if anchors is not None else EKB_ANCHORS
# ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ──────────────────────
@ -3045,6 +3330,7 @@ async def run_cian_city_sweep(
config,
proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None),
city_host=(_loc.cian_host if _loc else None),
) as scraper:
anchor_lots = await scraper.fetch_around_multi_room(
_a_lat,
@ -4522,6 +4808,14 @@ async def run_domclick_city_sweep(
Возвращает DomClickCitySweepCounters.
"""
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
# GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
# providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
# падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
# баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
# его под чужим region_code молча, со штатными счётчиками.
_geo_profile = geo_profile_for_region(region_code)
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters()
@ -4613,7 +4907,10 @@ async def run_domclick_city_sweep(
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies
config,
proxy_provider=proxy_provider,
cookies=cookies,
geo_profile=_geo_profile,
) as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
@ -4640,11 +4937,10 @@ async def run_domclick_city_sweep(
)
counters.lots_fetched += len(lots)
if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
# угадывается.
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
# Имя города — из профиля региона, а не из сравнения с vestigial
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# У области (50) city_name=None — одного города нет, угадывать нечего.
_dc_city = _geo_profile.city_name
inserted, updated = save_listings(
db,
lots,

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.pipeline import (
DEFAULT_REGION_CODE,
get_city_anchors,
run_avito_city_sweep,
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)
# 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,
# иначе pre-check снова гоняется каждый тик. 3 тика = минимум один промах даже при
# рассинхроне часов планировщика и БД на такт в любую сторону.
@ -1026,8 +1058,10 @@ async def _job_avito_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
# города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None →
# run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city).
# города вместо EKB_ANCHORS. Ключа нет вовсе → anchors=None → citywide-свип ЕКБ
# (прежнее поведение расписаний без city). Ключ ЕСТЬ, но слаг неизвестен →
# get_city_anchors падает с ValueError: опечатка в расписании раньше тихо давала
# прогон по Екатеринбургу под именем чужого города.
city = params.get("city")
anchors = get_city_anchors(city) if city else None
await run_avito_city_sweep(
@ -1047,6 +1081,7 @@ async def _job_avito_city_sweep(
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
enrich_houses=bool(params.get("enrich_houses", True)),
radius_m=int(params.get("radius_m", 1500)),
region_code=_resolve_region_code(params),
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
# имя якоря, оно не зависит от количества якорей, поэтому в отличие от
# combo-чекпоинта yandex-свипа гарда по числу якорей здесь не требуется.
@ -1066,6 +1101,7 @@ async def _job_avito_newbuilding_sweep(
proxy_provider=ctx.proxy_provider,
pages=int(params.get("pages", 20)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
region_code=_resolve_region_code(params),
# #3074: подхват страниц у оборванного предшественника — см.
# _job_avito_city_sweep выше.
resume_run_id=_pick_resume(db, run_id),
@ -1110,6 +1146,7 @@ async def _job_avito_full_load(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id),
incremental_days=incremental_days,
)
@ -1130,6 +1167,7 @@ async def _job_avito_full_load_exhaustive(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id),
incremental_days=None,
)
@ -1159,6 +1197,7 @@ async def _job_yandex_city_sweep(
radius_m=int(params.get("radius_m", 1500)),
enrich_address=bool(params.get("enrich_address", True)),
segments=params.get("segments"),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id),
)
@ -1184,6 +1223,7 @@ async def _job_cian_city_sweep(
detail_top_n=int(params.get("detail_top_n", 10)),
enrich_houses=bool(params.get("enrich_houses", True)),
newbuilding_only=bool(params.get("newbuilding_only", True)),
region_code=_resolve_region_code(params),
# #3074: подхват якорей у оборванного предшественника. Ключ чекпоинта —
# имя якоря, от их количества не зависит, поэтому гарда как у combo-
# чекпоинта яндекса здесь не требуется.
@ -1206,6 +1246,7 @@ async def _job_cian_full_load(
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
enrich_detail=bool(params.get("enrich_detail", False)),
detail_top_n=int(params.get("detail_top_n", 0)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id),
)
@ -1224,6 +1265,7 @@ async def _job_domclick_city_sweep(
rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)),
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),
)

View file

@ -1,23 +1,63 @@
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
Единый движок адаптивной бисекции «комнатность × цена» для всех провайдеров
(avito / cian / yandex / domclick). См. `bisection.py`.
Два слоя, намеренно разделённые:
* `bisection.py` боевой async-движок обхода `walk_price_range`: ходит в сеть
через provider-callback'и, пагинирует листья. `BisectionStats` — счётчики
усечения для уже существующих свипов.
* `planner.py` СИНХРОННЫЙ ЧИСТЫЙ планировщик `plan_price_corridors`: на вход
«сколько позиций в коридоре», на выход список коридоров + `PlanStats`.
Геометрическая середина `sqrt(lo*hi)` (цены логнормальны) и предохранители
`MAX_DEPTH` / `MIN_WIDTH_RATIO`; перенесён из обкатанного живьём
`scripts/local-avito-msk/collect.py`.
"""
from __future__ import annotations
from scraper_kit.pricing.bisection import (
BisectionConfig,
BisectionStats,
ProbeFailPolicy,
ProbeResult,
done_range_skipper,
walk_price_range,
)
from scraper_kit.pricing.planner import (
EKB_REGION_CODE,
MAX_DEPTH,
MIN_WIDTH_RATIO,
PRICE_CEIL,
PRICE_FLOOR,
PRICE_PROBE_START,
Corridor,
CountFn,
PlanStats,
PricePlan,
geometric_mid,
plan_price_corridors,
price_bisection_enabled,
width_ratio,
)
__all__ = [
"EKB_REGION_CODE",
"MAX_DEPTH",
"MIN_WIDTH_RATIO",
"PRICE_CEIL",
"PRICE_FLOOR",
"PRICE_PROBE_START",
"BisectionConfig",
"BisectionStats",
"Corridor",
"CountFn",
"PlanStats",
"PricePlan",
"ProbeFailPolicy",
"ProbeResult",
"done_range_skipper",
"geometric_mid",
"plan_price_corridors",
"price_bisection_enabled",
"walk_price_range",
"width_ratio",
]

View file

@ -88,6 +88,63 @@ class ProbeResult:
payload: Any = None
@dataclass
class BisectionStats:
"""Счётчики одного обхода — чтобы усечение выдачи было ЧИСЛОМ, а не тишиной.
Движок делит диапазон, пока `count > cap`, но деление не бесконечно: упёрлись
в `min_bracket` или в `max_depth` и переполненный диапазон всё равно уходит
в `on_leaf`, где пагинация штатно дорежет его до `cap`. Разница `count - cap`
в этот момент теряется БЕЗ СЛЕДА: по Свердловской области это никогда не
срабатывало (вторичка влезает в сид-брекеты), а по Москве и области, где
лотов десятки тысяч, прогон забирал верхушку и рапортовал успех.
Объект МУТАБЕЛЕН и передаётся в `walk_price_range` опционально: по умолчанию
``None`` ни одной новой ветки в обходе, дерево рекурсии байт-в-байт прежнее
(golden-parity провайдеров не двигается). Вызывающий складывает
`missed_estimate` в счётчики прогона.
"""
#: Сколько раз дёрнули provider-probe (стоимость планирования в запросах).
probes: int = 0
#: Сколько диапазонов дошло до пагинации.
leaves: int = 0
#: Листья, где `count > cap` — поделить не вышло, выдача будет обрезана.
truncated_leaves: int = 0
#: Оценка недобора: сумма `count - cap` по усечённым листьям.
missed_estimate: int = 0
#: Листья с непрочитанным счётчиком (полнота недоказуема, не то же, что 0).
unknown_leaves: int = 0
#: Диапазоны, брошенные по ProbeFailPolicy.SKIP — тоже дыра в покрытии.
skipped_probe_fail: int = 0
#: Переходы в «пагинировать-до-пустоты» без знания total (yandex DEGRADE).
degraded: int = 0
max_depth_reached: int = 0
def record_probe(self, depth: int) -> None:
self.probes += 1
self.max_depth_reached = max(self.max_depth_reached, depth)
def record_leaf(self, count: int | None, cap: int) -> None:
self.leaves += 1
if count is None:
self.unknown_leaves += 1
return
if count > cap:
self.truncated_leaves += 1
self.missed_estimate += count - cap
@property
def complete(self) -> bool:
"""Обход покрыл выборку целиком — ни усечений, ни пропусков, ни слепых листьев."""
return (
self.truncated_leaves == 0
and self.unknown_leaves == 0
and self.skipped_probe_fail == 0
and self.degraded == 0
)
@dataclass(frozen=True)
class BisectionConfig:
"""Per-provider константы/политики бисекции (все различия провайдеров тут).
@ -222,6 +279,7 @@ async def walk_price_range(
on_leaf: LeafFn,
on_degraded: DegradedFn | None = None,
should_skip: SkipFn | None = None,
stats: BisectionStats | None = None,
depth: int = 0,
) -> None:
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
@ -249,6 +307,10 @@ async def walk_price_range(
(:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле
рекурсии: у частично покрытого диапазона probe остаётся, а его уже
готовые половины отсекаются на входе, а не в листе после probe.
stats: опциональный :class:`BisectionStats` мутабельный аккумулятор
probe/leaf/усечений. ``None`` (умолчание) = ни одной новой ветки в
обходе. Передан вызывающий получает `missed_estimate` и обязан
донести его до счётчиков прогона, иначе усечение снова станет тишиной.
depth: текущая глубина рекурсии (для ``max_depth``).
"""
if should_skip is not None and should_skip(lo, hi):
@ -256,22 +318,34 @@ async def walk_price_range(
result = await probe(lo, hi, depth)
total = result.count
if stats is not None:
stats.record_probe(depth)
# ── Probe-fail: счётчик недоступен ────────────────────────────────────────
if total is None:
policy = config.probe_fail_policy
if policy is ProbeFailPolicy.SKIP:
if stats is not None:
stats.skipped_probe_fail += 1
return
if policy is ProbeFailPolicy.DEGRADE:
if stats is not None:
stats.degraded += 1
if on_degraded is not None:
await on_degraded(lo, hi)
return
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
if hi is None:
if stats is not None:
stats.record_leaf(None, config.cap)
await on_leaf(lo, hi, result)
return
_lo = lo if lo is not None else config.open_split_floor
if not _can_split_span(hi - _lo, config) and stats is not None:
# Закрытый неделимый диапазон без счётчика просто бросается — это
# такая же дыра в покрытии, как SKIP, и она обязана быть видна.
stats.skipped_probe_fail += 1
if _can_split_span(hi - _lo, config):
mid = (_lo + hi) // 2
await walk_price_range(
@ -282,6 +356,7 @@ async def walk_price_range(
on_leaf=on_leaf,
on_degraded=on_degraded,
should_skip=should_skip,
stats=stats,
depth=depth + 1,
)
await walk_price_range(
@ -292,6 +367,7 @@ async def walk_price_range(
on_leaf=on_leaf,
on_degraded=on_degraded,
should_skip=should_skip,
stats=stats,
depth=depth + 1,
)
return
@ -303,6 +379,8 @@ async def walk_price_range(
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
if hi is None and config.open_split_ceiling is None:
if stats is not None:
stats.record_leaf(total, config.cap)
await on_leaf(lo, hi, result)
return
@ -329,6 +407,7 @@ async def walk_price_range(
on_leaf=on_leaf,
on_degraded=on_degraded,
should_skip=should_skip,
stats=stats,
depth=depth + 1,
)
await walk_price_range(
@ -339,9 +418,15 @@ async def walk_price_range(
on_leaf=on_leaf,
on_degraded=on_degraded,
should_skip=should_skip,
stats=stats,
depth=depth + 1,
)
return
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
# Сюда же приезжает переполненный диапазон, который НЕ дали поделить
# min_bracket / max_depth: пагинация дорежет его до cap, и без stats разница
# count-cap пропала бы бесследно.
if stats is not None:
stats.record_leaf(total, config.cap)
await on_leaf(lo, hi, result)

View file

@ -0,0 +1,284 @@
"""Чистый планировщик ценовых коридоров под жёсткий потолок выдачи площадки.
ЗАЧЕМ. У каждой площадки жёсткий cap на набор фильтров: Avito ~1500 позиций,
Cian 1512, Yandex 500, DomClick 2000. По Свердловской области это не мешало
вторичка целиком влезает в сид-брекеты (`scraper_kit.price_brackets`). По Москве
и области лотов десятки тысяч (Cian 153k на объединённом скоупе, Yandex 31k,
DomClick 17.5k по области), и без дробления по цене прогон ТИХО забирает верхушку
выдачи и рапортует успех. Этот модуль превращает молчаливое усечение в число.
ЧЕМ ОТЛИЧАЕТСЯ ОТ `pricing.bisection`. `walk_price_range` боевой async-движок
обхода: он ходит в сеть, пагинирует листья и завязан на provider-callback'и, а
его арифметика деления `mid = (lo + hi) // 2`. Здесь СИНХРОННАЯ ЧИСТАЯ функция:
на вход «сколько позиций в коридоре», на выход список коридоров + честная
статистика. Ни одного сетевого вызова внутри, поэтому она тестируется как
обычная функция и её можно прогнать на модели распределения до прогона.
Алгоритм перенесён из обкатанного живьём `scripts/local-avito-msk/collect.py`
(:245-249, :303-336, :1408-1434) и сохраняет три его решения:
1. **Геометрическая середина** `sqrt(lo*hi)`, а не арифметическая. Цены
логнормальны: арифметическая середина коридора 1М..100М (50М) отрезает
вырожденно-пустую верхнюю половину, и дерево вырождается в лестницу
деление идёт, а переполненный левый край так и не разъезжается.
2. **Верхняя граница ищется удвоением** от `probe_start` (8 млн ), а не берётся
константой: потолок рынка по региону заранее неизвестен.
3. **Два предохранителя** `MAX_DEPTH` и `MIN_WIDTH_RATIO`. Неделимый
переполненный коридор НЕ молчит: он помечается `truncated` и его недобор
попадает в `PlanStats.missed_estimate`.
"""
from __future__ import annotations
import math
from collections.abc import Callable
from dataclasses import dataclass, field
# Нижняя граница осмысленного коридора, ₽ (подстановка вместо None-lo в арифметике).
PRICE_FLOOR = 500_000
# Старт удвоения при поиске верхней границы открытого коридора, ₽.
PRICE_PROBE_START = 8_000_000
# Абсолютный потолок поиска верхней границы, ₽.
PRICE_CEIL = 2_000_000_000
# Уже этого (по ГЕОМЕТРИЧЕСКОЙ ширине hi/lo) коридор не делим.
MIN_WIDTH_RATIO = 1.05
# Жёсткий предел глубины рекурсии: гарантия остановки, не более 2^12 листьев.
MAX_DEPTH = 12
# Свердловская область: текущий боевой скоуп, где бисекция не нужна (см.
# `price_bisection_enabled`).
EKB_REGION_CODE = 66
#: «Сколько всего позиций в коридоре [lo, hi]». ``None`` — счётчик не прочитался
#: (captcha / битая страница), это НЕ ноль. Границы включительные, ``None`` = без
#: границы.
CountFn = Callable[[int | None, int | None], int | None]
@dataclass(frozen=True)
class Corridor:
"""Один ценовой коридор плана."""
lo: int | None
hi: int | None
count: int | None = None
#: Коридор заведомо не влезает в потолок площадки и поделить его уже нельзя.
truncated: bool = False
#: Оценка недобора: ``count - hard_cap`` (0, если count неизвестен).
missed: int = 0
depth: int = 0
#: Счётчик не прочитался — коридор уходит в прогон, но за полноту не ручаемся.
unknown_count: bool = False
def label(self) -> str:
lo = "-" if self.lo is None else f"{self.lo:_}"
hi = "-" if self.hi is None else f"{self.hi:_}"
return f"[{lo} .. {hi}]"
@dataclass(frozen=True)
class PlanStats:
"""Честная сводка по плану. Усечение обязано быть видно здесь, а не в логе."""
corridors: int = 0
truncated_corridors: int = 0
#: Сколько позиций заведомо не будет собрано — сумма `missed` по усечённым.
missed_estimate: int = 0
#: Коридоры с непрочитанным счётчиком (полнота недоказуема).
unknown_corridors: int = 0
#: Сколько раз дёрнули `count_of` (стоимость планирования в запросах).
probes: int = 0
max_depth_reached: int = 0
#: Счётчик по корневому запросу — сколько всего в скоупе.
root_count: int | None = None
@property
def complete(self) -> bool:
"""План покрывает выборку целиком (ни усечений, ни слепых коридоров)."""
return self.truncated_corridors == 0 and self.unknown_corridors == 0
@dataclass(frozen=True)
class PricePlan:
corridors: list[Corridor] = field(default_factory=list)
stats: PlanStats = field(default_factory=PlanStats)
@property
def truncated(self) -> list[Corridor]:
return [c for c in self.corridors if c.truncated]
def geometric_mid(lo: int | None, hi: int, *, price_floor: int = PRICE_FLOOR) -> int:
"""Геометрическая середина коридора: ``sqrt(lo*hi)``.
Цены логнормальны, поэтому арифметическая середина делит ВЫБОРКУ крайне
неровно. Результат зажат в ``[lo+1, hi-1]``, чтобы деление всегда давало две
непустые половины и рекурсия не могла зациклиться на границе.
ПРЕДУСЛОВИЕ: ``hi - lo >= 2``. У атомарного коридора (``hi - lo <= 1``)
строгой внутренней точки не существует вовсе, и зажим вернул бы саму границу
половина совпала бы с родителем, то есть рекурсия по себе. Делимость
проверяет вызывающий (`plan_price_corridors`), см. там ветку `upper - eff_lo
<= 1`; в исходнике `collect.py` этой проверки нет, и от бесконечного спуска
его спасает только MAX_DEPTH.
"""
low = max(int(lo or price_floor), 1)
mid = int(math.sqrt(low * float(hi)))
return max(low + 1, min(hi - 1, mid))
def width_ratio(lo: int | None, hi: int | None, *, price_floor: int = PRICE_FLOOR) -> float:
"""Геометрическая ширина коридора ``hi/lo`` (``inf`` для открытого верха)."""
if hi is None:
return float("inf")
return float(hi) / max(float(lo or price_floor), 1.0)
def price_bisection_enabled(region_code: int | None) -> bool:
"""Нужна ли ценовая бисекция для региона.
УМОЛЧАНИЕ ВЫКЛЮЧЕНО ДЛЯ 66 (Свердловская обл.) И ДЛЯ НЕИЗВЕСТНОГО РЕГИОНА.
Причина не в том, что бисекция там вредна, а в том, что она там НЕ НУЖНА и
поэтому её включение было бы чистым регрессом: вторичка области целиком
влезает в сид-брекеты `price_brackets`, счётчик ни в одном брекете не бьётся
в cap, и каждый лишний probe это лишний запрос к площадке под антибот. То
есть для 66 план обязан быть РОВНО ТЕМ ЖЕ обходом, что и сегодня.
``None`` (регион не передали) трактуем как 66, а не как «включить»: провайдеры
фоллбэчат на ЕКБ-дефолт, когда регион не задан, и «безопаснее включить»
здесь означало бы молча поменять боевое поведение прогонов, которые просто
не прокинули параметр.
Для всех остальных регионов включено: именно там (77/50 Москва и область)
выдача кратно превышает cap площадки.
"""
if region_code is None:
return False
return int(region_code) != EKB_REGION_CODE
def plan_price_corridors(
*,
count_of: CountFn,
target: int,
hard_cap: int | None = None,
lo: int | None = None,
hi: int | None = None,
enabled: bool = True,
max_depth: int = MAX_DEPTH,
min_width_ratio: float = MIN_WIDTH_RATIO,
price_floor: int = PRICE_FLOOR,
probe_start: int = PRICE_PROBE_START,
price_ceil: int = PRICE_CEIL,
) -> PricePlan:
"""Разбить ценовой диапазон на коридоры, влезающие в потолок выдачи.
Args:
count_of: «сколько позиций в [lo, hi]»; ``None`` счётчик не прочитался.
target: целевой размер коридора. Берётся С ЗАПАСОМ ниже ``hard_cap``:
счётчик площадки врёт в обе стороны и выдача плывёт между probe и
пагинацией, поэтому коридор ровно в cap приезжает усечённым.
hard_cap: жёсткий потолок выдачи площадки; по умолчанию ``target``.
Недобор считается от него, а не от ``target``.
lo / hi: границы корневого диапазона (``None`` = без границы).
enabled: ``False`` -> ровно ОДИН коридор на весь диапазон, ноль делений
(режим региона 66, см. :func:`price_bisection_enabled`). Корневой
probe всё равно делается чтобы усечение, если оно там есть, попало
в статистику, а не осталось незамеченным.
max_depth / min_width_ratio: предохранители, см. модульный docstring.
Returns:
:class:`PricePlan` коридоры в порядке обхода + :class:`PlanStats`.
"""
cap = hard_cap if hard_cap is not None else target
corridors: list[Corridor] = []
probes = 0
deepest = 0
def probe(p_lo: int | None, p_hi: int | None) -> int | None:
nonlocal probes
probes += 1
return count_of(p_lo, p_hi)
def emit(c_lo: int | None, c_hi: int | None, count: int | None, depth: int) -> None:
nonlocal deepest
deepest = max(deepest, depth)
if count is None:
# Слепой коридор: собираем, но полноту не декларируем.
corridors.append(
Corridor(
lo=c_lo,
hi=c_hi,
count=None,
truncated=True,
missed=0,
depth=depth,
unknown_count=True,
)
)
return
over = count > cap
corridors.append(
Corridor(
lo=c_lo,
hi=c_hi,
count=count,
truncated=over,
missed=max(0, count - cap) if over else 0,
depth=depth,
)
)
def find_upper(f_lo: int | None) -> int:
"""Верхнюю границу открытого коридора ищем удвоением от probe_start."""
cand = max(int(f_lo or price_floor) * 2, probe_start)
while cand < price_ceil:
cnt = probe(cand, None)
if cnt is not None and cnt <= target:
return cand
cand *= 2
return cand
def split(s_lo: int | None, s_hi: int | None, depth: int, count: int | None) -> None:
if count is None or count <= target:
emit(s_lo, s_hi, count, depth)
return
too_narrow = width_ratio(s_lo, s_hi, price_floor=price_floor) <= min_width_ratio
if depth >= max_depth or too_narrow:
# Предохранитель сработал: делить больше нельзя. НЕ молчим — коридор
# уезжает в план помеченным, недобор попадает в missed_estimate.
emit(s_lo, s_hi, count, depth)
return
upper = s_hi if s_hi is not None else find_upper(s_lo)
if upper - (s_lo if s_lo is not None else price_floor) <= 1:
# Атомарный коридор: строгой внутренней точки нет, делить нечем.
# Третий предохранитель рядом с MAX_DEPTH / MIN_WIDTH_RATIO — при
# дефолтном min_width_ratio=1.05 недостижим, но min_width_ratio
# параметризуем, и без этой ветки деление ушло бы в рекурсию по себе.
emit(s_lo, s_hi, count, depth)
return
if s_hi is None:
# Хвост выше найденной границы закрывается отдельным коридором:
# иначе верх рынка просто выпал бы из плана.
emit(upper, None, probe(upper, None), depth + 1)
mid = geometric_mid(s_lo, upper, price_floor=price_floor)
for sub_lo, sub_hi in ((s_lo, mid), (mid, upper)):
split(sub_lo, sub_hi, depth + 1, probe(sub_lo, sub_hi))
root_count = probe(lo, hi)
if enabled:
split(lo, hi, 0, root_count)
else:
emit(lo, hi, root_count, 0)
truncated = [c for c in corridors if c.truncated]
stats = PlanStats(
corridors=len(corridors),
truncated_corridors=len(truncated),
missed_estimate=sum(c.missed for c in truncated),
unknown_corridors=sum(1 for c in corridors if c.unknown_count),
probes=probes,
max_depth_reached=deepest,
root_count=root_count,
)
return PricePlan(corridors=corridors, stats=stats)

View file

@ -398,6 +398,7 @@ class AvitoScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None,
target_city_slug: str | None = None,
proxy_provider: ProxyProvider | None = None,
filter_cards_by_city_slug: bool = True,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper):
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
self._target_city_slug = target_city_slug
# Слаг РЕГИОНА (moskva_i_mo покрывает Москву и всю область одним скоупом) в
# ссылках карточек не встречается — там /moskva/, /himki/, /balashiha/… .
# Поэтому per-card фильтр по слагу города-цели на таком скоупе выбросил бы
# 100% выдачи (замер 10-12.09.2026). False — фильтр выключен ДЛЯ ЭТОГО
# прогона, независимо от глобального config.avito_serp_ekb_only: флаг
# настройки говорит «отбрасывать добивку по всей России», а не «собирать
# только Екатеринбург», и на региональном слаге он неприменим физически.
self._filter_cards_by_city_slug = filter_cards_by_city_slug
# #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper):
ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = []
ekb_only = self._config.avito_serp_ekb_only
ekb_only = self._config.avito_serp_ekb_only and self._filter_cards_by_city_slug
# #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
kept_slug = self._target_city_slug or "ekaterinburg"

View file

@ -86,6 +86,7 @@ _MIN_BRACKET = 50_000
# Cian SERP показывает ~28 офферов на страницу.
_CIAN_OFFERS_PER_PAGE = 28
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
@ -97,6 +98,7 @@ def _cian_bisection_config(cap: int) -> BisectionConfig:
probe_fail_policy=ProbeFailPolicy.SKIP,
)
# Комнатности по умолчанию для exhaustive-загрузки.
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
@ -140,6 +142,7 @@ class CianScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None,
city_region_id: int | None = None,
city_host: str | None = None,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -153,9 +156,17 @@ class CianScraper(BaseScraper):
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
self._proxy_provider = proxy_provider
self._browser: BrowserFetcher | None = None
# #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID).
# #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт
# (CIAN_EKB_REGION_ID).
self._region_id = city_region_id or CIAN_EKB_REGION_ID
# Поддомен Циана — РЕГИОНАЛЬНЫЙ, а не городской: ekb.cian.ru общий для всей
# Свердловской обл. (поэтому раньше был захардкожен классовым base_url), но
# Москву и область он не обслуживает — там www.cian.ru. Приходит из реестра
# города (CityLocation.cian_host в orchestration/pipeline.py) вместе с region=,
# чтобы хост и region не разъехались. None → класс-дефолт ЕКБ: URL прежних
# свердловских развёрток остаются байт в байт.
if city_host is not None:
self.base_url = city_host
# #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
@ -296,8 +307,9 @@ class CianScraper(BaseScraper):
) -> str:
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
Используем ekb.cian.ru (city-specific subdomain общий для всей Свердловской
обл., НЕ меняется per-city). region= param фактический скоуп выборки
Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru вся
Свердловская обл., www.cian.ru Москва и МО; задаётся city_host в __init__,
по умолчанию ЕКБ). region= param фактический скоуп выборки
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
min_price / max_price опциональные ценовые границы (руб.) для price-бакетов.
"""

View file

@ -32,6 +32,7 @@ import hashlib
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
@ -49,8 +50,6 @@ logger = logging.getLogger(__name__)
# ── API constants ─────────────────────────────────────────────────────────────
_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"
# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
@ -81,13 +80,123 @@ _DOMCLICK_BISECTION = BisectionConfig(
open_split_floor=0,
)
# ── EKB geo guard ─────────────────────────────────────────────────────────────
# ── Гео-профиль региона ───────────────────────────────────────────────────────
_EKB_LAT_MIN: float = 56.6
_EKB_LAT_MAX: float = 57.0
_EKB_LON_MIN: float = 60.2
_EKB_LON_MAX: float = 60.9
_EKB_REGION_NAME: str = "Екатеринбург"
@dataclass(frozen=True)
class DomClickGeoProfile:
"""Всё, что отличает свип одного региона от свипа другого.
Раньше эти величины были модульными константами `_EKB_*`, и прогон с
region_code != 66 собрал бы Екатеринбург, проштамповав его чужим регионом.
Поля:
address_guid параметр `address` BFF-запроса (регион/локалити DomClick).
aids параметр `aids` (сужение выдачи). ``None`` = параметр НЕ
добавлять: у Москвы и области счётчик без него совпадает
с сайтом (замер 12.09.2026), у ЕКБ aids=20561 обязателен.
origin_url городская страница выдачи, на которой прогревается
контекст перед запросом к BFF (#3264). BFF — не страница,
а JSON-ручка SPA: навигацией на неё приходит только
ChallengeTimeout, а подзапросом из прогретой выдачи
HTTP 200. Прогревать нужно СВОЙ город.
bbox (lat_min, lat_max, lon_min, lon_max) гео-гарда выдачи.
region_name ожидаемый ``item["offerRegionName"]``. ``None`` = НЕ
сверять имя, гард только по bbox: вне ЕКБ поле ненадёжно,
офферы Новой Москвы приходят как «г. Говорово», а не
«Москва» (замер 12.09.2026).
city_name что писать в listings.city. ``None`` = не угадывать
(у области нет одного города).
"""
region_code: int
address_guid: str
aids: str | None
origin_url: str
bbox: tuple[float, float, float, float]
region_name: str | None
city_name: str | None
# Реестр профилей по коду региона.
#
# bbox для 77 и 50 — ДУБЛЬ значений backend/app/services/regions.py
# REGIONS[code].bbox_region (порядок тот же: lat_min, lat_max, lon_min, lon_max).
# Дубль намеренный и неустранимый: kit не имеет права импортировать app.* (это
# гейтится тестом), а общего места у kit и backend нет. Правишь bbox_region
# там — поправь здесь.
#
# bbox для 66 — НЕ bbox_region Свердловской области, а исторический ГОРОДСКОЙ
# bbox ЕКБ-гарда: свип 66 citywide по Екатеринбургу, а не по всей области.
# Значения 66 (GUID, aids, origin, bbox, имя региона) сохранены байт в байт,
# чтобы параметризация не изменила поведение прод-прогонов.
DOMCLICK_GEO_PROFILES: dict[int, DomClickGeoProfile] = {
66: DomClickGeoProfile(
region_code=66,
address_guid="0d475b79-88de-4054-818c-37d8f9d0d440",
aids="20561",
origin_url="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(56.6, 57.0, 60.2, 60.9),
region_name="Екатеринбург",
city_name="Екатеринбург",
),
# GUID Москвы и области проверены живым запросом 12.09.2026 (у Москвы region и
# locality — один и тот же GUID), см. scripts/local-avito-msk/collect.py
# MSK_DOMCLICK_GUID / MO_DOMCLICK_GUID. aids для обоих НЕ нужен: без него
# счётчик листинга совпадает с сайтом.
#
# Страница прогрева 77/50 — апексный домен, проверено запросом 15.09.2026:
# msk.domclick.ru -> 301 https://domclick.ru/...
# moskovskaya-oblast.domclick.ru-> 301 https://domclick.ru/...
# domclick.ru -> 401 (QRATOR-челлендж, как у ЕКБ)
# ekaterinburg.domclick.ru -> 401 (контроль)
# Схема `<город>.domclick.ru` работает НЕ для всех городов: у Москвы своего
# субдомена нет, апекс и есть московская выдача. 301 увёл бы прогрев на
# другой URL и с большой вероятностью потерял бы куку QRATOR.
77: DomClickGeoProfile(
region_code=77,
address_guid="1d1463ae-c80f-4d19-9331-a1b68a85b553",
aids=None,
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(55.10, 56.10, 36.80, 38.10),
region_name=None,
city_name="Москва",
),
50: DomClickGeoProfile(
region_code=50,
address_guid="9930cc20-32c6-4f6f-a55e-cd67086c5171",
aids=None,
# Область выдаётся с того же апексного домена; см. оговорку выше.
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(54.20, 56.96, 35.14, 40.21),
region_name=None,
city_name=None,
),
}
DEFAULT_GEO_PROFILE: DomClickGeoProfile = DOMCLICK_GEO_PROFILES[66]
# Обратная совместимость: страница прогрева ЕКБ как модульная константа
# (импортируется тестами subresource-режима).
_SWEEP_ORIGIN = DEFAULT_GEO_PROFILE.origin_url
def geo_profile_for_region(region_code: int) -> DomClickGeoProfile:
"""Профиль по коду региона. ValueError на неизвестном коде.
Молчаливый fallback на ЕКБ здесь недопустим: он и есть тот баг, ради
которого гео-скоуп стал параметром прогон собрал бы ЕКБ и записал его
под чужим region_code со штатными счётчиками.
"""
profile = DOMCLICK_GEO_PROFILES.get(region_code)
if profile is None:
_known = ", ".join(str(code) for code in sorted(DOMCLICK_GEO_PROFILES))
raise ValueError(
f"domclick: нет гео-профиля для region_code={region_code} "
f"(известны: {_known}); заведи запись в DOMCLICK_GEO_PROFILES "
"(address GUID, aids, страница прогрева, bbox) перед прогоном"
)
return profile
# ── JSON extraction ───────────────────────────────────────────────────────────
@ -152,14 +261,19 @@ def _build_offers_url(
price_gte: int | None,
price_lte: int | None,
offset: int,
*,
address_guid: str,
aids: str | None,
) -> str:
"""Строит URL для GET /api/offers/v1 с пагинацией.
urlencode кодирует "5+" "5%2B" (literal '+' в query string = space reject).
aids=None параметр не добавляется (Москва и область, см. DomClickGeoProfile).
"""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
params: list[tuple[str, str]] = [("address", address_guid)]
if aids is not None:
params.append(("aids", aids))
params += [
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
@ -176,25 +290,19 @@ def _build_offers_url(
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
# Страница, на которой прогревается контекст перед запросом к BFF (#3264).
# BFF — не страница, а JSON-ручка SPA; навигацией браузера на неё через мобильные узлы
# пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351 — 9/9 и 6/6 запросов
# зависли), а подзапросом из прогретой выдачи — HTTP 200 за 6 секунд на всех четырёх
# узлах и всех шести комнатных корзинах (замер 30.08.2026, суммарно 6359 офферов против
# 6367, снятых напрямую с резидентного IP). Прогрев обязателен: рукопожатие QRATOR
# попадает в куки контекста именно при заходе на страницу.
_SWEEP_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
def _build_count_url(
rooms: str,
price_gte: int | None,
price_lte: int | None,
*,
address_guid: str,
aids: str | None,
) -> str:
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
params: list[tuple[str, str]] = [("address", address_guid)]
if aids is not None:
params.append(("aids", aids))
params += [
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
@ -261,7 +369,7 @@ class DomClickScraper(BaseScraper):
Counters (публичные после fetch_city):
parse_failures офферы с ошибкой маппинга
geo_filtered офферы вне ЕКБ bbox или с неверным offerRegionName
geo_filtered офферы вне bbox профиля или с неверным offerRegionName
blocked True если sweep был прерван QRATOR-блоком
fetch_errors не-block ошибки извлечения JSON (truncated/garbled/bad shape)
buckets_total сколько комнатных бакетов прогон собирался пройти
@ -282,8 +390,12 @@ class DomClickScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None,
cookies: dict[str, str] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> None:
super().__init__()
# Гео-скоп прогона: GUID/aids в URL, страница прогрева и bbox гарда.
# Дефолт — ЕКБ (прежнее поведение всех вызовов без явного профиля).
self._geo = geo_profile or DEFAULT_GEO_PROFILE
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
# BrowserFetcher, delay_provider заменяет прямой импорт
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
@ -352,11 +464,12 @@ class DomClickScraper(BaseScraper):
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
вызывающим кодом (run_domclick_city_sweep), но:
- city_id vestigial (EКБ захардкожен через GUID).
- city_id vestigial: гео-скоп берётся из geo_profile (GUID/aids/bbox),
а не из city_id.
- rooms игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
Args:
city_id: игнорируется (EKB захардкожен).
city_id: игнорируется (гео-скоп в geo_profile скрейпера).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap).
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
@ -508,12 +621,18 @@ class DomClickScraper(BaseScraper):
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
(бакет пропускается, sweep продолжается).
"""
url = _build_count_url(rooms, price_gte, price_lte)
url = _build_count_url(
rooms,
price_gte,
price_lte,
address_guid=self._geo.address_guid,
aids=self._geo.aids,
)
logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
origin=self._geo.origin_url,
referer=self._geo.origin_url,
cookies=self._cookies,
fetch_mode="subresource",
)
@ -603,15 +722,22 @@ class DomClickScraper(BaseScraper):
offset = page_idx * PAGE_SIZE
if offset >= OFFSET_CAP:
break
url = _build_offers_url(rooms, price_gte, price_lte, offset)
url = _build_offers_url(
rooms,
price_gte,
price_lte,
offset,
address_guid=self._geo.address_guid,
aids=self._geo.aids,
)
logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
cookies=self._cookies,
fetch_mode="subresource",
)
url,
origin=self._geo.origin_url,
referer=self._geo.origin_url,
cookies=self._cookies,
fetch_mode="subresource",
)
try:
data = _extract_json(html)
except DomClickBlockedError:
@ -652,22 +778,23 @@ class DomClickScraper(BaseScraper):
break
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
"""Гео-гард по bbox профиля (и по имени региона, если оно задано).
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
У ЕКБ aids=20561 и так даёт чистую выдачу, а сверка имени остаётся
defensive-проверкой. Вне 66 profile.region_name обязан быть None:
offerRegionName там ненадёжен офферы Новой Москвы приходят как
«г. Говорово», и сверка по имени вырезала бы законную выдачу.
"""
region = item.get("offerRegionName", "")
if region != _EKB_REGION_NAME:
expected_region = self._geo.region_name
if expected_region is not None and item.get("offerRegionName", "") != expected_region:
return False
loc = item.get("location") or {}
lat = loc.get("lat")
lon = loc.get("lon")
if lat is None or lon is None:
return False
return (
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
)
lat_min, lat_max, lon_min, lon_max = self._geo.bbox
return lat_min <= float(lat) <= lat_max and lon_min <= float(lon) <= lon_max
def _map_item(
self, item: dict[str, Any], *, force_rooms: int | None = None
@ -844,6 +971,7 @@ async def scrape_domclick_city(
pages: int = 100,
*,
delay_provider: Callable[[str], float] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks.
@ -852,5 +980,7 @@ async def scrape_domclick_city(
import asyncio
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
"""
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
async with DomClickScraper(
config, delay_provider=delay_provider, geo_profile=geo_profile
) as scraper:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)

View file

@ -80,18 +80,37 @@ python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
# 3) продолжить прерванный прогон по сохранённому плану коридоров
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` и полный проход **не начинает**.
Дефолт — `--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 % джиттера — сознательно совпадает с прод-расписаниями
`request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
от `--platform`/`--region`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>`, а при
`--platform domclick --region 50``mo-serp-domclick-<UTC>`: платформа/регион в имени,
чтобы прогоны разных площадок и регионов не затирали друг другу план/CSV — регион в
таблице `msk_raw.domclick_cards` иначе не выражается, отдельной колонки под него нет),
`--out-dir`,
`--ssh-host/--container/--db-user/--db-name`.
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —

View file

@ -6,9 +6,19 @@
(подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) см.
класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
билдер URL коридора и своя целевая таблица в msk_raw.
Платформа выбирается ключом --platform {avito,cian,yandex,domclick} (дефолт
avito) см. класс PlatformAdapter ниже. У каждой платформы свой потолок
пагинации, свой билдер 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.
"""
@ -153,7 +163,9 @@ YANDEX_MAX_PAGES = 25
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
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 — один и тот
# же 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"
DEFAULT_DOMCLICK_BASE_URL = (
"https://bff-search-web.domclick.ru/api/offers/v1"
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
"&sort=qi&sort_dir=desc&limit=20&offset=0"
)
# GUID Московской области проверен живым запросом 12.09: тот же listing-эндпойнт
# с этим address отдаёт result.pagination.total = 17572, aids тоже не нужен.
MO_DOMCLICK_GUID = "9930cc20-32c6-4f6f-a55e-cd67086c5171"
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 отдаёт полную
# страницу (20 items), offset=2000 отвечает HTTP 400
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
# {"statusCode":400,"error":"Bad Request"}. Одинаково и для Москвы, и для
# области (проверено на обоих GUID) — значит жёсткий потолок пагинации общий:
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
DOMCLICK_PAGE_SIZE = 20
DOMCLICK_MAX_PAGES = 100
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
# всю московскую выдачу.
# bbox — единственный надёжный гео-гард для DomClick, отдельно для Москвы (с
# ТиНАО) и для Московской области. offerRegionName использовать НЕЛЬЗЯ: часть
# офферов Новой Москвы приходит с именами вида "г. Говорово", а не "Москва" —
# гард по имени региона молча вырезал бы легитимные лоты. scraper._is_geo_ok()
# кита сюда тоже не подходит: он захардкожен на offerRegionName ==
# "Екатеринбург" и отбросил бы буквально всю выдачу обоих регионов.
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
_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_PROBE_START = 8_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"])
except (KeyError, TypeError, ValueError):
continue
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
lat_min, lat_max, lon_min, lon_max = _DOMCLICK_BBOX
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
continue
lot = scraper._map_item(item)
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
# Гвард региона 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
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
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",
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,
help="базовый URL выдачи (дефолт зависит от --platform)")
help="базовый URL выдачи (дефолт зависит от --platform/--region)")
p.add_argument("--measure", type=int, default=100, metavar="N",
help="режим замера: не больше N загрузок страниц (дефолт 100)")
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-name", default="tradein")
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:
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:
args.target_count = ADAPTERS[args.platform].default_target
if args.batch_id is None:
args.batch_id = (
f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
prefix = (
"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):
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:
p.error("--measure должен быть >= 1")
return args