Compare commits

...

152 commits

Author SHA1 Message Date
bot-backend
3f87873d7b Merge remote-tracking branch 'origin/main' into fix/2656-anchor-ratio-freshness
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m6s
Ветка отстала от main на 151 коммит. Текстовых конфликтов нет, семантический — один.

`test_imv_card_survives_when_headline_suppressed_and_anchor_absent` добывал нулевой
headline тонкой выборкой (n=3 < HEADLINE_LISTINGS_MIN_N) — гейт достаточности его
обнулял. #oblast-F (#2823, смержен 2026-08-09) это поведение СНЯЛ: тонкая выборка
больше не обнуляет headline, только помечает низкую надёжность. Тест падал на
собственной предпосылке, а не на щели, которую стережёт. Нулевой headline берётся
отсутствием аналогов (n=0) — единственное оставшееся нулевое состояние; сама щель
(«тир добыт, якоря нет, headline нулевой → карточка IMV не должна исчезнуть») от
этого не изменилась. Фальсификация: возврат старого условия display-блока
(`anchor_tier is not None and ...`) снова роняет тест.

Прогон полного набора на смерженном дереве: 4248 passed, 18 skipped.
2026-08-12 17:49:48 +05:00
4d31a0ee82 fix(tradein/scraper): пустой пул прокси перестаёт стирать чекпоинт полного обхода (#2834)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-12 12:18:03 +00:00
e17687aed7 fix(tradein/scrapers): убрать оставшиеся обходы пула прокси (#2830) (#2833)
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 3m13s
Deploy Trade-In / build-backend (push) Successful in 1m46s
Deploy Trade-In / deploy (push) Successful in 1m48s
2026-08-12 10:53:36 +00:00
f2cbd76ae0 fix(tradein/scrapers): egress по источнику из пула с учётом банов, а не статичный env (#2831)
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 3m12s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-08-11 06:29:48 +00:00
29db137375 fix(tradein/admin): развести исходы проверки кук — бан не равен «плохим кукам» (#2829)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m9s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-08-11 06:08:16 +00:00
55c25416f6 fix(tradein/estimate): не перетирать дату обращения при оживлении оценки (#2828)
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 3m10s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m48s
2026-08-11 05:52:05 +00:00
82d8db9f42 fix(tradein/estimate): оживлять мёртвые сохранённые оценки при открытии (#2826)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m11s
Deploy Trade-In / test (push) Successful in 3m42s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 6m59s
2026-08-11 04:15:40 +00:00
0b84f0b661 fix(tradein/estimate): уверенность не может противоречить надёжности выборки (#2825)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 2m1s
2026-08-10 16:21:31 +00:00
8423af5dd5 feat(tradein): версионирование продукта — единый источник, подвал, PDF, /versions (#2824)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-10 16:00:54 +00:00
9d9457f67d fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать (#2823)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m30s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 7m24s
Deploy Trade-In / deploy (push) Successful in 1m25s
2026-08-10 15:42:22 +00:00
74344f7b8b docs(tradein/scraper): 42 прогона, а не 52 — в замере считались пары «прогон × фаза» (#2700) (#2822)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m36s
2026-08-10 11:49:27 +00:00
20ec6a5d33 fix(tradein/cian): 403 снимает узел с выдачи, а не гаснет в return None (#2700) (#2821)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m10s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m4s
2026-08-10 11:29:19 +00:00
0ed0140c9e fix(tradein/dedup): остаток схлопывания домов становится измеряемым числом, а не оценкой (#2690) (#2820)
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 3m11s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m57s
2026-08-10 11:18:19 +00:00
1307d55da6 fix(site-finder): метка источника весов выводится из результата резолва, а не из входа (#2811) (#2817)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
Deploy / build-worker (push) Successful in 3m0s
Deploy / deploy (push) Successful in 1m30s
2026-08-10 10:34:39 +00:00
405d2f2eec fix(tradein/db): разовая чистка 1123 адресов Авито с приклеенным рейтингом (#2814) (#2818)
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 3m9s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 1m42s
2026-08-10 10:22:33 +00:00
5ee4126ed0 fix(tradein/avito): рейтинг дома перестаёт уезжать в адрес объявления (#2814) (#2815)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m9s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m21s
2026-08-10 09:47:15 +00:00
a227877905 fix(tradein/scraper): прогон, у которого отказал каждый якорь, перестаёт быть успехом (#2625) (#2813)
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 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m35s
2026-08-10 09:36:23 +00:00
bot-backend
ab5c4b86cd fix(tradein/avito): рейтинг дома перестаёт уезжать в адрес объявления (#2814)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Авито с 27.07 рендерит рейтинг и число отзывов внутри того же <p> в
data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
отзыва». Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из
1 123 нет координат — доля 100%, 711 из них геокодер уже пробовал. Контроль в тех
же данных: у чистых адресов координаты есть у 4 766 из 5 715 (83%).

Строка без geom молча выпадает из comp-пула: Tier W отбирает через ST_DWithin, а
NULL не проходит предикат и нигде не считается. 2 446 из 3 270 безкоординатных
попадают в свежий пул аналогов — 12.7% аналогов невидимы радиусному поиску.

Режем по «·», за которой идёт ЦИФРА (рейтинг «·4,9», счётчик «·2 отзыва»). По
любой «·» нельзя: разделитель района пишется «, 59 · р-н Академический» — за
точкой буква, и этот хвост _deglue_house_marker намеренно сохраняет (#1773).
Тест проверяет обе стороны плюс три прежних хвоста (CSS, метро, «от N мин.»).

Чинит только новые вставки: base.py пишет address = COALESCE(listings.address,
EXCLUDED.address), адрес при конфликте не перезаписывается осознанно (#2777).
Бэкфилл 1 123 существующих строк вынесен в #2814 для database-expert.
2026-08-10 14:32:25 +05:00
272abac4d2 fix(tradein/matching): город развёртки доезжает до стража Tier-2a — межгородские склейки домов (#2777) (#2808)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 2m3s
2026-08-10 08:51:14 +00:00
72472c2783 fix(tradein/newbuilding): счётчики записи различают вставку и обновление (#2807) (#2809)
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 / 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) Has been cancelled
2026-08-10 08:50:56 +00:00
84a65d40dd fix(site-finder): §4.1 «Применить» действительно применяет веса POI (#2790) (#2810)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m21s
Deploy / deploy (push) Successful in 1m13s
2026-08-10 08:39:59 +00:00
12c189ac27 fix(tradein/scraper): сводка «что сейчас не собирает» — лестница вех не видит стрик 0 (#2670) (#2806)
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 3m22s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 2m7s
2026-08-10 08:29:26 +00:00
27e199e370 fix(tradein/proxy): упавшая проба присваивала себе бан боевого сбора (#2800) (#2805)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m12s
2026-08-09 20:13:10 +00:00
9cd6db023b fix(tradein/cian): Циан переименовал MFE карточки ЖК — читать оба имени (#2767) (#2804)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 2m11s
2026-08-09 18:41:02 +00:00
08bb9d6549 fix(tradein/proxy): проба спрашивает каждую площадку, вердикт пишется на пару «узел × источник» (#2800 B) (#2803)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m0s
2026-08-09 18:28:37 +00:00
7cd8c63b89 fix(tradein/proxy): узел, зарезервированный за Домкликом, до Домклика не доходит (#2800 A) (#2802)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 6m36s
2026-08-09 18:15:55 +00:00
f45ce3be2d fix(tradein/cian): банить узел только когда состояния НЕТ, а не по слову «captcha» (#2767) (#2801)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 2m32s
Deploy Trade-In / deploy (push) Successful in 2m26s
2026-08-09 18:04:01 +00:00
687bd38322 chore(tradein/db): снести DEPRECATED-колонку listings.ceiling_height (#2799)
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 3m11s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 3m26s
2026-08-09 17:51:15 +00:00
f3bcb1a25f fix(tradein/cian): обогащение ЖК падало не на разметке, а на сожжённом узле (#2767) (#2798)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m33s
2026-08-09 17:38:40 +00:00
f1f2bca2e9 fix(tradein/deactivate): TTL не снимает объявления по порогу ниже собственного цикла обхода (#2797)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m45s
2026-08-09 17:26:18 +00:00
7b36f86ea6 fix(tradein/domclick): свип ходит через прокси-пул, а отказ узла не обнуляет прогон (#2657) (#2796)
Some checks failed
Deploy Trade-In / deploy (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 / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-08-09 17:21:49 +00:00
45924021a7 chore(tradein/db): вернуть снос дубля индекса expires_at — теперь с lock_timeout (#2795)
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 3m7s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 6m35s
2026-08-09 17:10:18 +00:00
ef82172bd1 Merge pull request 'feat(tradein/payments): оплаченный отчёт хранится год — retain_until и предохранители в задаче удаления' (#2754) from feat/tradein-paid-retention into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m18s
Deploy Trade-In / build-frontend (push) Successful in 3m29s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Failing after 1m3s
2026-08-07 13:08:45 +00:00
bot-backend
5ce95a28a8 fix(tradein/payments): SET LOCAL lock_timeout в миграции 240 (gate threshold — артефакт)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 4m1s
check-migration-lock-timeout.py требует lock_timeout только для NN >= 250 в
tradein — порог назначен по номеру аварийной миграции 250, которую затем
сняли с деплоя (#2792). Фактический максимум применённого на main — 239,
то есть весь диапазон 240-249 гейтом не проверяется вообще ("проверено
новых миграций: 0" = не проверено ни одного файла, не "все чисты"). Функция
scan() из самого гейта, прогнанная напрямую без порогового отсечения,
помечает ALTER TABLE в этом файле как блокирующий DDL без lock_timeout.

trade_in_estimates — самая горячая таблица стека (история, история
сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались открытые
транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка встаёт в
очередь перед новыми запросами приложения. DDL на 1058 строках мгновенный —
риск не в исполнении, а в ожидании чужой блокировки.

Добавлено SET LOCAL lock_timeout = '5s' сразу после BEGIN + объяснение в
шапке файла, почему оно здесь при том что гейт формально не требует —
чтобы не убрали как "лишнее". Порог гейта не трогаю — отдельный issue.
2026-08-07 15:59:28 +03:00
bot-backend
7431615415 Merge remote-tracking branch 'forgejo/main' into feat/tradein-paid-retention
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m57s
# Conflicts:
#	tradein-mvp/backend/data/sql/_manifest_applied.txt
#	tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts
2026-08-07 15:40:52 +03:00
bot-backend
e6591a450a fix(tradein/payments): миграция 234 → 240 — номер снова занят на main
main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql
(0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее
дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и
всем открытым веткам).

Переименован файл + обновлены все 7 упоминаний "migration 234"
(_manifest_applied.txt, config.py, schemas/trade_in.py,
purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts,
types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по
имени файла.
2026-08-07 15:31:01 +03:00
482deb4864 fix(migrations): закрепить lock_timeout для блокирующего DDL и ловить невалидные индексы (#2791)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 43s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy / build-frontend (push) Successful in 47s
Deploy / build-worker (push) Successful in 49s
Deploy / build-backend (push) Successful in 50s
Deploy / deploy (push) Successful in 1m12s
Deploy Trade-In / test (push) Successful in 3m25s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 1m50s
2026-08-07 11:21:28 +00:00
29f1000228 revert(tradein/db): снять 250 с деплоя — очередь за локом блокирует пайплайн (#2792)
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 37s
Deploy Trade-In / test (push) Successful in 3m13s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy Trade-In / deploy (push) Successful in 2m16s
2026-08-07 11:01:31 +00:00
e4680082ea test(ci): развести «нагрузку не создать» и «защита сломана» в двух флапающих гейтах (#2783) (#2789)
Some checks failed
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 40s
Deploy / build-backend (push) Successful in 42s
Deploy / build-worker (push) Successful in 43s
Deploy / deploy (push) Successful in 1m39s
Deploy Trade-In / test (push) Successful in 3m24s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Failing after 7m47s
2026-08-07 10:35:15 +00:00
209e4e145f fix(site-finder): вернуть доступ к профилям весов — владелец из сессии (#2782) (#2788)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m43s
Deploy / deploy (push) Successful in 1m23s
2026-08-07 10:22:55 +00:00
c3a35a31d4 fix(ptica): скраб ПДн перестаёт утекать то, что защищает + проводка проверяется поведением (#2753) (#2787)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m55s
Deploy / build-worker (push) Successful in 4m28s
Deploy / build-frontend (push) Successful in 5m36s
Deploy / deploy (push) Successful in 2m1s
2026-08-07 10:11:36 +00:00
9de690ad4f chore(tradein/frontend): удалить остаточный слой фикстур (#2755) (#2785)
Some checks failed
Deploy / changes (push) Successful in 18s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 24s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m21s
Deploy Trade-In / test (push) Successful in 3m52s
Deploy / build-frontend (push) Successful in 4m39s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy / deploy (push) Successful in 1m27s
Deploy Trade-In / deploy (push) Failing after 10m5s
2026-08-07 10:00:48 +00:00
90c3e7e490 test(scrapers/krt): вернуть в прогон проверку многоблочной страницы (#2778) (#2781)
All checks were successful
Deploy / changes (push) Successful in 15s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 45s
Deploy / build-worker (push) Successful in 44s
Deploy / deploy (push) Successful in 1m37s
2026-08-07 09:48:52 +00:00
1f036e94d1 refactor(security): убрать мёртвую проверку админского токена (#2775) (#2776)
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m17s
Deploy / build-worker (push) Successful in 4m34s
Deploy / deploy (push) Successful in 1m44s
2026-08-07 09:30:23 +00:00
a9096f125a fix(tradein/db): снять побайтовый дубль индекса на trade_in_estimates(expires_at) (#2752) (#2784)
Some checks failed
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) Failing after 4m14s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-08-07 09:30:14 +00:00
306fd82836 fix(tradein): свести высоту потолков к одной колонке ceiling_height_m (#2779)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 2m26s
2026-08-07 09:18:48 +00:00
de4b2a4ae5 fix(tradein/houses): вернуть координаты объявлений в дом, когда объявления согласны (#2771) (#2780)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m19s
Deploy Trade-In / build-frontend (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-07 09:08:06 +00:00
05bc321728 build(tradein/frontend): состав зависимостей перестаёт зависеть от даты сборки (#2770) (#2772)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-07 09:07:58 +00:00
69ba6002da fix(tradein): поштучный процент гаснет вместе со сводным, когда причина — сама пара (#2672) (#2774)
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 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m1s
2026-08-07 08:56:43 +00:00
339713c1aa fix(tradein/yandex): просмотры режутся по разделителю тысяч + Циан молчит о непонятой фразе (#2669) (#2773)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m45s
2026-08-07 08:44:48 +00:00
5046ac7b4e fix(tradein/cian): показать, ЧТО пришло вместо состояния ЖК-страницы, и честно закрывать нулевой прогон (#2767) (#2768)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m11s
2026-08-07 08:06:25 +00:00
a034d7a1c1 test(tradein/frontend): первые проверки поведения — пустое поле, погашенное число, отказ 429 (#2766) (#2769)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / deploy (push) Successful in 57s
2026-08-07 07:55:37 +00:00
0de22f4bc9 fix(tradein/scraper): диагноз бана перестаёт назначаться по умолчанию (#2764) (#2765)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m59s
Deploy Trade-In / build-backend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 2m14s
2026-08-06 23:17:59 +00:00
8def690b00 fix(devops): дать trade-in связность с redis, потом уже задать REDIS_URL (#2709) (#2763)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 10s
Deploy / build-frontend (push) Successful in 39s
Deploy Trade-In / build-browser (push) Successful in 38s
Deploy / build-backend (push) Successful in 42s
Deploy Trade-In / build-frontend (push) Successful in 40s
Deploy / build-worker (push) Successful in 42s
Deploy / deploy (push) Successful in 1m18s
Deploy Trade-In / test (push) Successful in 3m15s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-08-06 22:37:25 +00:00
896243cf3f fix(devops): потолок логов основного стека — journald, как у trade-in (#2761) (#2762)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Successful in 36s
Deploy / build-backend (push) Successful in 37s
Deploy / build-frontend (push) Successful in 36s
Deploy / deploy (push) Successful in 1m30s
2026-08-06 22:29:27 +00:00
604ce14858 fix(tradein/devops): починить окно времени в инструкции чтения журнала (#2741) (#2760)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 28s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 25s
Deploy Trade-In / deploy (push) Successful in 1m36s
2026-08-06 21:53:02 +00:00
d605f0b938 ci: гейт против публикации занятых на VPS портов в workflow (#2757) (#2759) 2026-08-06 21:40:05 +00:00
b1498a9ca7 fix(tradein/devops): логи в journald — переживают пересоздание контейнера (#2741) (#2758)
All checks were successful
Deploy Trade-In / changes (push) Successful in 21s
Deploy Trade-In / build-browser (push) Successful in 50s
Deploy Trade-In / build-frontend (push) Successful in 51s
Deploy Trade-In / test (push) Successful in 3m23s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m29s
2026-08-06 21:24:57 +00:00
79f7b8fff3 ci(tradein): снять последний deselect — он пережил починку своего теста (#2756)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 27s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-08-06 20:53:55 +00:00
bot-backend
48664dfe0e fix(tradein/payments): pre-flight должен ловить аномалию, не штатное состояние (review PR #2754)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m49s
Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).

- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
  теперь считает только реальную аномалию (retain_until не проставлен, а
  платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
  под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
  позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
  оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
  джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
  (PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
  добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
  образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
  удаление — задача на проде выключена и ни разу не запускалась, текст
  теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
  на краткое изложение сути в комментарии + ссылку на PR #2754.
2026-08-06 22:49:09 +03:00
76c3f5c110 chore(frontend): фикстуры макета не по умолчанию + удаление осиротевших компонентов (#2747)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m9s
МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми).

Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария.

Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет.
2026-08-06 18:59:50 +00:00
bot-backend
5ff06d25b4 feat(tradein/payments): оплаченный отчёт хранится год — retain_until и предохранители в задаче удаления
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m4s
CI Trade-In / backend-tests (pull_request) Successful in 3m51s
Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки
WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция
будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен
жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж
безвозвратно снёс бы оплаченное.

Делается ДО платёжного кода, которого в этом PR нет:
- migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено,
  бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат.
- config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник
  "12 месяцев" для будущей оферты/экрана/SQL продления.
- Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше
  SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по
  тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при
  годовом хранении).
- purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное
  не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка +
  pre-flight, который считает оплаченных кандидатов и падает в mark_failed
  ДО первого батча при ненулевом результате.
- PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL;
  "ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут.
- Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at).
- privacy-страница: убрано устаревшее "механизма удаления нет" (неправда
  после #2547), добавлен срок 12 месяцев для оплаченных отчётов.

Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours,
_DELETE_EXPIRED_LEADS_SQL не тронуты.
2026-08-06 21:48:06 +03:00
ac870b0c58 fix(ptica): скраб ПДн перед отправкой в мониторинг + честная подпись НДС в отчётах (#2457) (#2749)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-worker (push) Successful in 3m33s
Deploy / deploy (push) Successful in 1m34s
PII scrub wired to BOTH channels (before_send AND before_send_transaction) in app/main.py and app/workers/celery_app.py.

Before: Celery had no before_send at all, and before_send_transaction was URL-only while glitchtip_traces_sample_rate defaults to 0.05 - the Starlette integration puts request.data on transaction scope exactly as on error scope, so lead bodies leaked through the transaction channel.

Keys: full MERA set (client_name/client_phone/client_email/phone/email/name) plus company/message from PilotRequestInput.

VAT label: 'NDS (parking)' -> 'NDS (parking + commercial)' in DOCX/HTML exporters - financial.py computes VAT over parking AND non-residential.
2026-08-06 18:47:35 +00:00
0535fa209a chore(tradein/db): уборка временных таблиц, дублей индексов и звёздочки в v_data_quality (#2746)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m2s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 2m3s
Миграции 222 (DROP 2 tmp-таблиц + 5 строгих дублей индексов + v_data_quality с явным списком колонок) и 225 (CREATE INDEX CONCURRENTLY под FK listing_source_snapshots.run_id).

Проверено на прод-БД в BEGIN…ROLLBACK и на чистой схеме (полный bootstrap 225 миграций в одноразовом контейнере).
2026-08-06 18:27:39 +00:00
2496670859 fix(tests): объявить 4 live-DB проверки приватности в skip_allowlist (#2750)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 30s
Deploy Trade-In / build-frontend (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Successful in 37s
Deploy Trade-In / deploy (push) Successful in 1m53s
2026-08-06 18:20:04 +00:00
d3d7464267 fix(tradein/deploy): скрапер пересобирается вместе с бэкендом + сверка образов после деплоя (#2679) (#2680)
Some checks failed
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-frontend (push) Successful in 42s
Deploy Trade-In / build-browser (push) Successful in 39s
Deploy Trade-In / test (push) Failing after 3m15s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
2026-08-06 17:53:30 +00:00
eb98852ddf ci: пропуск теста обязан назвать себя — иначе прогон красный (#2745)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
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) Has been cancelled
Deploy / build-backend (push) Successful in 46s
Deploy / build-worker (push) Successful in 46s
Deploy / deploy (push) Successful in 1m18s
2026-08-06 17:52:56 +00:00
d87c9fa191 Merge pull request 'feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу — этап 4 из 8' (#2547) from feat/mera-b2c-privacy into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m1s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m36s
Deep review APPROVE (deep-code-reviewer, 2026-08-06).

HIGH закрыт: purge trade_in_estimates ограничен `created_by IS NULL` — 129 B2C-строк
под удаление, 911 пилотских защищены (сверено на проде: 1040 просрочено всего).
MEDIUM закрыт: телефон в erase_person_data сравнивается по каноническому РФ-виду
с обеих сторон (8→7 при 11 цифрах, без усечения до последних 10).

Проверено: миграции 229/231 прогнаны на прод-схеме в BEGIN…ROLLBACK, тело дважды —
идемпотентны; CHECK consent отбивает false; NN свободны на main и в открытых PR;
consent-гейт недостижим для B2B (session-cookie инжектит X-Authenticated-User);
адрес не попадает в БД раньше согласия ни одним путём.
Гейт: CI Trade-In / backend-tests success 3m9s на 4ee4d4b8.
2026-08-06 17:04:27 +00:00
bot-backend
4ee4d4b8e2 fix(tradein/privacy): нормализация телефона к каноническому РФ-виду при erasure (#2547)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m9s
Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление
форматирования не закрывало разрыв, который сам ревьюер привёл в примере --
"+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки
(79991234567 vs 89991234567, différent на первой цифре) -- классическая для
РФ путаница 8/+7 trunk-префикса.

_ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping
получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное
тождество для российской нумерации, не эвристика (обсуждали: усечение до
"последних 10 цифр" риск-скориальнее -- склеивает номера разных стран,
удаление чужих данных хуже неудаления своих). Оба вызова
(_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из
статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один
телефон не попадает в текст запроса напрямую.

Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится
и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный
counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая
версия фикса) для этой пары находит 0 строк. Negative control: номер,
отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного
совпадения = удаления чужих данных).
2026-08-06 19:59:34 +03:00
a091da1aa8 fix(tests): починить и вычистить проверки, которые не исполнялись ни разу (#2744)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 32s
Deploy / build-worker (push) Successful in 32s
Deploy / deploy (push) Successful in 1m8s
Deploy Trade-In / test (push) Successful in 3m5s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 1m5s
2026-08-06 16:57:28 +00:00
bot-backend
881730bf20 fix(tradein/privacy): не удалять B2B-строки в purge + находить телефон в другом формате при erasure (#2547)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m6s
Deep-review HIGH: purge_expired_trade_in_data удалял trade_in_estimates по
expires_at без разбора B2B/B2C -- эта колонка TTL ссылки/PDF, а не срок
хранения строки, и её единообразно проставляет каждой оценке estimator.py.
Прод-аудит: 1040/1057 строк просрочены, 911 из них у пилотов (admin,
kopylov, brusnika, praktika, pilottest, admintest, user1). DELETE теперь
ограничен created_by IS NULL -- ровно анонимная B2C-популяция (129 строк).
Докстринг миграции 231 переписан: явные цифры аудита, необратимость,
чек-лист (свежий SELECT count + один supervised прогон) перед enable.

Deep-review MEDIUM: erase_person_data сравнивал phone точным =, а lead.py
сохраняет номер как прислали (без нормализации, намеренно) -- разное
форматирование одного и того же номера не находилось, 0 строк удалялось,
но ответ всё равно был 200 "данные удалены". Сравнение переведено на
regexp_replace(x, '\D', '', 'g') с обеих сторон.

Оба фикса проверены живьём (throwaway Postgres 16 в docker, вне обычного
mock-only CI-лейна): без гварда пилотская строка удалялась вместе с
анонимной; без нормализации разноформатный телефон не находился. С
фиксами -- находит/не находит ровно как задумано. Добавлены self-skipping
live-DB тесты (паттерн test_house_dedup_merge.py::_live_session) плюс
статические SQL-guard тесты.
2026-08-06 19:49:13 +03:00
9f51c98ff4 fix(tradein/matching): listing_sources датируется построчно, а не стартом транзакции (#2731) (#2743)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 6m44s
2026-08-06 16:35:29 +00:00
2d62b87cf3 feat(tradein/payments): схема БД, конфиг и kill-switch платёжного контура (#2732)
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 / changes (push) Successful in 10s
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
Миграция 233_payments.sql (payments / payment_notifications / payment_entitlements), поля TBANK_* и PAYMENTS_ENABLED, fail-fast в lifespan. Бизнес-логики нет, контур выключен по умолчанию.

По итогам deep review: UNIQUE NULLS NOT DISTINCT на обоих дедуп-ключах, payment_notifications.processed_at, payments.pd_erased_at, payments_lead_idx, CHECK на длину order_id, статусы сверены с официальной openapi.yaml (Confirm-2, v1.24).
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-08-06 16:33:27 +00:00
91423e0b53 fix(tradein/scrapers): метка наблюдения — время строки, а не старта транзакции (#2731) (#2742)
Some checks failed
Deploy Trade-In / changes (push) Successful in 24s
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 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Has been cancelled
2026-08-06 16:19:56 +00:00
a398b17e6d Merge pull request 'fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе' (#2737) from feat/tradein-payments-notification-hardening into main
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 3m14s
Deploy Trade-In / build-backend (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 1m14s
2026-08-06 16:10:29 +00:00
bot-backend
3ee99efaa4 chore(tradein/privacy): перенумерация 231 и merge main - коллизия префикса (#2547)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m21s
2026-08-06 19:10:27 +03:00
bot-backend
b93bee5393 Merge remote-tracking branch 'forgejo/main' into pr2547-privacy-work 2026-08-06 19:02:18 +03:00
c86a5378ef feat(tradein/houses): журнал слияний домов — слияние стало обратимым (#2740)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-08-06 15:41:20 +00:00
0dc6f12630 fix(tradein/avito): серия отказов обрывается и называет причину, а не выедает бюджет (#2674) (#2739)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m3s
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / deploy (push) Successful in 1m31s
2026-08-06 15:29:48 +00:00
4aec49f7fb fix(tradein/yandex): в очередь обогащения не берём то, что парсер отвергает до сети (#2674) (#2738)
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 3m7s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m13s
2026-08-06 15:22:21 +00:00
f0968c8513 test(tradein/auth): правило про синхронную сверку — сторожем, а не комментарием (#2715) (#2735)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 3m0s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m24s
2026-08-06 14:35:32 +00:00
90e328df66 fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715) (#2734)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m0s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-06 14:27:26 +00:00
bot-backend
dccd2d4272 chore(tradein/privacy): перенумерация миграций и merge main - разблокировка PR (#2547)
192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.

Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
2026-08-06 15:56:02 +03:00
64a7975549 fix(tradein/proxy): проба узла ходит браузерным трактом, вердикт живёт отдельно от HTTP-пробы (#2723) (#2736)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m54s
2026-08-06 12:50:24 +00:00
bot-backend
00d1f78668 fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
2026-08-06 15:48:57 +03:00
bot-backend
6820337da0 Merge remote-tracking branch 'forgejo/main' into pr2547-privacy-work
# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
#	tradein-mvp/backend/app/services/product_handlers.py
2026-08-06 15:47:03 +03:00
a32ccabd0d feat(tradein/payments): подпись Token, клиент Т-Банка и сборка чека (#2733)
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 3m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy Trade-In / deploy (push) Successful in 1m24s
PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет.
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-08-06 12:36:57 +00:00
2a1577738a chore(tradein/db): DROP listings_snapshots.position_in_serp — шаг 2 из 2 (#2697) (#2728)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m59s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m22s
2026-08-06 11:41:39 +00:00
5f71fc670f fix(tradein/scraper): сигнал живости из середины батча — живые прогоны перестают числиться зависшими (#2725) (#2727)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m59s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m36s
2026-08-06 11:30:52 +00:00
02b256288d fix(tradein): молча задеселекченный тест починен, deselect снят (#2722) (#2729)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-06 11:30:42 +00:00
3527d2b608 ci(tradein): тесты браузерного сайдкара наконец бегут в пайплайне (#2722) (#2726) 2026-08-06 11:19:50 +00:00
3231233b73 fix(tradein/browser): четыре теста сайдкара догоняют код — тест отстал, не код сломан (#2722) (#2724)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 58s
2026-08-06 11:13:17 +00:00
6cf9172d96 fix(tradein/auth): доля слотов сверки пароля на адрес — потолок перестаёт бить по своим (#2714) (#2717)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m0s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m9s
2026-08-06 10:42:39 +00:00
a52b9d07cf fix(tradein/browser): повтор evaluate на той же странице — гонка случается ПОСЛЕ load (#2676) (#2721)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m8s
Deploy Trade-In / deploy (push) Successful in 1m33s
2026-08-06 10:18:14 +00:00
b8afe13ae4 fix(tradein/scraper): длинная серия неудач перестаёт замолкать, оборванный прогон — называться успехом (#2670) (#2720)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m57s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m40s
2026-08-06 10:06:58 +00:00
0144092856 fix(tradein/houses): camelCase-типы домов приводятся к канону у источника (#2678) (#2719)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m58s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m5s
2026-08-06 09:55:54 +00:00
663a831775 fix(tradein/scraper): отметки времени прогона перестают замерзать в его же транзакции (#2702) (#2718)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-06 09:55:50 +00:00
1ba1a55770 fix(tradein/browser): гонка «execution context destroyed» — восстановимая, ретрай без relaunch (#2676) (#2716)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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) Successful in 2m8s
Deploy Trade-In / deploy (push) Successful in 1m35s
2026-08-06 09:44:23 +00:00
9d8114158b fix(tradein/auth): bcrypt вне событийного цикла + настоящий потолок темпа логинов (#2665) (#2712)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-08-06 09:02:10 +00:00
627e163103 fix(tradein): TTL-деактивация не исполняется, пока сбор по источнику лежит (#2659) (#2710)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m56s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m16s
2026-08-06 08:40:41 +00:00
396d8a74b6 fix(tradein/scraper): сторож «ноль результатов» перестаёт читать «не измерено» как ноль (#2703) (#2713)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m56s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m49s
2026-08-06 08:28:59 +00:00
9e5e9fca08 fix(tradein/scraper): статус «забанен» перестаёт выдавать наш сбой за чужой (#2686) (#2711)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m58s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-06 08:18:26 +00:00
cb64796b44 fix(tradein/scraper): дневной снимок узнаёт свой прогон (#2701) (#2707)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m55s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m21s
2026-08-06 07:00:17 +00:00
c66e18cd13 fix(tradein/imv): домовая оценка ходит в сайдкар через пул, а не через чужой env-прокси (#2698) (#2708)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m0s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 1m38s
2026-08-06 06:52:15 +00:00
90c193f898 fix(tradein): «медианный торг» гаснет на псевдорепликах и на неправдоподобном минусе (#2672) (#2706)
Some checks failed
Deploy Trade-In / deploy (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) Successful in 2m58s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-08-06 06:48:47 +00:00
bc5233de15 fix(tradein/cian): просмотры перестают теряться на int() по фразе (#2669) (#2705)
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 / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-08-06 06:46:18 +00:00
58dcd76d2e fix(tradein/domclick): sale_type перестаёт быть третьим словарём (#2674) (#2696)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m51s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-06 06:11:45 +00:00
44470f7310 fix(tradein/scraper): detail-backfill с нулём обогащений перестаёт называться успехом (#2674) (#2695)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m57s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-06 05:59:37 +00:00
27272762ef fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674)
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 2m57s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-08-06 05:49:48 +00:00
a7ca0e9ee8 fix(tradein): вернуть фотографии подсказок Avito IMV задним числом (#2674) (#2693)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m54s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m7s
2026-08-06 05:40:02 +00:00
e1c26c212a chore(tradein): догнать _manifest_applied.txt до факта прода (31 имя) (#2692)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m54s
Deploy Trade-In / build-backend (push) Successful in 28s
Deploy Trade-In / deploy (push) Successful in 1m6s
2026-08-06 00:54:41 +00:00
fb5ec56a54 Merge pull request 'chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674)' (#2689) from chore/2674-dead-code-sweep into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m10s
Deploy Trade-In / test (push) Successful in 3m1s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m19s
2026-08-06 00:37:05 +00:00
3d38d589d0 fix(tradein): панорама для страниц без истории, точные счётчики, окно без гонки (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m0s
Правки по ревью PR #2689.

Признак панорамы был недостижим примерно для десятой части страниц. Вызов стоял
после раннего возврата по пустой истории размещений, поэтому идеально отрисованная
страница без единого объявления до записи не доходила: на проде 1519 оценок против
1360 домов с историей. Резолв дома и запись панорамы подняты выше возврата — гейт
честности не тронут. Цена: match_or_create_house теперь вызывается и для таких
страниц (может создать дом), но это тот же вызов с тем же адресом, который уже
отрабатывает на остальных 90%.

Числа в комментариях к схеме были оценками планировщика, а не точным счётом:
listings 142 569 против реальных 93 408 (раздув мёртвыми кортежами на 53%),
house_sources 46 813 против 49 502. На безопасность удаления это не влияло — нули
там точные, — но оценка уезжала в постоянный комментарий к схеме, в PR, тезис
которого «каждое утверждение несёт число с прода». Пересчитано точным count(*).

Окно расписания ДОМ.РФ 03:00-04:00 совпадало с refresh_search_matview — то есть
ровно с тем заданием, которое переносит year_built в поиск. Планировщик берёт
случайный момент внутри окна и гоняет источники параллельно, так что порядок был
подбрасыванием монеты. Перенесено на 01:00-02:00; в комментарии честно сказано, что
гарантии всё равно нет и при аномально долгом прогоне возможно отставание на цикл.

Тесты, адресовавшие вызовы по позиции (db.execute.call_args_list[0]), переведены на
фильтр по SQL — это и была причина, по которой добавление второго execute ломало
шесть чужих тестов разом. То же для side_effect в тесте отката батча: исключение
доставалось бы записи панорамы, которая свои ошибки глотает, и тест молча проверял
бы не тот путь. В test_save_history_items_inserts_each возвращено утверждение о
числе коммитов (было удалено вместо обновления).

Refs #2674
2026-08-06 05:32:14 +05:00
f76485781b Merge pull request 'fix(tradein/matching): честность тиров сопоставления домов (#2674)' (#2688) from fix/2674-matching-tiers-honesty into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m50s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m45s
2026-08-06 00:18:06 +00:00
bot-backend
1a577fe748 fix(tradein/matching): снять слияние по ГАР-GUID, починить приёмник кадастра и keeper (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его
полностью и добавляю три правки, которых не хватало.

СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid).
Аргумент «общий UUID здания есть независимая идентичность» оказался круговым.
gar_flats_loader проставляет gar_house_guid предикатом
  WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon
— левая часть побайтово равна ключу канон-прохода, то есть guid является
детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл
с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход
отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID»,
выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна
ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр
стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен
ключ, независимый от канона, либо включённый гео-страж — это другая задача.

Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр
КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher,
RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком.
Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который
парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы
никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number
и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две
квартиры одного дома дали бы два дома — то самое дробление. В listings оба
поля пишутся как раньше.

Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без
объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись
обгоняла запись со 192 объявлениями вопреки задокументированному правилу.
Дефект предсуществующий и живой для канон-прохода.

Сторож границы вызова для живого ФИАС-тира. Прежние проверки были
структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей
границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела,
тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление
«тест ловит неуловимый класс» из прошлого описания снято как преувеличение:
структурная проверка ловит подслучай, и building_cadastral_number её проходит
при нуле срабатываний из 49 502.

Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение
ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного
утверждения в шапке cadastral_geo_match.py.

Refs #2674
2026-08-06 05:11:47 +05:00
f5b39e6fc9 chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m57s
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.

ПОДКЛЮЧЕНО

Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).

filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.

has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.

УДАЛЕНО

Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.

Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.

Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.

v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».

ЗАДОКУМЕНТИРОВАНО

BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.

v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.

house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.

Refs #2674
2026-08-06 05:00:36 +05:00
bot-backend
3fd6550a16 fix(tradein/matching): честность тиров сопоставления домов (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.

Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.

Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.

Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).

Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.

Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.

Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.

Refs #2674
2026-08-06 04:37:12 +05:00
5e92810d72 Merge pull request 'fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674)' (#2685) from fix/2674-avito-full-load-coverage into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m49s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m39s
2026-08-05 23:13:39 +00:00
6d76328168 fix(tradein/avito): верное объяснение границы окна и критерий приёмки по глубине (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m52s
Разбор ревью PR #2685. Выбор окна в 7 суток подтверждён непредвзятым замером по
одному прогону (run 2990, exhaustive 02.08, 2184 датированных строки): W=2 -> 147,
W=6 -> 446, W=7 -> 1275, W=12 -> 1278. Шестёрка теряет две трети семёрки, а 7..12 —
плато в +3 лота, то есть семёрка стоит на самой дешёвой его точке. Потеря окна 2
занижена мной в первом заходе: не 3.26x, а 8.7x.

Механизм объяснён неверно. Пик на возрасте ровно 7 — не недельный авто-подъём Авито,
а квантование нашего же парсера относительных дат: «неделю назад» -> ровно today-7,
«две недели назад» -> today-14, возрасты 8..13 по этому пути недостижимы. То самое
плато (3 лота из 2184) это и доказывает: при реальном подъёме полоса 8..13 была бы
заполнена. Вывод от этого только крепнет — шестёрка режет не по пику распределения,
а по границе квантования и теряет бакет «неделю назад» целиком, а внутри него
реальный возраст от 7 до 13 суток.

51% не воспроизводится: 1212 из 3714 датированных наблюдений — 32.6%. Пятьдесят один
получается только на знаменателе, урезанном возрастами 0-13.

Цена по запросам описана неверно и в опасную сторону. Рост не пропорционален лотам:
стоимость бакета — ceil(свежих/50) страниц с полом 1-2, при окне 7 на бакет выходит
~15-20 свежих (1275 на 77 бакетов), то есть меньше страницы. Большинство бакетов как
стояло на 1-2 страницах, так и останется. Верхняя граница честная и продом пережитая:
полный обход без отсечки — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990).

Отсюда же переписан критерий приёмки: ждать «9-10 тысяч собранных лотов» нельзя, это
уведёт в ложный вывод. Прогон с окном 2 уже собирал 2804 лота, потому что первые
страницы всё равно полные — объём почти не сдвинется, сдвинется глубина. Считать надо
лоты с listing_date в полосе [D-7, D-3] и число страниц из лог-строки paginated=.

Впечатана мина на случай отката такта: ни миграция (GREATEST только расширяет), ни
планировщик (расширяет до такта, не сужает) окно не сузят, поэтому interval_days
7 -> 1 при окне 7 даст восьмикратный охват каждый день. Такт и окно менять вместе.

int(params.get("interval_days", 1)) падал на значении null в jsonb — соседний параметр
строкой выше обрабатывался через явную проверку на None, этот нет.
2026-08-06 04:09:05 +05:00
0ed0f97ae2 Merge pull request 'fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674)' (#2684) from fix/2674-admin-metrics-honesty into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m2s
Deploy Trade-In / test (push) Successful in 2m55s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 2m5s
2026-08-05 23:01:30 +00:00
3c5f535e6c fix(tradein/admin): гейт отмены по источнику, честный комментарий view, лимит 50 (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 2m58s
Ревью PR #2684 — четыре MINOR.

1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была
   пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не
   проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито
   мог бы «отменить» refresh_search_matview — задача продолжила бы работать под
   статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их
   вычищаем), а has_running_run перестал бы держать single-run guard, который
   существует из-за инцидента с двойным свипом и баном (2026-05-31).

   Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel +
   отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы
   guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку.
   Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все
   площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import.
   Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает.

2. Комментарий пересозданного v_data_quality утверждал, что его обновляет
   /api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит
   свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права
   переносить в прод ложное утверждение о читателе.

3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти —
   сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться.
   Привязка к вкладке НЕ возвращается.

4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE —
   миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест
   проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы.

Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон
3490 passed / 9 skipped, tsc --noEmit чистый.
2026-08-06 03:57:46 +05:00
e4ac0365cf fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m53s
Окно и такт разъехались. Каденс avito_full_load задаёт default_params.interval_days,
глубину обхода — incremental_days (since = today - N, ранняя остановка по listing_date).
Это два независимых литерала, обязанных совпадать: 129 поставил окно 2 при ежедневном
такте (перекрытие было), 206 перевёл такт на 7 суток и окно не тронул. Прогон видит
[D-2, D] = 3 суток из 7; дни D+1..D+4 не попадают ни в один прогон.

Числа с прода (tradein, read-only 2026-08-06). 2026-06-21 — единственный день, когда
оба обхода отработали: инкрементальный run 297 — 2804 unique, exhaustive run 295 —
9992 unique, то есть окно в 2 суток достаёт 28.1% инвентаря. Когорта run 295, не
виденная после 23.06 (listing_date заморожен): полоса [D-2, D] — 189 лотов, полоса
[D-7, D-3] — ещё 427, расширение окна берёт в 3.26 раза больше. Гистограмма
(last_seen_at::date - listing_date) за 20 суток: возраст 0-2 — 594, возраст ровно 7 —
1212 (51% датированных наблюдений) — у Avito недельный авто-подъём, sortTimeStamp
сдвигается кратно 7. Структурный минимум бездырочного покрытия — 6, но 6 режет ровно
по этому пику; 7 = такт, полосы соседних прогонов смыкаются с суточным перехлёстом
под дрейф расписания (замер: last_run 03.08 13:37 -> next_run_at 10.08 14:16).

Цена: страниц примерно втрое больше на прогон, но прогон недельный. До 206 система
платила ~150-370 страниц семь раз в неделю; после правки — ~500-1200 в неделю, всё
ещё примерно вдвое дешевле, чем до 206.

Чиню в двух местах: миграция 215 выводит окно из фактического interval_days строки
(GREATEST — не сужает окно шире такта), scheduler расширяет его на лету и пишет
warning, чтобы расхождение не вернулось следующей правкой каденса.

Полный обход: причина не в площадке. Все пять banned-прогонов
avito_full_load_exhaustive (05.07-02.08) несут один текст —
"browser-sidecar error: browser unavailable (proxy may be down)", то есть 503 от
своего же сайдкара, у которого не поднялся камуфокс. В те же дни avito_city_sweep
(20 done), avito_newbuilding_sweep (21) и avito_detail_backfill (63) работали.
Корень — мёртвый BROWSER_PROXY_AVITO (ard.mobileproxy.space) в env сайдкара, куда
полный обход проваливался, потому что строил свой BrowserFetcher без пула; починено
не здесь, а #2637 (02.08, пул для браузерного пути Авито) и #2616 шаг 2 (05.08,
снос мёртвых env). Прод подтверждает: 0 лотов 05/12/19/26.07, 2334 и 362 в двух
прогонах после 02.08.

Остаток, который чинится кодом, здесь: 503 сайдкара классифицировался как soft-ban и
уходил в бюджет IP-ротации, а ротация снята (#2616 шаг 2, max_rot=0) — условие
rot_done < max_rot ложно всегда, а бюджет коротких backoff-retry стоял в else и был
для soft-ban недостижим. Ни одного ретрая на самую частую ошибку: один блип сайдкара
стоил бакета, четыре подряд — всего прогона. Бюджет backoff теперь общий для обеих
причин; сайдкар на таком 503 сам поднимает фоновый retry launch'а, поэтому повтор
через пару секунд обычно проходит.

Статус banned на такой ошибке остаётся ложью (площадка не банила) — это же чтение
легло в основание 206. Здесь не трогаю: набор status ограничен CHECK-констрейнтом,
а mark_banned в отличие от mark_failed сохраняет чекпоинт done_buckets.
2026-08-06 03:39:37 +05:00
4d0795ae7a fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 2m57s
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.

1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
   колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
   вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
   запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
   соседней. Persist-флаг на объявлении такое отношение выразить не может,
   реализовать пометку нечем.

2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
   HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
   инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
   и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
   deactivate_stale_*. Отдельные колонки были бы вторым определением того же.

3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
   его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
   Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.

4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
   DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
   точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
   есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
   он молча подставлял source вкладки. Новый источник появляется в списке сам.

Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.

Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).
2026-08-06 03:34:18 +05:00
673c02e5d6 Merge pull request 'fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674)' (#2682) from fix/2674-writers-honor-schema into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m50s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 6m36s
2026-08-05 22:12:29 +00:00
77336d351c chore(tradein): перенумеровать миграцию 212 -> 213 (коллизия с #2681)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m55s
PR #2681 смержен, пока ветка была в работе, и принёс
212_sber_index_pull_weekly.sql. Номер 212 занят — беру 213 (свободен,
проверено git ls-tree по origin/main после fetch).

Почему локальный гейт молчал: test_new_files_do_not_reuse_prefix сравнивает
префиксы файлов В ОДНОМ ДЕРЕВЕ, а смерженный 212_sber в этой ветке
отсутствует. Проверено симуляцией (копия data/sql + stub 212_sber):
с моим 212 тест КРАСНЫЙ («212 уже у нового 212_sber»), с 213 — зелёный.

Кросс-ветковым реестром занятых номеров служит _manifest_applied.txt, но он
отстал на 27 имён (171, 187-188, 189-211, 213), поэтому префикс 212 нигде не
числился занятым. Про долг — отдельно, в этом PR манифест не трогаю.

Apply after в шапке обновлён на 212_sber_index_pull_weekly.sql.
2026-08-06 03:07:09 +05:00
ab01f7cc48 fix(tradein): убрать невыводимые события, развести «снято» и «протухло» (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено
собственными запросами к проду — сходится, местами хуже заявленного.

1. delisted/relisted УБРАНЫ из писателя событий.
   Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%,
   avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и
   возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же
   обход, тот же день, разница только в покрытии: событие рождается тем, что
   скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения:
   avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против
   контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней
   (85.7%) — это 2-3.08, два дня после возобновления обхода.
   Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок
   хуже пустого журнала — не пишем. is_active убран из запроса целиком.
   Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся».

2. TTL-путь пишет 'stale', а не 'closed'.
   Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
   против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая
   «дата продажи» одной датой. 'closed' остаётся только за 404: там ответила
   площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT.

3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time,
   type) работал только внутри прогона: второй прогон в те же сутки (2 августа
   их было два) давал дубли. Теперь заявленная идемпотентность действительно
   работает.

4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из
   25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа
   роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу
   (house_suggestions + house_placement_history, там 8.8% без площади).
   Студия → rooms=0 по конвенции kit'а, а не None.

Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6;
обязательная комнатность — 2; now() вместо date_trunc — 1.
2026-08-06 03:01:05 +05:00
807d586627 Merge pull request 'fix(tradein): сигналы о сбоях наконец становятся событиями, а протухание кук предупреждает заранее (#2674)' (#2681) from fix/2674-alerts-actually-fire into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m53s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 7m53s
2026-08-05 21:56:53 +00:00
3e1b9a8b0d fix(tradein): чинит такт загрузки СберИндекса — иначе новый ERROR стал бы ложной тревогой (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m59s
Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось
на моих же числах (все 24 прогона монитора, read-only):

  13-16.07  alert=1  age 73..76  latest=май
  17.07     alert=0  age 46      latest=июнь  ← день загрузки
  18-31.07  alert=0  age 47..60
  01-05.08  alert=1  age 61..65

Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст
считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60
ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя
источника: девять срабатываний были замером нашего собственного такта. Поднятие
до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц.

Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге
60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от
любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному
sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю.

Дополнительно по ревью:
- поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» →
  ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней;
- тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест).

Refs #2674
2026-08-06 02:53:26 +05:00
43aaf91b97 fix(tradein): писатели наконец пишут то, что обещает схема — фото подсказок, статус «снято», события объявлений (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m56s
Три находки одного класса из эпика: колонка есть, писатель есть, тест на писателя
зелёный — а данные не появляются. Тестами это не ловится по построению, только
сверкой с продом.

1. house_suggestions: парсер выбрасывал imageLink, а INSERT не перечислял
   image_link + area_m2/rooms/floor/total_floors. 25 055 строк с NULL во всех
   пяти колонках, ~74 дня с миграции 064. Метрики парсятся из title тем же
   _parse_title, что и у placementHistory.

2. listings_snapshots.status: 'active' у всех 394 299 строк при 55 448 реально
   неактивных объявлений. Оба места вызова с литералом 'active' честны — там
   объявление действительно видели; не писал никто ветку «снято». Теперь оба
   места деактивации пишут снимок 'closed' в ТОЙ ЖЕ транзакции: TTL-задача
   (data-modifying CTE, все 4 источника через один deactivate_stale_listings)
   и 404 из avito_detail_backfill. Дата снятия перестаёт быть догадкой.

3. listing_source_events: схема знает 5 типов, писался 1 (price_change, 8288
   строк). Дописаны ветки delisted/relisted/edited/first_seen в тот же
   set-based statement — данные для них уже лежат в снимке. JOIN → LEFT JOIN
   LATERAL, иначе first_seen недостижим по построению; план #2607 (per-row
   index point-lookup по idx_lss_source_date) сохранён, проверено EXPLAIN на
   проде. Счётчики прогона теперь по типам, все пять всегда присутствуют —
   ровно они показали бы четыре нуля из пяти.

Миграция не нужна: все колонки и CHECK уже существуют.

Тесты: tests/test_2674_writers_honor_schema.py. Гейты сверяют писателя со
СХЕМОЙ (колонки INSERT против CREATE TABLE 064, типы событий против CHECK 079),
поэтому ловят и следующую забытую колонку. Фальсификация патч-методом: без
фикса 1 — 6 красных, без фикса 2 — 6, без фикса 3 — 4.
2026-08-06 02:29:58 +05:00
46bbb79881 fix(tradein): сигналы о сбоях наконец становятся событиями, а протухание кук предупреждает заранее (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m58s
В скрапер-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR),
поэтому любой сигнал уровня WARNING событием не становится — сколько бы раз он
ни срабатывал. Прод это подтвердил: монитор устаревания СберИндекса отработал
24 раза, 9 из них со staleness-вердиктом, событий ноль; куки Домклика протухли
2026-08-03 и об этом никто не узнал.

Разбирали не «поменять warning на error», а по каждому сигналу: сбой, из-за
которого данные перестают обновляться — событие; рутина и ожидаемые состояния —
лог. Плюс предупреждение ЗАРАНЕЕ там, где чинить нужно руками (куки Домклика —
по образцу #2658 для Циана, переиспользован тот же подход session_expires_at +
COOKIE_EXPIRY_WARN_DAYS).

У поллера Росреестра выход нового квартала оставлен уровнем info, но получил
явный capture_message(level="info"): новость хорошая, но требует ручного импорта
оператором, а INFO-строка живёт только до ближайшего редеплоя. logger.error для
неё был бы враньём в error-rate и стрик-алертах.

Оговорка: у GlitchTip-проекта сейчас нет ни правил, ни получателей (#2673) —
события станут видны в интерфейсе, но никому не отправятся.

Refs #2674
2026-08-06 02:29:11 +05:00
bb6e5c7e41 fix(tradein/deploy): не уходить в drain, когда пересоздавать нечего (#2679)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
`SCRAPER_RECREATE` истинно и на infra-правках (compose / workflow / deploy/**),
а те почти всегда собирают тот же образ по кэшу: digest не меняется, `up -d`
выходит no-op — и за него платили пятиминутным ожиданием слива scrape_runs,
прерывая сбор. Теперь после `docker compose pull` (порядок важен: до pull'а под
:latest ещё старый образ) сравниваем ID подтянутого образа с тем, на котором
бежит tradein-scraper. Совпало — печатаем «пересоздавать нечего» и идём дальше
без drain'а; не совпало или контейнера/тега нет — drain как раньше.

Заодно закрыт ложный startup-reap: чекпоинт и reap завязаны на тот же признак и
больше не выполняются, когда recreate'а не было. Иначе прогон, переживший
таймаут drain'а в НЕ пересозданном контейнере, помечался бы 'cancelled',
продолжая работать.

scraper остаётся в $SERVICES в обоих случаях — при совпавшем образе `up -d`
no-op, но правка самого compose (env/лимиты сервиса) так всё же доезжает.

Сверка образов: «контейнера нет» и «контейнер отстал» теперь разные сообщения —
это разные аварии и чинятся по-разному; отсутствие tradein-backend (эталона)
тоже отдельная строка.

Refs #2679
2026-08-06 01:58:34 +05:00
15ca70ad70 fix(tradein/deploy): скрапер пересобирается вместе с бэкендом + сверка образов после деплоя (#2679)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Почему: `tradein-backend`, `tradein-scraper` и `tradein-tgbot` — один образ
gendesign-tradein-backend, но пересоздание скрапера было привязано к allowlist'у
путей («файлы, которые исполняет планировщик»). Список перечислял только то, что
вспомнили: 2026-07-02 (#2188) на нём погорел fias-dedup — починили ДОБАВЛЕНИЕМ
путей; 2026-08-05 (#2675) тот же механизм выстрелил снова на
house_imv_backfill.py / product_handlers.py. За июнь-август 48% (193 из 402)
backend-мержей не попадали ни в один путь списка.

Что сделано вместо очередного пополнения списка:
- фильтр `scraper` удалён; признак пересоздания = `if:` джобы build-backend
  (backend || infra || workflow_dispatch), т.е. «образ мог пересобраться»;
- добавлена сверка image ID backend/scraper/tgbot после health-checks: при
  расхождении деплой падает ДО записи .tradein-deployed-sha, а не отчитывается
  успехом. Следующий прогон возьмёт ту же базу и пересоберёт накопленное.

«Phase 0»-компромисс (infra не трогает скрапер, чтобы не убить многочасовой
прогон) снят: с #1951 перед recreate'ом идёт graceful drain + startup-reap,
а `compose up -d` на неизменившемся образе — no-op.

Цена по факту (43 прод-деплоя 31.07-05.08): деплой со скрапером median 113s /
mean 146s против 76s / 74s без него; средний деплой вырастет примерно на 40s,
худший случай — до +5 мин (потолок drain'а при длинном full-load'е).

Refs #2679
2026-08-06 01:49:54 +05:00
9f9086fa4d Merge pull request 'fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)' (#2675) from fix/2674-house-imv-params into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m47s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-08-05 20:02:31 +00:00
4b4ab8b34c fix(tradein/imv): счётчики прогона в total_seen/new_count + лог дрейфа ремонта (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
По ревью PR #2675.

1. counters прогона не заполняли выделенные колонки. _column_counts
   (scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и
   new_count из new_count|lots_inserted — ни одного из них в дикте не было,
   поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done
   по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625):
   даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня
   выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и
   new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди
   checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном
   расписании это и правда сигнал.

2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое
   значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но
   дрейф вокабуляра реален (70950 строк listings с пустым нормализованным
   ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» —
   паритет с house_type_normalizer, который такой лог уже пишет.

3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по
   данным: это одновременно МОДА и МЕДИАННАЯ категория популяции
   (standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
   needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а
   не просто «не край шкалы». Там же названа асимметрия: поштучный путь
   эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит —
   решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий
   читатель не принял это за недосмотр.

Refs #2674
2026-08-06 00:58:11 +05:00
0815319e1c fix(tradein/imv): домовая оценка перестаёт врать про ремонт и тип дома (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673).

1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли
   как «косметический ремонт», хотя мода repair_state по объявлениям тех же
   домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631
   (косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state)
   в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит
   через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator
   тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не
   заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это
   середина порядковой шкалы required < cosmetic < euro < designer, а не край,
   системного сдвига в одну сторону не даёт.

2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и
   когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс
   (медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k <
   brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически
   занижал. На проде так уехали 363 дома совсем без типа и 75 домов с
   camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против
   monolithic). Теперь сырое значение прогоняется через общий
   scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick /
   gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса),
   дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается
   no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и
   'wireframe' намеренно НЕ маппятся — честного соответствия у них нет.

3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50
   помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не
   поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed.
   Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done.

Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony
13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по
has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia
5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии —
шум, а не данные.

Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого
модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на
отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после
возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed»
в tradein-browser и 12 на 403 Авито.

Refs #2674
2026-08-06 00:29:49 +05:00
4e9e4f558e Merge pull request 'fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)' (#2671) from fix/2666-discount-plausibility-gate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m8s
Deploy Trade-In / test (push) Successful in 2m51s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m16s
2026-08-05 19:20:08 +00:00
77ae08f207 fix(tradein): отказ гейта — факт про выборку вместо обещания надёжности (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью PR #2671.

Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не
можем: пары — псевдореплики (одно объявление переиспользуется на многих
сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных
объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько
пар есть и что на такой выборке медиана гуляет на десятки п.п.

Формулировка диапазонной ветки укорочена: она дублировала street_only-
дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной
карточки — две формулировки подряд читались как стена текста; теперь три
однострочных хинта, на 820px — по две строки, переполнения нет.

В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал
ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны
ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят
на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой:
26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта —
отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как
гарантию.

Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10")
заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой
даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало
пар», а не «вне диапазона»).
2026-08-06 00:16:13 +05:00
ef8609d725 Merge pull request 'fix(tradein/cian): читать bti из offerData — BTI-персист в houses писал ноль строк (#2435)' (#2668) from feat/2435-cian-house-enrichment into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m50s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m55s
2026-08-05 18:54:35 +00:00
301fbed0d7 Merge pull request 'fix(tradein/scraper): блок QRATOR у Домклика больше не помечает прогон успешным (#2657)' (#2667) from fix/2657-domclick-block-not-done into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 9s
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 2m51s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Has been cancelled
2026-08-05 18:49:21 +00:00
b88535425e fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 1m10s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
/sales-vs-listings отдавал median_discount_pct без всякой проверки: после
сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9%
на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем
просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без
номера дома (ADR #721), так что на длинной улице в пару попадают квартиры
разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число,
которому нельзя верить.

Пороги подобраны по проду (симуляция эндпоинта на 238 реальных
пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару):
- MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы
  подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15.
  Кривая ломается на 10; совпадает с уже принятым в продукте
  sell_time_sensitivity_min_n_lots.
- Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение
  разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один
  городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132).
  Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП),
  граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%).

Форма отказа — не пустота: новое поле median_discount_explanation по образцу
confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно
строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и
per-pair discount_pct не трогаются.
2026-08-05 23:49:03 +05:00
c232772e70 fix(tradein/cian): читать bti из offerData — BTI-персист в houses писал ноль строк (#2435)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
#2435 (PR #2437) завёл запись BTI-полей дома через match_or_create_house, но на
проде она не дала ни одной строки: 9361 дом, 0 с series_name/entrances/flat_count/
is_emergency/heat_supply_type/gas_supply_type/overlap_type — при 628 detail-
обогащённых Cian-листингах за Jul 5-22 и 5188 домах, которых Cian вообще касался.

Причина: bti читался только как соседний с defaultState ключ контейнера
frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — offerData.bti.
extract_all_states() исправно возвращает 143 ключа, но bti среди них нет,
поэтому bti_data всегда оставался None и весь write-path был мёртвым.

Существующие тесты этого не ловили: они кормят bti_data прямо в
save_detail_enrichment, минуя fetch_detail. Новый тест гоняет реальный
сохранённый HTML (fixtures/cian_flat_330982715.html) через настоящий
fetch_detail — без фикса краснеет.

Старое место оставлено фоллбэком.
2026-08-05 23:39:40 +05:00
9b9f299922 fix(tradein/scraper): блок QRATOR у Домклика больше не помечает прогон успешным (#2657)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
Honest-status в run_domclick_city_sweep требовал ОДНОВРЕМЕННО блок И ноль лотов,
поэтому распознанный QRATOR-блок после первых собранных лотов уходил в `done`.
На проде это 13 из 13 прогонов с blocked=1 (39-464 лота вместо ~6300) — ни один
распознанный блок ни разу не дал не-`done` статус.

Домклик структурно отличается от cian/yandex (#2625/#2642): там независимые
anchor'ы и провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет,
sweep линейный по ROOM_BUCKETS, и первый же блок делает break — оставшиеся бакеты
не пробуются вовсе. Значит блок = прогон оборван, сколько бы лотов он ни успел
взять до этого.

Теперь: blocked → mark_banned (external constraint, не наш баг; тот же статус,
что #2642 дал cian/yandex — доступен как триггер ротации IP #2611, сама ротация
не вызывается). Ноль лотов с fetch-ошибками, но БЕЗ блока → по-прежнему failed.
Честная пустота → по-прежнему done.

Пометка прокси-пула (fetcher.report_ban, #2600 п.1) не тронута — живёт в
providers/domclick/serp.py и срабатывает раньше и независимо от статуса прогона.

Refs #2657
2026-08-05 23:37:48 +05:00
c9f71da484 Merge pull request 'feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)' (#2663) from feat/2571-login-throttle into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 2m49s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m10s
2026-08-05 18:31:27 +00:00
96d62e418b Merge pull request 'fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)' (#2662) from fix/2658-loud-skip-status into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m6s
Deploy Trade-In / test (push) Successful in 2m42s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 2m11s
2026-08-05 18:24:04 +00:00
63ea44fdd2 Merge pull request 'fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)' (#2664) from fix/2660-display-freshness-segment into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m38s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-05 18:16:58 +00:00
40fdf11f19 fix(tradein/auth): не ронять и не занимать пул на замедлении входа (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m56s
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради
которой писалась защита.

Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))`
при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем
не ограничен сверху — `record()` только копит метки и на лимит не смотрит.
С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и
без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель
степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое
поведение не меняется.

Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме
`get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в
`get_user_by_username` оставляет её в открытой транзакции — соединение
висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало,
чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по
pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки
учётки, ради ухода от которой замедление и выбиралось. Соединение теперь
возвращается в пул перед сном.

Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя
становится ключом обоих лимитеров, а их словарь при часовом окне не
подчищается; и явно записано, что `limit` у счётчика на имя не порог.
2026-08-05 23:14:11 +05:00
d173163025 fix(tradein): тест ловит копию константы, а не equality; честный комментарий про вклад свежести (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
По ревью PR #2664.

1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
   `lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
   малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
   ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
   срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
   Теперь проверяем исходник через inspect.getsource — фальсифицировано
   подстановкой копии литерала вместо импорта: тест краснеет.

2. Комментарий в location_index.py приписывал свежести чужую заслугу.
   Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
   сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
   не коррекция смещения, а страховка на будущее, оплаченная третью пула
   (3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
   ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
   но он должен быть написан как размен.

   Там же задокументирован новый режим отказа: свежесть связала витрину со
   здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
   прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
   поломки сбора, сценарий не гипотетический.

Окно свежести не меняю — вопрос вынесен отдельно.

Refs #2660
2026-08-05 23:13:22 +05:00
b800760c24 fix(tradein/scraper): фильтр skipped в админке + освежение схлопнутой строки (#2658)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью PR #2662.

Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).

Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.

Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.

session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).

Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
2026-08-05 23:10:47 +05:00
837ad8cfd4 fix(tradein): сегментный гард в «медианном торге», свежесть в индексе локации, честные админ-счётчики (#2660)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m42s
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.

1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
   27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
   девелоперский прайс (который не торгуется) формировал показываемый процент
   торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
   намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
   Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
   перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).

2. location_index — предикат свежести + сегментный гард в обоих запросах
   медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
   тот же пул, на котором считается цена; окно свежести берётся импортом
   LISTINGS_FRESH_DAYS, второго определения константы не заводим.

3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
   рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
   Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
   как «всё собирается».

Refs #2660
2026-08-05 22:39:48 +05:00
7d154de1f7 feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m50s
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.

Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.

Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
2026-08-05 22:38:23 +05:00
0b54b96984 fix(tradein/scraper): пропуск расписания пишет строку прогона со статусом skipped (#2658)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m41s
Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в
scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней
на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно
двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое.

Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»),
но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем
его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run`
(already_running / concurrent_claim / running_appeared_under_lock), kit
`scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в
`error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов.

Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком
`counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и
иначе плодили бы строку каждый тик (60 с).

Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул
None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через
logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration
(event_level=ERROR), поэтому прежний capture_message(level="warning") событием не
становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же
pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит,
когда сбор уже встал.

Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают
failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не
прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя.
2026-08-05 22:35:51 +05:00
bot-backend
5626d9e720 feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу (этап 4 из 8)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m11s
Три дефекта, каждый блокировал легальный публичный запуск.

1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
   только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
   терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
   строкой расчёта, до геокодирования и до обоих мест записи адреса.

   Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
   заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
   Отдельная таблица событий не заводилась: согласие даётся ровно на создание
   этой строки, и когда строка удаляется по сроку, исчезновение доказательства
   вместе с данными логично.

   Enforcement НЕ выводится из пустого created_by — первая версия так и делала
   и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
   пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
   выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
   согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.

2. Срок жизни оценки применялся только как фильтр при чтении — физического
   удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
   декларированному сроку. Заведена задача удаления пачками с ограничением на
   прогон и коммитом после каждой пачки, идемпотентная. В расписании она
   ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
   и первый прогон должен быть под наблюдением.

3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
   ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
   телеграме.

   Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
   телефона и без обращения в поддержку неидентифицируем — удалить его данные
   без дополнительной идентификации нельзя. Отдельно: удаление чистит только
   копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
   кодовой базе, нужен ручной шаг.

4. Соответствие текста согласия на фронте и снимка на бэке держалось на
   комментарии. Теперь есть тест, который ловит расхождение.

Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.

Тесты: 2775 passed.
2026-07-28 15:24:21 +03:00
318 changed files with 43815 additions and 7665 deletions

View file

@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund
- Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock.
- Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`.
- Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`).
- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки.
## Prettier / lint

View file

@ -16,11 +16,43 @@ paths:
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
BEGIN;
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
-- DDL здесь (idempotent)
COMMIT;
```
## lock_timeout при блокирующем DDL (обязательно)
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
отказ, повторить позже) вместо тихой очереди перед приложением.
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
очередь запросов приложения.
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
обрежет `CONCURRENTLY` ниже по файлу.
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
## Idempotency (обязательно)
- `CREATE TABLE IF NOT EXISTS`

View file

@ -30,6 +30,7 @@ jobs:
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -54,21 +55,115 @@ jobs:
frontend:
- 'tradein-mvp/frontend/**'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
# members = backend + packages/*), со своим Dockerfile и без pyproject,
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
- 'tradein-mvp/browser/**'
- '.forgejo/workflows/ci-tradein.yml'
backend-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
# Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на
# заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()`
# self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой
# test_house_dedup_merge (#2740: houses.url стал NOT NULL), а
# test_gar_flats_loader вообще падал до первого утверждения (#2744).
#
# Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped,
# тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не
# медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на
# девять реальных проверок больше. Накладные — только подъём контейнера и
# bootstrap схемы (219 файлов, ~20с).
defaults:
run:
working-directory: ./tradein-mvp/backend
env:
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
CI_PG: ci-pg-tradein-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Поднять Postgres и собрать схему tradein
working-directory: .
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА.
# Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в
# логе прогона: `docker create image=... network="host"`), а на 5432 того
# же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` +
# `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять
# порт, а psql из job'а ушёл В ПРОД и получил
# `password authentication failed for user "tradein"`. То есть
# `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая.
# Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта,
# и ходим по его собственному IP: прод недостижим в принципе, параллельные
# прогоны не конфликтуют, psql берём из самого контейнера.
#
# ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и
# bootstrap падал на `container is not running`.
#
# `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ
# поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже
# отвечает «accepting connections», хотя снаружи БД ещё не существует, а
# впереди рестарт. Проба по TCP зеленеет только на настоящем сервере —
# том самом, к которому пойдут тесты.
#
# postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py
# проверяет KNN по geometry (PostGIS_Version() в connectivity-probe).
# Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с
# `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
ready=""
for _ in $(seq 1 45); do
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then
ready=1; break
fi
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
sleep 2
done
if [ -z "$ready" ]; then
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
docker logs --tail 50 "$CI_PG" 2>&1 || true
exit 1
fi
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV"
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
# Тот же порядок и тот же строгий режим, что в deploy-tradein.yml:
# `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED.
# Никаких «применилось как получилось»: схема в CI либо та же, что на
# проде, либо гейта нет.
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \
"CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE ROLE gendesign_reader;"
for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do
fname=$(basename "$sql_file")
# ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а
# backfill, читающий foreign table gendesign_rosreestr_deals из БД
# ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING
# создать не из чего. На пустых таблицах backfill всё равно no-op.
if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then
echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет"
continue
fi
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \
|| { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; }
done
echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц"
- name: Install uv
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
@ -96,15 +191,87 @@ jobs:
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
# leak из другого test-модуля, pre-existing. Второй исторический deselect
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
# совпадать с test-job в deploy-tradein.yml.
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
#
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
# заголовок добавлен, deselect снят, полный прогон зелёный.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
#
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
#
# `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него
# `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так
# девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не
# называет себя вслух, со временем перестаёт быть верным.
run: uv run pytest -q -rs
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
# вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог:
# 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом
# правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину.
#
# Почему НЕ переиспользуем backend-job:
# 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит;
# 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock;
# 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют.
browser-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.browser == 'true'
# Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего
# pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле
# взять нативный job-таймаут, чем тащить плагин ради одного каталога.
timeout-minutes: 10
defaults:
run:
working-directory: ./tradein-mvp/browser
steps:
- uses: actions/checkout@v4
- name: Set up Python
# 3.12 — как в browser/Dockerfile (FROM python:3.12-slim).
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test deps
# ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки
# Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser
# (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты
# мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib.
# pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам
# крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов.
#
# aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`),
# то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы
# проверку версии, которой в проде нет.
run: pip install pytest aiohttp
- name: Run pytest (tradein-mvp/browser)
# Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего
# не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped.
# `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не
# растворится в строке точек.
run: pytest -q -rs
frontend-checks:
runs-on: ubuntu-latest
@ -118,25 +285,42 @@ jobs:
- name: Set up Node
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
# cache: npm включён с #2770 — package-lock.json теперь tracked.
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: tradein-mvp/frontend/package-lock.json
- name: Install deps (npm install, no lockfile)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
run: npm install --legacy-peer-deps --no-audit --no-fund
- name: Install deps (npm ci)
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт
# видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до
# него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто
# не ставил), и версии в CI и в прод-образе выбирались независимо по дате
# сборки — гейт проверял не тот код, который деплоится.
#
# Правишь package.json — регенерируй лок в том же PR: `npm ci` требует
# точного match и иначе роняет и этот job, и build образа.
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Type-check (tsc --noEmit)
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Run tests (vitest)
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
# молчат про то, что видит пользователь — пустое поле, погашенное число,
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
#
# Пропусков в сьюте нет и быть не должно: сторож пропусков
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
# с причиной В КОДЕ.
run: npm test
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint

View file

@ -12,8 +12,18 @@ name: CI
# единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через
# connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango
# установлен ниже), тогда как на macOS-dev они runtime-skip'аются.
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
# .github/workflows/ci.yml как образец service-блока.
#
# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через
# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис-
# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ
# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод.
# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного
# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект
# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres
# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример
# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не
# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions)
# уронит сборку, если такая публикация всё же появится.
on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
@ -45,6 +55,25 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- name: "Guard: host-port collisions in workflows (#2757)"
# Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на
# КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает
# дефицитный слот раннера. Падение = merge заблокирован.
# python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3).
run: |
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
run: |
python3 scripts/check-migration-lock-timeout.py --selftest
python3 scripts/check-migration-lock-timeout.py
- uses: dorny/paths-filter@v3
id: filter
with:
@ -69,6 +98,20 @@ jobs:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
# Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый
# хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор,
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
# нужен, в отличие от tradein-лэйна.
#
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
# SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому
# integration остаётся честно пропущенным — с причиной в логе (`-rs`).
defaults:
run:
working-directory: backend
@ -76,14 +119,53 @@ jobs:
# TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает
# запросы при settings.testing=True) — иначе 401 на всём /api/v1.
TESTING: "1"
# Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта
# нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через
# connectivity-probe к этому хосту (5432 недоступен → skip).
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379/0
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
CI_PG: ci-pg-backend-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Поднять Postgres для тестов
working-directory: .
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в
# ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с
# `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то
# есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами,
# в bridge-сети, без публикации порта, ходим по его IP.
#
# `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
ready=""
for _ in $(seq 1 45); do
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then
ready=1; break
fi
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
sleep 2
done
if [ -z "$ready" ]; then
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
docker logs --tail 50 "$CI_PG" 2>&1 || true
exit 1
fi
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV"
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
- name: Set up Python
uses: actions/setup-python@v5
with:
@ -136,10 +218,13 @@ jobs:
# но --ignore — belt-and-suspenders на случай сбора фикстур).
# tests/integration self-skip'ается через requires_test_db (skipif на
# TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается.
# tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe
# (5432 недоступен в этом mock-lane) → SKIP. Это intended.
# tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745).
# PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK.
#
# `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без
# него пропуск неотличим от прогона — именно так проверка тихо перестаёт
# исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740).
#
# Coverage-gate (#68): --cov=app меряет покрытие пакета app/.
# --cov-fail-under=65 → job RED если покрытие упало ниже baseline
# (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject;
@ -148,7 +233,7 @@ jobs:
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
# term-missing → видно непокрытые строки прямо в job-логе.
run: |
uv run pytest -q --ignore=tests/smoke \
uv run pytest -q -rs --ignore=tests/smoke \
--cov=app \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
@ -169,6 +254,13 @@ jobs:
echo "$report"
fi
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
frontend-tests:
runs-on: ubuntu-latest
needs: changes

View file

@ -28,12 +28,28 @@ jobs:
frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }}
browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }}
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
app_version: ${{ steps.build-meta.outputs.app_version }}
build_sha: ${{ steps.build-meta.outputs.build_sha }}
build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
# проброшены как build-args в build-backend/build-frontend ниже (см.
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
# Считается ОДИН раз здесь, а не в каждой job отдельно.
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
id: build-meta
run: |
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
@ -91,7 +107,6 @@ jobs:
echo "frontend=true" >> "$GITHUB_OUTPUT"
echo "browser=true" >> "$GITHUB_OUTPUT"
echo "infra=true" >> "$GITHUB_OUTPUT"
echo "scraper=true" >> "$GITHUB_OUTPUT"
# Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges
# (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes.
@ -107,28 +122,40 @@ jobs:
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
- 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
# причина, что у backend выше.
- 'tradein-mvp/VERSION'
# /versions статически запекает CHANGELOG.md в билд (см.
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
# frontend/** иначе не долетала бы до образа.
- 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
- 'tradein-mvp/docker-compose.prod.yml'
- 'tradein-mvp/deploy/**'
- '.forgejo/workflows/deploy-tradein.yml'
scraper:
- 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
# без этих путей scraper-контейнер оставался на старом коде
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
# scheduler — его правки тоже обязаны пересоздавать контейнер.
- 'tradein-mvp/backend/app/services/matching/**'
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
- 'tradein-mvp/packages/scraper-kit/**'
# УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом
# «файлов, которые исполняет планировщик», и перечислял только то,
# что вспомнили. Дважды выстрелило одинаково:
# 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не
# до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**,
# house_dedup_merge.py) — залатали случай, не механизм;
# 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py
# в списке не значились → планировщик час крутил старый код,
# деплой при этом отчитался успехом.
# За июнь-август 48% (193 из 402) backend-мержей не попадали ни в
# один из путей списка, т.е. половина правок доезжала до scraper'а
# только со следующим «удачным» деплоем. Теперь пересоздание
# привязано не к списку файлов, а к факту пересборки образа —
# см. SCRAPER_RECREATE в job deploy.
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
@ -164,15 +191,25 @@ jobs:
run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend)
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
# leak из другого test-модуля, pre-existing. Второй исторический deselect
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
# БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml.
#
# Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с
# объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в
# /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на
# такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в
# pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё
# на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс
# дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, рядом с самим тестом.
#
# `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне —
# 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый
# пропуск роняет прогон через хук в tests/conftest.py.
run: uv run pytest -q -rs
build-backend:
runs-on: ubuntu-latest
@ -201,6 +238,13 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
@ -226,6 +270,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
# поэтому копируем внутрь ДО build. /versions статически запекает его
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
# комментарий). Не влияет на кэш другого шага — читается только этим.
- name: Stage CHANGELOG.md into frontend build context
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
uses: docker/build-push-action@v6
with:
@ -236,9 +288,15 @@ jobs:
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
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 }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -294,21 +352,32 @@ jobs:
uses: appleboy/ssh-action@v1.0.3
env:
IMAGE_TAG: latest
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
GHCR_PAT: ${{ secrets.GHCR_PAT }}
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
# scraper-code paths (the `scraper` paths-filter already covers
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
# should trigger a scraper recreate. (infra term intentionally dropped.)
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса,
# одна строка image, разный command). Значит вопрос «пересоздавать ли
# scraper» — это не «трогали ли его файлы», а «мог ли пересобраться
# образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend:
# backend || infra || workflow_dispatch. Ровно тогда в реестре мог
# появиться новый :latest, и оставить scraper на старом — значит
# оставить планировщик на старом коде (инцидент #2679).
#
# Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ
# пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс
# больше не нужен — с #1951 перед recreate'ом идёт graceful drain
# (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам
# `compose up -d` на неизменившемся образе — no-op.
SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
GITHUB_SHA: ${{ github.sha }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
cd /opt/gendesign
@ -431,6 +500,35 @@ jobs:
done
echo "All migrations applied."
# (3b) Невалидные индексы после цикла (#2752). Оборванный
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
# на записи всё равно платится. Молчит это так (воспроизведено на
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
# «relation already exists, skipping», выходит с кодом 0 → миграция
# помечается применённой, а индекс остаётся невалидным навсегда.
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
# ловит и этот путь, и невалидные индексы любого другого
# происхождения (отменённый job, ручной CIC оператором).
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
if [ -n "$invalid_idx" ]; then
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
echo " пересоздать индекс и повторить деплой."
exit 1
fi
echo "✓ невалидных индексов нет."
# Bootstrap gendesign_reader password from env (post-migration, #976).
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
@ -489,8 +587,43 @@ jobs:
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
# истинно и на infra-правках (compose / workflow / deploy/**), а те
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
# образе бежит scraper, что уже лежит в локальном демоне.
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
# 'cancelled', продолжая работать).
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
echo " drain пропускаем, in-flight прогоны не трогаем"
else
scraper_stale="yes"
fi
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
# прогона подчистит периодический 6h zombie-reaper.
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
if [ -n "$scraper_stale" ]; then
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
echo " ждём слива in-flight scrape_runs (до 5 мин)"
drained=""
for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
@ -527,15 +660,11 @@ jobs:
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
@ -653,7 +782,7 @@ jobs:
# снижает шанс поймать контейнер ровно в момент between-restarts
# промежуточного "running" внутри crash-loop.
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
# scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
# scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому
# проверяем только то, что реально входит в текущий $SERVICES.
for svc in tgbot scraper; do
case " $SERVICES " in
@ -683,6 +812,49 @@ jobs:
echo "→ tradein-$svc running."
done
# Сверка образов backend-семейства (#2679) — последняя проверка перед
# маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ
# gendesign-tradein-backend; backend пересоздаётся на каждом деплое
# (безусловно в $SERVICES) и потому всегда несёт свежий :latest —
# он и есть эталон. Если у scraper или tgbot image ID другой, значит
# контейнер остался на старом коде, а деплой без этой проверки
# отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до
# tradein-backend, ff98603ba3cc; tradein-scraper остался на
# da26154c64a6 часовой давности — а планировщик, единственный
# исполнитель домовой оценки, живёт именно там).
# Падаем, а не warning'уем: расхождение = правка не работает, и
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
# по-разному, поэтому сообщения различаются явно.
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
image_mismatch=""
if [ -z "$backend_image" ]; then
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
image_mismatch="yes"
fi
for svc in scraper tgbot; do
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
if [ -z "$svc_image" ]; then
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
echo " образ, а неполный стек: сервис не работает вообще."
image_mismatch="yes"
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
image_mismatch="yes"
fi
done
if [ -n "$image_mismatch" ]; then
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
echo " docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps backend scraper tgbot"
exit 1
fi
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do

View file

@ -309,6 +309,31 @@ jobs:
done
echo "All migrations applied."
# Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
# CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
# пользуется, а re-run миграции его не чинит — `CREATE INDEX
# CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
# skipping» и выходит с кодом 0, после чего миграция помечается
# применённой, а индекс остаётся битым навсегда (воспроизведено на
# PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
# Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
# на проде таких индексов 0 — это профилактика.
invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
if [ -n "$invalid_idx" ]; then
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
echo " пересоздать индекс и повторить деплой."
exit 1
fi
echo "✓ невалидных индексов нет."
# Set tradein_fdw_reader password from env (post-migration bootstrap).
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
# password lives only in /opt/gendesign/backend/.env.runtime.

View file

@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
SCRAPE_KN_DEFAULT_REGIONS=66
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
SCRAPE_KN_STATE_PATH=data/playwright_state.json
# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
# Переменная сохранена в core/deps.py для быстрого rollback.
SCRAPE_ADMIN_TOKEN=
# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437,
# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось
# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard
# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426).

View file

@ -2189,12 +2189,21 @@ def analyze_parcel(
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
# тогда веса системные или дефолтные, а не профильные.
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_effective_weights = _resolved.weights
_weights_source = _resolved.source
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
# None когда profile_id не передавали; False когда передали, но применилось
# другое (не найден / чужой / перебит inline-весами).
_requested_profile_applied: bool | None = (
None if profile_id is None else _weights_source == "profile"
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
@ -4085,9 +4094,12 @@ def analyze_parcel(
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
"requested_profile_applied": _requested_profile_applied,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
@ -4203,6 +4215,7 @@ def analyze_parcel(
"profile_user_id": profile_user_id,
"inline_weights": _inline_weights,
"weights_source": _weights_source,
"requested_profile_applied": _requested_profile_applied,
"x_session_id": _session_id,
},
district=_district_name,

View file

@ -146,9 +146,6 @@ class Settings(BaseSettings):
# Path to a pre-captured Playwright storage_state.json (committed in repo,
# used by worker to skip cold-start WAF challenge).
scrape_kn_state_path: str = "data/playwright_state.json"
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ

View file

@ -1,23 +0,0 @@
"""Shared FastAPI dependencies."""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.core.config import settings
def verify_admin_token(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> None:
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
if not settings.scrape_admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]

View file

@ -48,7 +48,7 @@ from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
from app.observability.sentry_scrub import scrub_event
from app.services.auth_session import resolve_session_token
logger = logging.getLogger(__name__)
@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
# (middleware, маршруты) видели активный client с самого старта процесса.
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
if settings.glitchtip_dsn:
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
# before_send_transaction оставлял бы PII-канал открытым при любом
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@ -82,8 +87,14 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
# дополнительная мера, а условие, без которого скраб не полон.
include_local_variables=False,
before_send=scrub_event,
before_send_transaction=scrub_event,
integrations=[
StarletteIntegration(),
FastApiIntegration(),

View file

@ -1,17 +1,72 @@
"""Хук before_send_transaction для GlitchTip/Sentry SDK.
"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK.
Redact-ит api keys / tokens из URL-spans перед отправкой чтобы
секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
через HttpxIntegration performance-spans.
`scrub_sensitive_query` redact-ит api keys / tokens из URL-spans перед
отправкой чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
GlitchTip через HttpxIntegration performance-spans.
`scrub_pii_event` redact-ит consumer-PII (client_name / client_phone /
client_email / phone / email / name / company / message) из events перед
отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на
sentry-sdk 2.58) НЕ покрывает эти поля это user-data, попадающий в
request.data / extra / contexts (pilot-заявки `PilotRequestInput` в
`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/
`message`, куда чаще всего прилетают телефоны/имена/адреса; чат свободный
вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет
сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/
sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/
client_email Птица их не использует сегодня, но одинаковый механизм на
оба продукта проще сопровождать), плюс `company`/`message`, специфичные для
`PilotRequestInput` (#2457-review).
`scrub_event` composed-хендлер (PII-scrub + URL-secret redact), которым
надо вешать ОБА канала `before_send` И `before_send_transaction`.
Starlette-интеграция кладёт тело запроса в `request_info["data"]` на
transaction-scope точно так же, как на error-scope (scope-обработчики для
transactions НЕ пропускаются пропуск бывает только на availability-чеках).
Если повесить PII-scrub только на `before_send`, а `before_send_transaction`
оставить на голом `scrub_sensitive_query` PII продолжит течь через
transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review,
воспроизведено: pilot-заявка с реальными данными ~1/20 попадает в
транзакцию с полным телом).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.types import Event
logger = logging.getLogger(__name__)
# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753).
# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе
# в новое событие, которое снова пойдёт через этот же обработчик; при
# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено:
# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика
# остаётся в stdout контейнера: текст трассировки значений переменных не несёт.
ignore_logger(__name__)
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля
# свободного текста (#2457-review).
_PII_KEYS = frozenset(
{
"client_name",
"client_phone",
"client_email",
"phone",
"email",
"name",
"company",
"message",
}
)
_SENSITIVE_PARAM_RE = re.compile(
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
re.IGNORECASE,
@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
request["url"] = _redact(request["url"])
return event
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(key, str) and key.lower() in _PII_KEYS:
obj[key] = _REDACTED
else:
_scrub(value)
elif isinstance(obj, list):
for item in obj:
_scrub(item)
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction)
перед отправкой в GlitchTip.
Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list),
заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event
(не None) иначе SDK дропнет отчёт целиком.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
return event
def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""Composed `before_send` / `before_send_transaction` handler: PII-scrub +
URL query-secret redact. Вешать ОДИНАКОВО на оба канала см. module
docstring (#2457-review): transaction-scope несёт `request.data` точно так
же, как error-scope.
try/except предохранитель: sentry_sdk оборачивает вызов `before_send` в
`capture_internal_exceptions`, который при исключении внутри хендлера
ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что
редактор упал, event просто не уйдёт). Наблюдаемость важнее полноты
покрытия редактора: лучше отправить событие в состоянии "сколько успели
отредактировать до сбоя", чем не отправить вообще и молча остаться без
сигнала в мониторинге.
"""
try:
scrub_pii_event(event, hint)
scrub_sensitive_query(event, hint)
except Exception as exc:
# Ни трассировки, ни str(exc): и то и другое способно нести значения из
# ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что
# защищает (#2753). Имя класса исключения данных не несёт. Событием
# мониторинга эта строка не станет — см. ignore_logger выше.
logger.error(
"sentry_scrub.scrub_event: handler failed (%s), sending event as-is",
type(exc).__name__,
)
return event

View file

@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],

View file

@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],

View file

@ -10,7 +10,7 @@ API surface:
- create_profile(db, payload) WeightProfile
- update_profile(db, user_id, profile_id, payload) WeightProfile | None
- delete_profile(db, user_id, profile_id) bool
- resolve_weights(db, user_id, profile_id) dict[str, float]
- resolve_weights(db, user_id, profile_id) ResolvedWeights(weights, source)
"""
from __future__ import annotations
@ -19,7 +19,7 @@ import json
import logging
import math
from datetime import datetime
from typing import Any
from typing import Any, NamedTuple
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
return True
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
"""Вернуть эффективные веса для analyze_parcel.
class ResolvedWeights(NamedTuple):
"""Веса + КАКОЙ источник фактически применился (#2811).
Лестница приоритетов ниже по построению стирает разницу между «взял, что
просили» и «не нашёл, взял что было» а метка в ответе /analyze строится
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
молча «весами» этот объект не притворится.
"""
weights: dict[str, float]
source: str # "profile" | "user_default" | "system"
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
Порядок приоритетов:
1. profile_id задан загрузить именно этот профиль
2. user_id задан загрузить default-профиль пользователя
3. Иначе вернуть системные значения _SYSTEM_POI_WEIGHTS
1. profile_id задан загрузить именно этот профиль source="profile"
2. user_id задан загрузить default-профиль пользователя source="user_default"
3. Иначе системные значения _SYSTEM_POI_WEIGHTS source="system"
Запрошенный, но НЕ применённый profile_id не тишина: warning с
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
/analyze необязательный модификатор, а не адресуемый ресурс; 404 превратил
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
return dict(profile.weights)
return ResolvedWeights(dict(profile.weights), "profile")
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
return dict(profile.weights)
resolved = ResolvedWeights(dict(profile.weights), "user_default")
logger.debug("resolve_weights: returning system defaults")
return dict(_SYSTEM_POI_WEIGHTS)
if profile_id is not None:
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
# молча считалась не по тем весам (#2811, ранее #2788).
logger.warning(
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
"фактический источник весов %r",
profile_id,
user_id,
resolved.source,
)
else:
logger.debug("resolve_weights: источник весов %s", resolved.source)
return resolved

View file

@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query
from app.observability.sentry_scrub import scrub_event
logger = logging.getLogger(__name__)
@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
if settings.glitchtip_dsn:
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review,
# см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker
# вообще не скрабил error-события (тут before_send не было), а
# before_send_transaction был на голом scrub_sensitive_query (только URL) —
# оба канала пропускали PII.
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
@ -30,7 +35,12 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
before_send_transaction=scrub_sensitive_query,
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см.
# app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно.
# В воркере вектор шире: задачи держат в кадрах сырые ответы источников.
include_local_variables=False,
before_send=scrub_event,
before_send_transaction=scrub_event,
integrations=[
CeleryIntegration(monitor_beat_tasks=True),
SqlalchemyIntegration(),

View file

@ -0,0 +1,146 @@
"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py.
Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут
`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В
процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а
если бы и был достижим глобальный клиент SDK остался бы живым для всех
последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает
вместе с ней.
Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а
DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются
случайно на каждый запуск кадр стека несёт не только переменные, но и строки
исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной.
stdout одна строка JSON: counts / scrub_handler_entries / markers / payloads
(тело каждого канала отдельно см. `main`).
"""
from __future__ import annotations
import importlib
import io
import itertools
import json
import sys
import uuid
from typing import Any
import sentry_sdk
_FAILURE_CAP = 3
def _fresh(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex}"
def _leaking_event(markers: dict[str, str]) -> dict[str, Any]:
"""Событие с ПДн в трёх местах, которые закрывает scrub_event."""
return {
"message": "sentry-wiring-probe",
"level": "error",
"request": {
"data": {"phone": markers["phone"], "message": markers["free_text"]},
"url": f"https://example.invalid/probe?api_key={markers['url_secret']}",
},
}
def main(module: str) -> int:
importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init()
client = sentry_sdk.get_client()
sent: list[str] = []
def _record(envelope: Any) -> None:
buf = io.BytesIO()
envelope.serialize_into(buf)
sent.append(buf.getvalue().decode("utf-8", "replace"))
client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign]
markers = {
"phone": _fresh("probe-phone"),
"free_text": _fresh("probe-free-text"),
"url_secret": _fresh("probe-url-secret"),
"local_var": _fresh("probe-local-var"),
}
# 1. Канал ошибок (before_send).
sentry_sdk.capture_event(_leaking_event(markers))
after_error = len(sent)
# 2. Канал транзакций (before_send_transaction) — Starlette кладёт
# request.data на transaction-scope так же, как на error-scope.
transaction = _leaking_event(markers)
transaction["type"] = "transaction"
transaction["transaction"] = "sentry-wiring-probe-tx"
transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}}
transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z"
transaction["timestamp"] = "2026-01-01T00:00:01.000000Z"
transaction["spans"] = []
sentry_sdk.capture_event(transaction)
after_transaction = len(sent)
# 3. Локальные переменные кадра стека (include_local_variables). Имя
# переменной произвольное — ключевой скраб такое не ловит по построению.
def _raise_with_local() -> None:
applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека
raise RuntimeError("sentry-wiring-probe boom")
try:
_raise_with_local()
except RuntimeError:
sentry_sdk.capture_exception()
after_exception = len(sent)
# 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка
# журнала об отказе уходит в мониторинг через LoggingIntegration, снова
# попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса
# проверено: не завершается, 1000+ вложенных трассировок за минуту).
# Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе
# проба на сломанном коде висела бы вместо того, чтобы честно покраснеть.
from app.observability import sentry_scrub
original = sentry_scrub.scrub_pii_event
entries: list[int] = []
def _boom(*_a: Any, **_kw: Any) -> Any:
entries.append(1)
if len(entries) > _FAILURE_CAP:
return None
raise RuntimeError("sentry-wiring-probe scrubber failure")
sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment]
try:
# Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is").
sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"})
finally:
sentry_scrub.scrub_pii_event = original # type: ignore[assignment]
after_scrub_failure = len(sent)
# Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные
# переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит
# чужую проверку и мешает понять, что именно сломано.
bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure]
names = ["error", "transaction", "exception", "scrub_failure"]
spans = dict(zip(names, itertools.pairwise(bounds), strict=True))
print(
json.dumps(
{
"counts": {name: end - start for name, (start, end) in spans.items()},
"scrub_handler_entries": len(entries),
"markers": markers,
"payloads": {
name: "\n".join(sent[start:end]) for name, (start, end) in spans.items()
},
}
)
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))

View file

@ -5,36 +5,16 @@
from __future__ import annotations
# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5).
# Используем importlib.util.find_spec вместо прямого import — иначе
# `import app.workers...` пересоздаёт `app` как Python package и
# перебивает FastAPI instance, привязанный в строке выше → AttributeError
# на app.dependency_overrides.
import importlib.util
# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python
# package и перебивает FastAPI instance, привязанный ниже → AttributeError на
# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers...").
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
_SCRAPE_CADASTRE_AVAILABLE = (
importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None
)
requires_scrape_cadastre = pytest.mark.skipif(
not _SCRAPE_CADASTRE_AVAILABLE,
reason=(
"app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). "
"Команда: git rebase origin/main"
),
)
# Токен для тестов (не реальный)
ADMIN_TOKEN = "test-admin-token"
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
"""Создать mock db session с преднастроенными ответами."""
@ -113,8 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
# ── Tests ────────────────────────────────────────────────────────────────────
@requires_scrape_cadastre
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_pilot_returns_job_id() -> None:
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
@ -131,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "pilot"},
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
@ -144,8 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
app.dependency_overrides.clear()
@requires_scrape_cadastre
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_manual_list() -> None:
"""POST /cadastre/jobs scope=manual_list с явным списком."""
db = _make_mock_db()
@ -164,7 +139,6 @@ def test_create_job_manual_list() -> None:
"scope": "manual_list",
"quarters": ["66:41:0303161", "66:41:0303162"],
},
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
@ -174,8 +148,6 @@ def test_create_job_manual_list() -> None:
app.dependency_overrides.clear()
@requires_scrape_cadastre
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
"""scope=manual_list без quarters → 400."""
db = _make_mock_db()
@ -189,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "manual_list"},
headers=ADMIN_HEADERS,
)
assert response.status_code == 400
finally:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_list_jobs_returns_list() -> None:
"""GET /cadastre/jobs → список jobs."""
db = _make_mock_db(job_row=_make_sample_job_row())
@ -213,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
try:
client = TestClient(app)
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
response = client.get("/api/v1/admin/cadastre/jobs")
assert response.status_code == 200, response.text
body = response.json()
assert isinstance(body, list)
@ -225,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_get_job_not_found_returns_404() -> None:
"""GET /cadastre/jobs/9999 → 404."""
db = MagicMock()
@ -239,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
try:
client = TestClient(app)
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
response = client.get("/api/v1/admin/cadastre/jobs/9999")
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_cancel_job_success() -> None:
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
db = MagicMock()
@ -262,7 +230,6 @@ def test_cancel_job_success() -> None:
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs/42/cancel",
headers=ADMIN_HEADERS,
)
assert response.status_code == 200, response.text
body = response.json()
@ -272,7 +239,6 @@ def test_cancel_job_success() -> None:
app.dependency_overrides.clear()
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_cancel_job_not_found_returns_404() -> None:
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
db = MagicMock()
@ -289,27 +255,7 @@ def test_cancel_job_not_found_returns_404() -> None:
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs/9999/cancel",
headers=ADMIN_HEADERS,
)
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
@pytest.mark.skip(
reason=(
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
"Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не "
"несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
"реверта security-решения #437. Тест проверял удалённое поведение."
)
)
def test_create_job_no_token_returns_401() -> None:
"""Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "pilot"},
)
assert response.status_code in (401, 503)

View file

@ -4,7 +4,11 @@
- валидный запрос без year scope all_years_2022_2026, task_id в ответе
- валидный запрос с year=2026 scope year_2026
- year < 2022 или > 2030 422
- отсутствие X-Admin-Token 401/503
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
X-Admin-Token 401/503» стояла в этом докстринге, но соответствующего теста в
файле нет. Гейт `/api/v1/admin/*` middleware `rbac_guard` (app/main.py),
покрыт tests/test_rbac.py.
"""
from __future__ import annotations
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
from app.main import app
ADMIN_TOKEN = "test-admin-token"
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
return result
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_all_returns_task_id() -> None:
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
mock_result = _mock_task("task-all-001")
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
):
mock_refresh_all.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={})
assert response.status_code == 200, response.text
body = response.json()
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
assert "queued_at" in body
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_refresh_year_returns_task_id() -> None:
"""POST year=2026 → refresh_year queued, scope=year_2026."""
mock_result = _mock_task("task-year-002")
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
):
mock_refresh_year.apply_async.return_value = mock_result
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={"year": 2026})
assert response.status_code == 200, response.text
body = response.json()
@ -67,25 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
client = TestClient(app)
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
response = client.post(ENDPOINT, json={"year": bad_year})
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
@pytest.mark.skip(
reason=(
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
"Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не "
"несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
"реверта security-решения #437. Тест проверял удалённое поведение."
)
)
def test_trigger_no_token_returns_401_or_503() -> None:
"""Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app)
response = client.post(ENDPOINT, json={})
assert response.status_code in (401, 503), response.text

View file

@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
В mock-БД профилей нет значит применились системные веса, и ответ обязан
это признать, а не утверждать, что считал по профилю.
"""
from app.core.db import get_db
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
# sanity: веса и правда системные, промах реальный
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
_SYSTEM_POI_WEIGHTS["tram_stop"]
)
assert wp["source"] != "profile", (
f"?{qs}: применились системные веса, а метка source='profile'"
"ответ утверждает то, чего не было (#2811)"
)
assert wp["source"] == "system"
# «что просили» не теряется: запрошенный id + явный признак промаха
assert wp["profile_id"] == 999999
assert wp["requested_profile_applied"] is False
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_found_profile_keeps_label_and_flag() -> None:
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
from datetime import UTC, datetime
import app.services.site_finder.weight_profiles as wp_module
from app.core.db import get_db
from app.services.site_finder.weight_profiles import WeightProfile
profile = WeightProfile(
id=7,
user_id="user-1",
profile_name="test",
weights={"tram_stop": -0.4},
is_default=False,
description=None,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
original = wp_module.get_profile
wp_module.get_profile = lambda _db, uid, pid: profile
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
assert wp["source"] == "profile"
assert wp["requested_profile_applied"] is True
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
finally:
wp_module.get_profile = original
app.dependency_overrides.clear()
_stop_patches()

View file

@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `
from __future__ import annotations
import os
from pathlib import Path
import pytest
@ -40,3 +41,70 @@ def _clear_dependency_overrides():
app.dependency_overrides.clear()
except Exception:
pass
# ── Бюджет пропусков (#2745) ──────────────────────────────────────────────────
#
# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта:
# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и
# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI
# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть
# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в
# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал
# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно.
#
# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и
# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не
# ошибка; ошибка — пропуск без записи.
#
# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без
# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить.
#
# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона,
# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких
# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml).
_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt"
_observed_skips: set[str] = set()
def _allowed_skips() -> set[str]:
if not _SKIP_ALLOWLIST_PATH.exists():
return set()
lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines()
return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())}
def _record_skip(report) -> None:
if report.skipped:
# nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может
# отсутствовать, а не каждую её параметризацию.
_observed_skips.add(report.nodeid.split("[", 1)[0])
def pytest_runtest_logreport(report) -> None:
_record_skip(report)
def pytest_collectreport(report) -> None:
# Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в
# logreport, и nodeid у него — путь файла.
_record_skip(report)
def pytest_sessionfinish(session, exitstatus) -> None:
unlisted = sorted(_observed_skips - _allowed_skips())
if not unlisted:
return
print(
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
)
for nodeid in unlisted:
print(f" - {nodeid}")
print(
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
"пропуск без записи неотличим от пройденной проверки."
)
if exitstatus == 0:
session.exitstatus = 1

View file

@ -197,32 +197,74 @@ class TestParseBabushkinaBlock:
assert site.krt_kind == "жилой застройки"
class TestMultiSiteCountFromDump:
"""Проверка количества площадок из реального дампа страницы."""
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
#
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
#
# Свойство, ради которого они писались, от дампа не зависит и выражается
# страницей из блоков, которые уже лежат в этом файле:
# • несколько <h2>-секций с годами разбираются в одном проходе;
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
# уникальным (номер сам по себе ключом не является);
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
#
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
# реального документа, синтетической страницей оно не заменяется. На проде
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
# появится образец страницы.
_MULTISITE_PAGE = (
"<div class='content'>"
+ _BRUSNIKA_BLOCK
+ _BABUSHKINA_BLOCK
+ textwrap.dedent(
"""\
<p>&nbsp;<strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
<p>Площадь территории: 3,65 га;<br />\
Градостроительный потенциал: 117 640 кв. м;<br />\
Всего многоквартирных домов: 13.</p>
"""
)
+ "</div>"
)
class TestMultiSitePage:
"""Разбор страницы из нескольких блоков — без локального дампа."""
@pytest.fixture(scope="class")
def dump_html(self):
"""Загружает локальный дамп страницы.
def sites(self):
return parse_krt_page(_MULTISITE_PAGE)
Тест пропускается если файл отсутствует (CI без дампа).
"""
import os
def test_all_three_sites_parsed(self, sites):
"""Три блока в двух годовых секциях → три площадки."""
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
dump_path = os.path.join(
os.path.dirname(__file__),
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
)
if not os.path.exists(dump_path):
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
with open(dump_path, encoding="utf-8") as fh:
return fh.read()
def test_site_count_from_dump(self, dump_html):
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
sites = parse_krt_page(dump_html)
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
def test_no_duplicate_site_names(self, dump_html):
sites = parse_krt_page(dump_html)
def test_no_duplicate_site_names(self, sites):
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
names = [s.site_name for s in sites]
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
assert sum(n.startswith("2.") for n in names) == 2, names
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
def test_blocks_do_not_leak_into_each_other(self, sites):
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
"""
with_developer = [s for s in sites if s.developer_name]
assert len(with_developer) == 1, [s.developer_name for s in sites]
assert "Брусника" in with_developer[0].developer_name
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
def test_kinds_are_read_per_block(self, sites):
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds

View file

@ -202,9 +202,7 @@ class TestParseTable11:
# Реальная зона должна быть в результате
assert "Зона жилой застройки" in zone_names
# Артефакты (zone_name пуст, не is_total) отброшены
non_total_no_zone = [
r for r in result if not r["zone_name"] and not r.get("is_total")
]
non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")]
assert non_total_no_zone == []
# Итого-строка осталась
assert any(r.get("is_total") for r in result)
@ -483,15 +481,23 @@ class TestParsePptTepMocked:
# ── Тест на реальном PDF-образце (skip в CI) ─────────────────────────────────
_REAL_PDF_PATH = Path(
"C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf"
#
# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с
# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного
# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы.
# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся —
# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС.
_REAL_PDF_PATH = (
Path(__file__).resolve().parents[4]
/ ".playwright-mcp"
/ "ekb-docs"
/ "ppt2018_22823_poyasnit_PPT.pdf"
)
@pytest.mark.skipif(
not _REAL_PDF_PATH.exists(),
reason="Реальный PDF-образец отсутствует (только для локального запуска)",
reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск",
)
class TestParsePptTepRealPdf:
"""Интеграционные тесты на реальном образце ppt2018_22823."""

View file

@ -10,8 +10,10 @@
DNS-fail повторяет timeout на каждый analyze.
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
раздельные таблицы внутри модуля).
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ сеть зовётся не больше раза на
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
(#1370, см. сам тест).
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
следующий вызов идёт по сети заново (а не из устаревшего кэша).
@ -23,6 +25,7 @@ from __future__ import annotations
import os
import threading
import time
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
class TestConcurrencySafe:
def test_single_flight_cold_start_one_network_call(self) -> None:
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
все получают одно и то же значение, и после шторма кэш отвечает без сети.
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
было требование, которого код НЕ выполняет и выполнять не собирается:
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
сериализуются на время httpx-вызова даже для разных координат), а рядом с
ним написано, что cold-start на один ключ «может породить несколько
параллельных запросов приемлемо». Тест зеленел не потому, что защита
работает, а потому что при GIL первый поток обычно успевал сложить
результат раньше остальных.
Замер 2026-08-07, 200 штормов подряд: при дефолтном
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 те самые
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
всегда, когда гонка вообще случается, чинить надо было тест.
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
запросы бывают только на cold-start одного ключа и они идемпотентны.
Понадобится это отдельная задача с отдельным обоснованием, а не
побочный эффект правки теста.
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
промах кэша до первой записи. Так тест мерит худший случай той самой
уступки, а не везение планировщика.
"""
start_barrier = threading.Barrier(16)
get_call_count = 0
get_lock = threading.Lock()
@ -253,8 +284,7 @@ class TestConcurrencySafe:
nonlocal get_call_count
with get_lock:
get_call_count += 1
# Микро-задержка — окно для других потоков добраться до lock'а.
# Не делаем sleep большим, чтобы тест не висел.
time.sleep(0.05) # окно, в котором остальные потоки видят промах
return _make_httpx_response(_make_forecast_response())
client_ctx = MagicMock()
@ -276,11 +306,27 @@ class TestConcurrencySafe:
t.start()
for t in threads:
t.join()
storm_calls = get_call_count
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
assert len(results) == 16
assert all(r is not None for r in results)
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
assert results[0] is not None
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
# запись в кэш); меньше единицы невозможно, кэш был пуст.
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
# снятого single-flight — без этого уступка превращается в дыру.
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
assert after_storm == results[0]
assert get_call_count == storm_calls, (
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
f"{get_call_count} против {storm_calls}"
)
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,97 @@
# Объявленные пропуски сьюта backend/.
#
# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не
# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала
# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда,
# когда на неё понадобилось опереться (#2722, #2729, #2740).
#
# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий.
# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ
# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра.
#
# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть
# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск
# не случился, безвредна; пропуск без записи — нет.
# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ───────────────────────
# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0.
# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет.
tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf
tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections
tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes
tests/test_layout_tz_pdf.py
# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ───────────────────────────
# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой
# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не
# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query
tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query
# ── SQL-логика на живой Postgres ──────────────────────────────────────────────
# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни
# разу с момента написания. Схема не нужна — строят себе временные таблицы.
# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16
tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index
tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area
tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg
tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month
tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged
# ── Живая сеть NSPD (nspd.gov.ru) ─────────────────────────────────────────────
# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма.
# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s
tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real
tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty
tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
# ── Образец, которого нет в git ───────────────────────────────────────────────
# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py::
# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не
# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит
# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), —
# зато свойство, которое они проверяли (несколько блоков на странице, уникальные
# site_name), выразимо без дампа и теперь проверяется на многоблочной странице,
# собранной из блоков, уже лежащих в том же тест-файле.
#
# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF
# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком.
# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) —
# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить
# нет: официальный градостроительный документ, без персональных данных.
# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`,
# убрать эти шесть строк.
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows

View file

@ -6,9 +6,15 @@
- GET /{id} 200 / 404
- PUT /{id} 200 / 404
- DELETE /{id} 204 / 404
- 401 при отсутствии X-Admin-Token
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при
отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в
файле нет заголовок просто отправлялся во все запросы и никем не читался
(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware
`rbac_guard` (app/main.py, `_ADMIN_API_RE` 403 для role != admin) и покрыт
tests/test_rbac.py.
Mock-based: get_db переопределяется через dependency override.
"""
@ -24,9 +30,6 @@ from app.core.db import get_db
from app.main import app
from app.services.site_finder.weight_profiles import WeightProfile
_ADMIN_TOKEN = "test-admin-token"
_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN}
_NOW = datetime.now(UTC)
@ -50,9 +53,7 @@ def _make_profile(
@pytest.fixture()
def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient:
"""TestClient с переопределённым SCRAPE_ADMIN_TOKEN."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
def client() -> TestClient:
return TestClient(app)
@ -76,7 +77,7 @@ def _clear_overrides():
# ── GET list ───────────────────────────────────────────────────────────────────
def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET ?user_id= → 200 + пустой список."""
mock = MagicMock()
_override_db(mock)
@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
"app.api.v1.admin_weight_profiles.list_profiles",
lambda db, user_id: [],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-x"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json() == []
@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
_clear_overrides()
def test_list_returns_profiles(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET ?user_id= → 200 + список профилей."""
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
mock = MagicMock()
@ -108,10 +106,9 @@ def test_list_returns_profiles(
"app.api.v1.admin_weight_profiles.list_profiles",
lambda db, user_id: profiles,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
body = r.json()
@ -124,7 +121,7 @@ def test_list_returns_profiles(
# ── POST create ────────────────────────────────────────────────────────────────
def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST создаёт профиль, возвращает его со статусом 201."""
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
mock = MagicMock()
@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
"app.api.v1.admin_weight_profiles.create_profile",
lambda db, payload: created,
)
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
"weights": {"school": 2.0, "park": 1.5},
"is_default": False,
},
headers=_HEADERS,
)
assert r.status_code == 201
body = r.json()
@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
_clear_overrides()
def test_create_validation_unknown_category(client_with_token: TestClient) -> None:
def test_create_validation_unknown_category(client: TestClient) -> None:
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
"profile_name": "Bad",
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
},
headers=_HEADERS,
)
assert r.status_code == 422
def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None:
def test_create_validation_weight_out_of_bounds(client: TestClient) -> None:
"""POST с весом вне [-2, 3] → 422."""
r = client_with_token.post(
r = client.post(
"/api/v1/admin/site-finder/weight-profiles",
json={
"user_id": "user-1",
"profile_name": "Bad",
"weights": {"school": 99.0},
},
headers=_HEADERS,
)
assert r.status_code == 422
@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -
# ── GET one ────────────────────────────────────────────────────────────────────
def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET /{id}?user_id= → 200."""
profile = _make_profile(7)
mock = MagicMock()
@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
"app.api.v1.admin_weight_profiles.get_profile",
lambda db, user_id, profile_id: profile,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles/7",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json()["id"] == 7
@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
_clear_overrides()
def test_get_profile_not_found(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET /{id} несуществующего профиля → 404."""
mock = MagicMock()
_override_db(mock)
@ -216,10 +207,9 @@ def test_get_profile_not_found(
"app.api.v1.admin_weight_profiles.get_profile",
lambda db, user_id, profile_id: None,
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -229,7 +219,7 @@ def test_get_profile_not_found(
# ── PUT update ─────────────────────────────────────────────────────────────────
def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""PUT /{id} → 200 + обновлённый профиль."""
updated = _make_profile(3, profile_name="Обновлённый")
mock = MagicMock()
@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
"app.api.v1.admin_weight_profiles.update_profile",
lambda db, user_id, profile_id, payload: updated,
)
r = client_with_token.put(
r = client.put(
"/api/v1/admin/site-finder/weight-profiles/3",
params={"user_id": "user-1"},
json={"profile_name": "Обновлённый"},
headers=_HEADERS,
)
assert r.status_code == 200
assert r.json()["profile_name"] == "Обновлённый"
@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
_clear_overrides()
def test_update_profile_not_found(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""PUT /{id} несуществующего → 404."""
mock = MagicMock()
_override_db(mock)
@ -262,11 +249,10 @@ def test_update_profile_not_found(
"app.api.v1.admin_weight_profiles.update_profile",
lambda db, user_id, profile_id, payload: None,
)
r = client_with_token.put(
r = client.put(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
json={"profile_name": "X"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -276,7 +262,7 @@ def test_update_profile_not_found(
# ── DELETE ─────────────────────────────────────────────────────────────────────
def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE /{id} → 204."""
mock = MagicMock()
_override_db(mock)
@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke
"app.api.v1.admin_weight_profiles.delete_profile",
lambda db, user_id, profile_id: True,
)
r = client_with_token.delete(
r = client.delete(
"/api/v1/admin/site-finder/weight-profiles/5",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 204
finally:
_clear_overrides()
def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""DELETE /{id} несуществующего → 404."""
mock = MagicMock()
_override_db(mock)
@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
"app.api.v1.admin_weight_profiles.delete_profile",
lambda db, user_id, profile_id: False,
)
r = client_with_token.delete(
r = client.delete(
"/api/v1/admin/site-finder/weight-profiles/999",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 404
finally:
@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
def test_list_include_system_calls_with_system(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
system_profile = _make_profile(
@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system(
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
lambda db, user_id: [user_profile, system_profile],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1", "include_system": "true"},
headers=_HEADERS,
)
assert r.status_code == 200
body = r.json()
@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system(
def test_list_without_include_system_does_not_call_with_system(
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GET без include_system → list_profiles (только пользовательские профили)."""
user_profile = _make_profile(1, user_id="user-1")
@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system(
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
lambda db, user_id: called_with_system.append(True) or [],
)
r = client_with_token.get(
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers=_HEADERS,
)
assert r.status_code == 200
assert len(r.json()) == 1
@ -399,41 +381,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N
assert len(captured_params) == 1
assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID
assert captured_params[0]["user_id"] == "user-test"
# ── Auth ───────────────────────────────────────────────────────────────────────
_TOKEN_REMOVED_REASON = (
"App-level X-Admin-Token header удалён 2026-05-23 (см. docstring "
"app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, "
"двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. "
"В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта "
"security-решения. Тест проверял удалённое поведение."
)
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app)
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
# без headers — нет X-Admin-Token
)
assert r.status_code == 401
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app)
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers={"X-Admin-Token": "wrong-token"},
)
assert r.status_code == 401

View file

@ -2,14 +2,27 @@
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
и что scrub_sensitive_query redact-ит api keys из URL spans.
что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
company/message) из request.data/extra/contexts (#2457-review), и — в конце
файла что до транспорта не доезжают ни ПДн тела запроса, ни значения
локальных переменных кадра стека, ни второе событие о сбое самого скраба
(#2753, поведение через подставной транспорт вместо поиска подстроки).
"""
import json
import os
import pathlib
import subprocess
import sys
from functools import lru_cache
from unittest.mock import patch
import pytest
import sentry_sdk
_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
def test_sdk_imports_without_error() -> None:
"""Все интеграции импортируются без ModuleNotFoundError."""
@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None:
event: dict = {"request": {"url": "https://example.com"}}
result = scrub_sensitive_query(event, {})
assert result["request"]["url"] == "https://example.com"
# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ────────────
#
# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий
# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data,
# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk
# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором.
def test_scrub_pii_redacts_request_data() -> None:
"""scrub_pii_event заменяет client_name/phone/email в request.data."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {
"data": {
"client_name": "Иван Иванов",
"phone": "+79991234567",
"email": "ivan@example.com",
"address": "Екатеринбург, ул. Ленина 1",
}
}
}
result = scrub_pii_event(event, {})
data = result["request"]["data"]
assert data["client_name"] == "[REDACTED]"
assert data["phone"] == "[REDACTED]"
assert data["email"] == "[REDACTED]"
# non-PII поле не трогаем
assert data["address"] == "Екатеринбург, ул. Ленина 1"
def test_scrub_pii_redacts_pilot_request_company_and_message() -> None:
"""scrub_pii_event заменяет company/message — свободный текст
PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают
телефоны/имена/адреса, а не только фиксированные name/phone/email
(#2457-review)."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {
"data": {
"company": "ООО Ромашка",
"message": "Меня зовут Иван, звоните на +79991234567",
"source": "landing",
}
}
}
result = scrub_pii_event(event, {})
data = result["request"]["data"]
assert data["company"] == "[REDACTED]"
assert data["message"] == "[REDACTED]"
# non-PII поле не трогаем
assert data["source"] == "landing"
def test_scrub_pii_redacts_client_prefixed_keys() -> None:
"""Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) —
Птица их сегодня не использует, но одинаковый механизм на оба продукта
проще сопровождать (#2457-review)."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"extra": {
"client_name": "Иван",
"client_phone": "+79991234567",
"client_email": "ivan@example.com",
}
}
result = scrub_pii_event(event, {})
extra = result["extra"]
assert extra["client_name"] == "[REDACTED]"
assert extra["client_phone"] == "[REDACTED]"
assert extra["client_email"] == "[REDACTED]"
def test_scrub_pii_redacts_extra() -> None:
"""scrub_pii_event заменяет PII-ключи в extra, не трогая остальное."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"extra": {
"phone": "+79990000000",
"email": "x@y.ru",
"name": "Пётр",
"lead_id": 42,
}
}
result = scrub_pii_event(event, {})
extra = result["extra"]
assert extra["phone"] == "[REDACTED]"
assert extra["email"] == "[REDACTED]"
assert extra["name"] == "[REDACTED]"
assert extra["lead_id"] == 42
def test_scrub_pii_is_case_insensitive() -> None:
"""Сверка PII-ключей case-insensitive."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}}
result = scrub_pii_event(event, {})
assert result["extra"]["Client_Name"] == "[REDACTED]"
assert result["extra"]["PHONE"] == "[REDACTED]"
def test_scrub_pii_redacts_nested_contexts() -> None:
"""scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}}
result = scrub_pii_event(event, {})
assert result["contexts"]["lead"]["email"] == "[REDACTED]"
assert result["contexts"]["trace"]["op"] == "http"
def test_scrub_pii_leaves_non_pii_untouched() -> None:
"""scrub_pii_event не трогает event без PII-ключей."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {
"request": {"data": {"region": "66", "area_sqm": 50}},
"extra": {"job": "geocode"},
"level": "error",
}
result = scrub_pii_event(event, {})
assert result["request"]["data"] == {"region": "66", "area_sqm": 50}
assert result["extra"] == {"job": "geocode"}
assert result["level"] == "error"
def test_scrub_pii_handles_missing_sections() -> None:
"""scrub_pii_event не падает на пустом event."""
from app.observability.sentry_scrub import scrub_pii_event
result = scrub_pii_event({}, {})
assert result == {}
def test_scrub_pii_handles_none_and_non_dict_sections() -> None:
"""scrub_pii_event не бросает исключений на None/не-dict секциях."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"}
result = scrub_pii_event(event, {})
assert result is event
def test_scrub_pii_returns_event_not_none() -> None:
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
from app.observability.sentry_scrub import scrub_pii_event
event: dict = {"request": {"data": {"client_name": "X"}}}
result = scrub_pii_event(event, {})
assert result is not None
assert result is event
# ── scrub_event (composed before_send / before_send_transaction handler) ───────
#
# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА
# канала (before_send И before_send_transaction). До #2457-review composed-хук
# висел только на before_send, а before_send_transaction оставался на голом
# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data
# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь
# через transaction-канал при glitchtip_traces_sample_rate > 0.
def test_scrub_event_composes_pii_and_url_secret_scrub() -> None:
"""scrub_event применяет PII-scrub (ключ-based) И URL query-string secret
redact (regex) оба разом, не заменяя друг друга разные классы данных."""
from app.observability.sentry_scrub import scrub_event
event: dict = {
"request": {
"data": {"client_name": "Иван"},
"url": "https://example.com?api_key=supersecret",
}
}
result = scrub_event(event, {})
assert result is not None
assert result["request"]["data"]["client_name"] == "[REDACTED]"
assert "[REDACTED]" in result["request"]["url"]
assert "supersecret" not in result["request"]["url"]
def test_scrub_event_returns_event_not_none() -> None:
"""scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"name": "X"}}}
result = scrub_event(event, {})
assert result is not None
assert result is event
def test_scrub_event_survives_scrub_pii_event_exception() -> None:
"""try/except в scrub_event — предохранитель: sentry_sdk оборачивает
before_send в capture_internal_exceptions, который при исключении ТОЛЬКО
логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал).
Если scrub_pii_event падает scrub_event обязан вернуть event, а не
пробросить исключение дальше (#2457-review)."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"client_name": "X"}}}
with patch(
"app.observability.sentry_scrub.scrub_pii_event",
side_effect=RuntimeError("boom"),
):
result = scrub_event(event, {})
assert result is not None
assert result is event
def test_scrub_event_survives_scrub_sensitive_query_exception() -> None:
"""То же самое для второго шага композиции (URL-secret redact)."""
from app.observability.sentry_scrub import scrub_event
event: dict = {"request": {"data": {"name": "X"}}}
with patch(
"app.observability.sentry_scrub.scrub_sensitive_query",
side_effect=RuntimeError("boom"),
):
result = scrub_event(event, {})
assert result is not None
assert result is event
# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ─────
#
# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в
# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут
# многострочные комментарии, где те же подстроки встречаются, — достаточно
# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не
# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом
# before_send (локальные переменные кадра стека уходят мимо ключевого скраба).
#
# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе
# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него
# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а
# `capture_envelope` подменён до первого события.
@lru_cache(maxsize=2)
def _probe(module: str) -> str:
"""Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку."""
env = {
**os.environ,
"TESTING": "1",
# Синтаксически валидный DSN на несуществующий хост: init отработает,
# сети не будет даже если транспорт когда-нибудь перестанут подменять.
"GLITCHTIP_DSN": "https://probe@localhost.invalid/1",
# Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/),
# и без этого `import app` уехал бы в editable-установку пакета — то есть
# проба мерила бы чужое дерево, а не то, что рядом с ней лежит.
"PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]),
}
proc = subprocess.run(
[sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module],
cwd=_BACKEND_ROOT,
env=env,
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}"
return proc.stdout.strip().splitlines()[-1]
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_pii_never_reaches_transport(module: str) -> None:
"""Оба канала (error И transaction) отдают транспорту событие без ПДн.
Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ
`before_send_transaction` комментарий с теми же словами не спасает.
"""
probe = json.loads(_probe(module))
markers = probe["markers"]
for channel in ("error", "transaction"):
payload = probe["payloads"][channel]
# Контроль «событие вообще доехало»: без него проверка была бы зелёной
# и на пробе, которая молча ничего не отправила.
assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало"
assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал"
leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload]
assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}"
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_local_variables_never_reach_transport(module: str) -> None:
"""`include_local_variables=False`: значения локальных переменных кадра стека
не уходят в мониторинг (#2753).
Ключевой скраб такое не ловит по построению имя переменной произвольно,
а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()`
(в sentry-sdk он по умолчанию `True`).
"""
probe = json.loads(_probe(module))
payload = probe["payloads"]["exception"]
assert probe["counts"]["exception"] == 1
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
assert (
probe["markers"]["local_var"] not in payload
), f"{module}: значение локальной переменной ушло в мониторинг"
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
"""Сбой самого скраба не порождает ВТОРОГО события (#2753).
`logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об
отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR),
снова попала бы в скраб, снова упала рекурсия, защиты от которой в SDK
нет (проверено на коде до фикса: не завершается). Красный, если
`ignore_logger` убрать: обработчик войдёт повторно.
"""
probe = json.loads(_probe(module))
assert (
probe["scrub_handler_entries"] == 1
), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
assert probe["counts"]["scrub_failure"] == 1

View file

@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
- resolve_weights: нет user_id и profile_id системные дефолты
- resolve_weights: user_id задан, default-профиль есть его веса
- resolve_weights: profile_id задан его веса
- resolve_weights: профиль не найден системные дефолты (fallback)
- resolve_weights: профиль не найден системные дефолты (fallback) + source != profile
"""
from __future__ import annotations
import logging
from unittest.mock import MagicMock
import pytest
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
"""Оба аргумента None → возвращаются системные веса."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
# db не должен вызываться вообще
db.execute.assert_not_called()
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
result["school"] = 999.0
result.weights["school"] = 999.0
# Оригинал не изменён
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
finally:
wp_module.get_default_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "user_default"
def test_resolve_weights_uses_specific_profile() -> None:
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
finally:
wp_module.get_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "profile"
def test_resolve_weights_profile_not_found_fallback() -> None:
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
assert result.source == "system"
def test_resolve_weights_empty_profile_weights_fallback() -> None:
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
finally:
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
def test_resolve_weights_profile_id_without_owner_is_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
-0.4). Метка обязана быть 'system', а промах попасть в warning.
"""
db = MagicMock()
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id=None, profile_id=1)
assert result.source == "system"
assert result.weights == _SYSTEM_POI_WEIGHTS
assert "profile_id=1" in caplog.text
db.execute.assert_not_called() # профиль даже не искали
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
видна. Метка должна сказать 'user_default', а не 'profile'.
"""
import app.services.site_finder.weight_profiles as wp_module
default_profile = _make_profile_mock({"school": 2.0})
db = MagicMock()
original_get = wp_module.get_profile
original_default = wp_module.get_default_profile
wp_module.get_profile = lambda _db, uid, pid: None
wp_module.get_default_profile = lambda _db, uid: default_profile
try:
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id="user-1", profile_id=999)
finally:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result.source == "user_default"
assert result.weights == {"school": 2.0}
assert "profile_id=999" in caplog.text

View file

@ -17,10 +17,66 @@
#
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
# Volumes are shared with docker-compose.yml so switching between files preserves data.
#
# ── logging: journald (#2761) ────────────────────────────────────────────────
# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
# и живёт в /var/lib/docker/containers/<id>/ (умирает вместе с контейнером).
# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
# способ на обе половины, расхождение двух стеков дороже в поддержке.
#
# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
# postgres 2.2 ← 162 МБ за 74 дня
# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
# ИТОГО ~39 МБ/сутки
# Бюджет journald (замер там же, сообщение самого systemd-journald):
# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
#
# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
# # История через пересоздания: журнал принадлежит root:systemd-journal, а
# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
# # "No entries". Рабочий однострочник — через docker-группу:
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=<id>
# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
#
# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
# ночных прогонов.
# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
# host drop-in journald.conf.d, не этот файл.
# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
#
# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
x-logging: &default-logging
driver: journald
options:
tag: "{{.Name}}"
services:
postgres:
image: postgis/postgis:16-3.4
logging: *default-logging
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
@ -46,6 +102,7 @@ services:
redis:
image: redis:7-alpine
logging: *default-logging
restart: unless-stopped
volumes:
- redis_data:/data
@ -54,6 +111,40 @@ services:
interval: 10s
timeout: 3s
retries: 5
# #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
# его достать. До этого redis жил только в gendesign_default, а tradein — в
# gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
# резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
# отсутствующая связность (см. #2709).
#
# Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
# поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
# ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
# сервис в tradein-compose в этот список не попадает и `--no-deps` его не
# подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
# бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
# сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
#
# aliases: тот же приём, что уже применён к postgres выше — стабильное имя
# gendesign-redis вместо compose-зависимого gendesign-redis-1.
# ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
# блок networks:, неявная привязка к default пропадает, и backend/worker/
# beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
#
# Разделение ключей — по НОМЕРУ БД, инстанс общий:
# db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
# db1 — trade-in (SearchCache) ← вводится здесь
# db2 — glitchtip (см. REDIS_URL ниже)
# Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
# брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
# обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
# tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
# celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
networks:
default: {}
shared:
aliases:
- gendesign-redis
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
@ -76,6 +167,7 @@ services:
# отдельно (docs/osrm-routing.md).
osrm:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
volumes:
@ -103,6 +195,7 @@ services:
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
osrm-walk:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes:
@ -118,6 +211,7 @@ services:
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
# required: false — compose не падает если файла нет (первый деплой).
@ -153,6 +247,7 @@ services:
frontend:
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
@ -172,6 +267,7 @@ services:
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@ -201,6 +297,7 @@ services:
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
@ -219,6 +316,7 @@ services:
glitchtip-web:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-web
logging: *default-logging
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
# Bootstrap script activates the profile after DB + secrets are ready.
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
@ -254,6 +352,7 @@ services:
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
logging: *default-logging
profiles: ["glitchtip"]
depends_on:
postgres:
@ -272,6 +371,7 @@ services:
caddy:
image: caddy:2
logging: *default-logging
restart: unless-stopped
ports:
- "80:80"
@ -310,6 +410,7 @@ services:
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
build: ./ops/glitchtip-auth-forwarder
container_name: gendesign-auth-forwarder
logging: *default-logging
restart: unless-stopped
environment:
GLITCHTIP_DSN: ${GLITCHTIP_DSN}

View file

@ -69,7 +69,7 @@
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
---
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
Поле оставлено в `core/deps.py` только для быстрого rollback.
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
**Ротировать нечего: переменной больше нет.**
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
(процедура: `sed` в `backend/.env.runtime``up -d --force-recreate --no-deps backend beat`,
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
(Anton), не выполняется в рамках этого PR.
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
`Settings.model_config``extra="ignore"`, лишняя переменная безвредна.
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует**
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.

View file

@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
@ -22,6 +22,7 @@ import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import { useMe } from "@/lib/useMe";
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
@ -129,16 +130,16 @@ function SiteFinderContent() {
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
const weightsChangeInitializedRef = useRef(false);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
// который сервер не читает с #437) — оба недостижимы без DevTools.
//
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
// user_id → tram_stop 0.5 вместо 0.4 из профиля). То есть выбранный профиль
// молча не применялся бы, а UI показывал бы его ползунки.
const profileUserId = useMe().data?.username ?? "";
// Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => {
@ -192,12 +193,7 @@ function SiteFinderContent() {
setIsochrones(undefined);
mutate({
cad: currentData.cad_num,
options:
profileId != null
? { profileId }
: currentProfileUserId
? { profileUserId: currentProfileUserId, weights }
: { weights },
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
});
// mutate is stable from useMutation — safe to omit from deps.
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
@ -216,12 +212,11 @@ function SiteFinderContent() {
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
options: buildAnalyzeOptions(
currentWeights,
activeProfileId,
profileUserId,
),
});
}
@ -329,46 +324,9 @@ function SiteFinderContent() {
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}

View file

@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import {
AnalyzeWeightsContext,
adaptEgrn,
useParcelAnalyzeQuery,
} from "@/lib/site-finder-api";
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
import type {
ParcelAnalysis,
PendingConceptProgram,
@ -29,7 +34,40 @@ interface Props {
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
/**
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
* анализа свой у шапки, свой у секций.
*
* null = веса не применяли запрос как раньше, без тела.
*/
export function AnalysisPageContent({ cad }: Props) {
const [appliedWeights, setAppliedWeights] = useState<Record<
PoiCategoryKey,
number
> | null>(null);
return (
<AnalyzeWeightsContext.Provider value={appliedWeights}>
<AnalysisPageBody
cad={cad}
appliedWeights={appliedWeights}
onWeightsApply={setAppliedWeights}
/>
</AnalyzeWeightsContext.Provider>
);
}
function AnalysisPageBody({
cad,
appliedWeights,
onWeightsApply,
}: Props & {
appliedWeights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [horizon, setHorizon] = useState<number>(12);
const queryClient = useQueryClient();
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
<GroupDivider label="Стройка и рынок" />
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* 4. Рынок и конкуренты IMPLEMENTED in A7. Веса POI из §4.1
поднимаем сюда: «Применить» меняет ключ analyze-запроса скор
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
<Section3SettingsAndCompetitors
cad={cad}
data={analysis}
weights={appliedWeights}
onWeightsApply={onWeightsApply}
/>
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />

View file

@ -0,0 +1,159 @@
/**
* #2790 п.1 «Применить» у весов POI в §4.1 ничего не применяло.
*
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
*
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес карты,
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
* POST /analyze не случается вообще красный.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalysisPageContent } from "../AnalysisPageContent";
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
GateVerdictBanner: () => null,
}));
vi.mock("@/components/site-finder/HorizonSelector", () => ({
HorizonSelector: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
Section1ParcelInfo: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
Section2NetworksUtilities: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
Section4Estimate: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
Section5Atmosphere: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
Section6Forecast: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
Section7Concept: () => null,
}));
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
SectionAlternatives: () => null,
}));
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
BestLayoutsBlock: () => null,
}));
const CAD = "66:41:0702017:131";
const ANALYSIS = {
cad_num: CAD,
score: 18.91,
district: { district_name: "Чкаловский" },
egrn: null,
competitors: [],
};
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
analyzeBodies.length = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(async (input, init) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/analyze")) {
const raw = init?.body;
analyzeBodies.push(
typeof raw === "string"
? (JSON.parse(raw) as Record<string, unknown>)
: undefined,
);
return jsonResponse(ANALYSIS);
}
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<AnalysisPageContent cad={CAD} />
</QueryClientProvider>,
);
}
/** Ползунок конкретной категории по подписи строки в панели весов. */
function sliderFor(label: string): HTMLInputElement {
const row = screen.getByText(label).closest("div");
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
const input = row.querySelector('input[type="range"]');
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
return input as HTMLInputElement;
}
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
it("отправляет ползунки в тело повторного analyze", async () => {
renderPage();
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
// бэкенд считает по системным. Это же и baseline для «стало другим».
await waitFor(() => expect(analyzeBodies.length).toBe(1));
expect(analyzeBodies[0]).toBeUndefined();
fireEvent.click(await screen.findByText("POI Веса"));
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
fireEvent.change(sliderFor("Трамвайные ост. ()"), {
target: { value: "-2" },
});
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
await waitFor(() => expect(analyzeBodies.length).toBe(2));
const applied = analyzeBodies[1]?.weights as Record<string, number>;
expect(applied.park).toBe(3);
expect(applied.tram_stop).toBe(-2);
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
expect(applied.school).toBe(1.5);
});
});

View file

@ -10,8 +10,8 @@
/* ===================== SCOPED TOKENS (dark) ===================== */
.pticaRoot[data-theme="dark"] {
--font-ui:
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
system-ui, sans-serif;
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui,
sans-serif;
--font-mono:
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
monospace;
@ -907,31 +907,6 @@
letter-spacing: 0.08em;
}
/* ===================== PLACEHOLDER PANEL ===================== */
.placeholderPanel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
min-height: 240px;
border-style: dashed;
}
.placeholderPanel .soon {
font-size: 22px;
letter-spacing: 0.12em;
color: var(--text-soft);
font-weight: 700;
text-transform: uppercase;
}
.placeholderPanel p {
font-size: 10px;
color: var(--text-soft);
margin: 0;
max-width: 280px;
}
/* ===================== STATE SCREENS ===================== */
.stateScreen {
display: grid;

View file

@ -526,14 +526,15 @@ function VariantPanel({
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
: ""}
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
Входной НДС по строительству уже учтён в себестоимости. Налог на
прибыль 25% (с 2025 года). Цена продажи жилья {" "}
РФ); НДС начисляется на нежилое паркинг (машиноместа) и
коммерцию 1-го этажа. Входной НДС по строительству уже учтён в
себестоимости. Налог на прибыль 25% (с 2025 года). Цена продажи
жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
; себестоимость СМР, цена паркинга и цена нежилого нормативные
ориентиры.
{financial.financing_enabled && financial.financing_is_simplified
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
"кредитом по ставке-нормативу, проценты капитализируются, " +
@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (паркинг)"
label="НДС (паркинг + коммерция)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow

View file

@ -1,5 +0,0 @@
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
// File kept to avoid breaking any external imports; exports an empty stub.
export {};

View file

@ -3,12 +3,14 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
POI_LABELS,
POI_WEIGHT_MAX,
POI_WEIGHT_MIN,
SYSTEM_PROFILE_USER_ID,
useCreateProfile,
useWeightProfiles,
type PoiCategoryKey,
@ -29,13 +31,6 @@ interface Props {
weights: Record<PoiCategoryKey, number>,
profileId: number | null,
) => void;
/**
* If provided, enables save/load from DB.
* Must be non-empty for CRUD functionality.
*/
userId?: string;
/** Admin token for CRUD API calls. */
adminToken?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -66,12 +61,7 @@ function weightsEqual(
// ── Component ─────────────────────────────────────────────────────────────────
export function WeightProfilePanel({
currentWeights,
onWeightsChange,
userId,
adminToken,
}: Props) {
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
const [open, setOpen] = useState(false);
// Local draft weights — editable before "Применить"
@ -89,11 +79,19 @@ export function WeightProfilePanel({
const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false);
// Profiles query (only when userId + adminToken provided)
const canUseCrud = !!userId && !!adminToken;
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
const { data: me } = useMe();
const userId = me?.username ?? "";
const canUseCrud = !!userId;
const profilesQuery = useWeightProfiles(userId);
const createMutation = useCreateProfile(adminToken ?? "");
const createMutation = useCreateProfile();
// ── Handlers ────────────────────────────────────────────────────────────────
@ -113,11 +111,22 @@ export function WeightProfilePanel({
}
function handleApply() {
onWeightsChange({ ...draft }, selectedProfileId);
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
// а они ровно те, что на ползунках.
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
const addressableId =
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
? selected.id
: null;
onWeightsChange({ ...draft }, addressableId);
}
const handleSaveProfile = useCallback(async () => {
if (!canUseCrud || !userId || !adminToken) {
if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@ -136,15 +145,7 @@ export function WeightProfilePanel({
} catch {
// Error visible through createMutation.error
}
}, [
canUseCrud,
userId,
adminToken,
saveName,
draft,
saveDefault,
createMutation,
]);
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
// ── Derived ─────────────────────────────────────────────────────────────────
@ -242,6 +243,7 @@ export function WeightProfilePanel({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
>
Профиль:
</label>
@ -268,6 +270,7 @@ export function WeightProfilePanel({
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.profile_name}
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
{p.is_default ? " ★" : ""}
</option>
))}
@ -278,10 +281,13 @@ export function WeightProfilePanel({
</div>
)}
{/* Hint when no crud */}
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
Caddy basic_auth) остаётся для dev-запуска без прокси, где
/api/v1/me отвечает 401. Текст называет причину, а не действие:
вводить тут больше нечего. */}
{!canUseCrud && (
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
Укажите User ID и Admin Token для сохранения профилей.
Пользователь не определён сохранение профилей недоступно.
</p>
)}
@ -362,6 +368,9 @@ export function WeightProfilePanel({
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
Сохранится для пользователя {userId} другие его не увидят.
</p>
<input
autoFocus
type="text"

View file

@ -0,0 +1,151 @@
/**
* #2782 CRUD профилей весов был недостижим никому без DevTools.
*
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) то есть
* фича стояла за признаком, который ничего не решал.
*
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
* 2) в запросы CRUD не уходит X-Admin-Token;
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
* считает по системным весам, рапортуя source="profile").
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WeightProfilePanel } from "../WeightProfilePanel";
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const PROFILE = {
id: 1,
user_id: "admin",
profile_name: "Мой профиль",
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
is_default: true,
description: null,
created_at: "2026-05-15T05:30:51Z",
updated_at: "2026-05-15T05:30:51Z",
};
function renderPanel() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<WeightProfilePanel
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
onWeightsChange={() => {}}
/>
</QueryClientProvider>,
);
}
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
async function openPanel() {
const { default: userEvent } = await import("@testing-library/user-event");
await userEvent.setup().click(screen.getByText("POI Веса"));
}
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockImplementation(async (input) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([PROFILE]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
renderPanel();
await openPanel();
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.getByText("Профиль:")).toBeTruthy();
});
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
renderPanel();
await openPanel();
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.queryByText(/Admin Token/i)).toBeNull();
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
});
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
renderPanel();
await openPanel();
await waitFor(() => {
const crudCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes("/weight-profiles"),
);
expect(crudCall).toBeTruthy();
const headers = new Headers(
(crudCall?.[1] as RequestInit | undefined)?.headers,
);
expect(headers.has("X-Admin-Token")).toBe(false);
});
});
});
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
const weights = { ...POI_DEFAULT_WEIGHTS };
it("выбранный профиль уходит вместе с profileUserId", () => {
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
profileId: 7,
profileUserId: "admin",
});
});
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
const options = buildAnalyzeOptions(weights, 7, "");
expect(options.profileId).toBeUndefined();
expect(options.weights).toEqual(weights);
});
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
profileUserId: "admin",
weights,
});
});
});

View file

@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
interface AnalysisBreadcrumbProps {
cadNum: string;
}
export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexWrap: "wrap",
minWidth: 0,
flex: 1,
}}
>
{/* SiteFinder root */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
SiteFinder
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Cad number */}
<Link
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 260,
}}
title={cadNum}
>
{cadNum}
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Current page */}
<span
aria-current="page"
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
whiteSpace: "nowrap",
}}
>
Анализ
</span>
</nav>
);
}

View file

@ -1,266 +0,0 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ExternalLink } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SubSection {
id: string;
label: string;
}
interface NavSection {
id: string;
label: string;
sub?: SubSection[];
}
// ── Config ────────────────────────────────────────────────────────────────────
const NAV_SECTIONS: NavSection[] = [
{ id: "section-1", label: "1. Объект" },
{ id: "section-2", label: "2. Земля и риски" },
{
id: "section-3",
label: "3. Рынок",
sub: [
{ id: "section-3-1", label: "3.1 Настройки выборки" },
{ id: "section-3-2", label: "3.2 Планировки" },
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
],
},
{ id: "section-4", label: "4. Оценка" },
{ id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз",
sub: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
];
// All section IDs in scroll order (for IntersectionObserver)
const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
);
// ── Component ─────────────────────────────────────────────────────────────────
export function AnalysisSidebar() {
const [activeId, setActiveId] = useState<string>(ALL_SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
// Scrollspy via IntersectionObserver
useEffect(() => {
const candidates = ALL_SECTION_IDS.map((id) =>
document.getElementById(id),
).filter((el): el is HTMLElement => el !== null);
if (candidates.length === 0) return;
// Track which sections are visible; pick topmost visible one
const visible = new Set<string>();
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
visible.add(entry.target.id);
} else {
visible.delete(entry.target.id);
}
});
// Pick the topmost section that is currently visible
const next = ALL_SECTION_IDS.find((id) => visible.has(id));
if (next) setActiveId(next);
},
{
root: null,
// Trigger when section top enters top 60% of viewport
rootMargin: "-8px 0px -40% 0px",
threshold: 0,
},
);
candidates.forEach((el) => observerRef.current!.observe(el));
return () => {
observerRef.current?.disconnect();
};
}, []);
function handleAnchorClick(
e: React.MouseEvent<HTMLAnchorElement>,
targetId: string,
) {
e.preventDefault();
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
setActiveId(targetId);
}
return (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 4,
position: "sticky",
top: 56,
height: "calc(100vh - 56px)",
overflowY: "auto",
}}
>
{/* Section label */}
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 8px",
padding: "0 4px",
}}
>
Навигация
</p>
{/* Nav items */}
{NAV_SECTIONS.map((section) => {
const isParentActive =
activeId === section.id ||
section.sub?.some((s) => s.id === activeId);
return (
<div key={section.id}>
<a
href={`#${section.id}`}
onClick={(e) => handleAnchorClick(e, section.id)}
style={{
display: "block",
padding: "7px 10px",
borderRadius: 8,
fontSize: 13,
fontWeight: isParentActive ? 600 : 400,
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
background: isParentActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{section.label}
</a>
{/* Sub-sections */}
{section.sub && (
<div
style={{
paddingLeft: 12,
marginTop: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{section.sub.map((sub) => {
const isSubActive = activeId === sub.id;
return (
<a
key={sub.id}
href={`#${sub.id}`}
onClick={(e) => handleAnchorClick(e, sub.id)}
style={{
display: "block",
padding: "5px 10px",
borderRadius: 6,
fontSize: 12,
fontWeight: isSubActive ? 600 : 400,
color: isSubActive
? "var(--accent)"
: "var(--fg-tertiary)",
background: isSubActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{sub.label}
</a>
);
})}
</div>
)}
</div>
);
})}
{/* Sources footer */}
<div
style={{
marginTop: "auto",
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<p
style={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 6px",
padding: "0 4px",
}}
>
Источники
</p>
{[
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
{ label: "НСПД", href: "https://nspd.gov.ru" },
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
].map(({ label, href }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
fontSize: 11,
color: "var(--fg-tertiary)",
textDecoration: "none",
}}
>
<ExternalLink size={10} strokeWidth={1.5} />
{label}
</a>
))}
</div>
</aside>
);
}

View file

@ -1,410 +0,0 @@
"use client";
/**
* MassingEconomics LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
* epic #1953).
*
* Driven by the interactive 3D MassingScene: every time the user drags the
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
* result + the analysis context into a `MassingProgram` and hands it here via
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
* прибыль / ROI).
*
* Robustness:
* debounce slider drags fire many programs; only the settled one is sent.
* latest-wins an in-flight request is superseded by a newer one via a
* monotonic request id; a stale response is dropped, never overwriting a
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
* last-good on a failed recompute we keep the last successful values and
* show a subtle inline note rather than blanking the panel.
* skeleton a plain grey fade KPI grid while the FIRST recompute is in
* flight (no shimmer, per ui-conventions).
*
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
* this strip uses the light KPI tokens via the shared KpiCard.
*/
import { useEffect, useRef, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { KpiCard } from "@/components/analytics/KpiCard";
import { Section } from "@/components/analytics/Section";
import {
priceSourceCaption,
useRecomputeMassing,
type FinancialModel,
type MassingProgram,
type MassingRecomputeOutput,
type Teap,
} from "@/lib/concept-api";
const DEBOUNCE_MS = 250;
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
function formatMoneyCompact(rub: number): string {
const abs = Math.abs(rub);
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
return `${nf.format(Math.round(rub))}`;
}
function formatInt(n: number): string {
return nf.format(Math.round(n));
}
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
function formatFar(far: number): string {
return far.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// ── KPI grid ───────────────────────────────────────────────────────────────────
interface KpiGridProps {
teap: Teap;
financial: FinancialModel;
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
farTarget: number;
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
ksitOver: boolean;
/** Dim the strip while a fresher recompute is in flight (last-good values). */
stale: boolean;
}
function KpiGrid({
teap,
financial,
farTarget,
ksitOver,
stale,
}: KpiGridProps) {
const netPositive =
financial.net_profit_rub > 0
? true
: financial.net_profit_rub < 0
? false
: null;
return (
<div
style={{
opacity: stale ? 0.55 : 1,
transition: "opacity 150ms linear",
}}
>
{/* ТЭП */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Общая площадь (GFA)"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
/>
<KpiCard
label="Продаваемая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
hint="Жилая к продаже = (GFA нежилой 1-й этаж) × коэффициент эффективности класса."
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
/>
<KpiCard
label="КСИТ — факт / цель"
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
delta={{
value: ksitOver
? "Факт превышает регламентный потолок"
: "В пределах регламента",
positive: ksitOver ? false : true,
}}
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
/>
</div>
{/* Финмодель */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginTop: 12,
}}
>
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
/>
<KpiCard
label="Себестоимость"
value={formatMoneyCompact(financial.cost_rub)}
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
/>
<KpiCard
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
delta={{
value:
netPositive === true
? "Положительная (после НДС и налога на прибыль)"
: netPositive === false
? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая",
positive: netPositive,
}}
hint="Выручка себестоимость НДС на нежилое налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
/>
<KpiCard
label="ROI на затраты"
value={formatPct(financial.roi)}
delta={{
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
financial.discount_rate_used,
)} годовых).`}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: "Дисконтированный денежный поток",
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{ value: priceSourceCaption(financial), positive: null }}
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
/>
</div>
</div>
);
}
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
function SkeletonGrid() {
const cells = Array.from({ length: 7 });
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
aria-hidden="true"
>
{cells.map((_, i) => (
<div
key={i}
style={{
height: 92,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
}}
/>
))}
</div>
);
}
// ── Component ───────────────────────────────────────────────────────────────────
interface Props {
/**
* The current massing program (Σ footprint × floors + context), mapped by
* Section7Concept from the 3D scene's computeModel result. `null` until the
* scene has fired its first onModelChange (or when geometry is missing).
*/
program: MassingProgram | null;
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
farTarget: number;
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
ksitOver: boolean;
/**
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
* non-residential / ЗОУИТ-СЗЗ) drives the honest «условный расчёт» caveat
* below the strip alongside the negative-economics case.
*/
gateConstrained?: boolean;
}
export function MassingEconomics({
program,
farTarget,
ksitOver,
gateConstrained = false,
}: Props) {
const recompute = useRecomputeMassing();
// Last successful result kept locally so a failed/stale recompute never blanks
// the panel (last-good values stay on screen).
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
const [errored, setErrored] = useState(false);
// Monotonic request id → latest-wins: a response is applied only if it belongs
// to the most recently issued request, so out-of-order arrivals are dropped.
const reqIdRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable JSON key so we only recompute when the program actually changes
// (not on every parent re-render that hands an equal-but-new object).
const programKey = program ? JSON.stringify(program) : null;
useEffect(() => {
if (!program) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const id = ++reqIdRef.current;
recompute
.mutateAsync(program)
.then((out) => {
if (id !== reqIdRef.current) return; // stale — a newer request won.
setResult(out);
setErrored(false);
})
.catch(() => {
if (id !== reqIdRef.current) return; // stale failure — ignore.
setErrored(true); // keep last-good `result`.
});
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// recompute is a stable mutation object; we key off the serialized program.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [programKey]);
// No program yet → nothing to show (parent gates this on geometry anyway).
if (!program) return null;
// First recompute in flight, no last-good value yet → skeleton.
if (!result) {
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
>
<SkeletonGrid />
</Section>
);
}
// A fresher request is in flight over the last-good values.
const stale = recompute.isPending;
// Honest «условный расчёт» caveat: when the economics turn negative OR the
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
// we say so plainly rather than presenting the figures as a viable project.
const economicsNegative =
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
const showConditionalNote = economicsNegative || gateConstrained;
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
>
<KpiGrid
teap={result.teap}
financial={result.financial}
farTarget={farTarget}
ksitOver={ksitOver}
stale={stale}
/>
{showConditionalNote ? (
<p
role="note"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: "16px",
color: "var(--warn)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden="true"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
и/или экономика отрицательна при текущих вводных. Измените
этажность, число секций или класс модель пересчитается.
</span>
</p>
) : null}
{errored ? (
<p
role="status"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
color: "var(--warn)",
}}
>
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
Не удалось пересчитать экономику по последнему изменению показаны
предыдущие значения. Измените параметры ещё раз для повторного
расчёта.
</p>
) : null}
</Section>
);
}

View file

@ -28,6 +28,10 @@ interface Props {
cad: string;
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
data: ParcelAnalysis;
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
weights: Record<PoiCategoryKey, number> | null;
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}
interface FilterState {
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
function Section31Settings({
filters,
onFiltersChange,
weights,
onWeightsApply,
}: {
filters: FilterState;
onFiltersChange: (f: FilterState) => void;
weights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
() => ({ ...POI_DEFAULT_WEIGHTS }),
);
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
function handleWeightsChange(
newWeights: Record<PoiCategoryKey, number>,
_profileId: number | null,
) {
setWeights(newWeights);
}
const chips: Array<{
key: keyof Omit<FilterState, "radiusKm">;
label: string;
@ -136,8 +133,8 @@ function Section31Settings({
margin: "4px 0 0",
}}
>
Фильтры применяются к конкурентам локально без повторного запроса к
бэкенду
Радиус и фильтры применяются к конкурентам локально. Веса POI
пересчёт анализа на бэкенде по кнопке «Применить»
</p>
</div>
@ -259,8 +256,8 @@ function Section31Settings({
Профиль весов POI
</div>
<WeightProfilePanel
currentWeights={weights}
onWeightsChange={handleWeightsChange}
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
onWeightsChange={onWeightsApply}
/>
</div>
</div>
@ -769,7 +766,12 @@ function applyFilters(
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
export function Section3SettingsAndCompetitors({
cad,
data,
weights,
onWeightsApply,
}: Props) {
const [filters, setFilters] = useState<FilterState>({
radiusKm: 2,
onlyUnderConstruction: false,
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
<StageDetails>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<Section31Settings
filters={filters}
onFiltersChange={setFilters}
weights={weights}
onWeightsApply={onWeightsApply}
/>
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (

View file

@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Building2 } from "lucide-react";
// ── Helpers ───────────────────────────────────────────────────────────────────
function getStoredOrgId(): string | null {
// Guard against SSR — localStorage not available on server
if (typeof window === "undefined") return null;
try {
return localStorage.getItem("gd_org_id");
} catch {
return null;
}
}
function orgInitials(orgId: string): string {
// Build a 2-letter monogram from org ID string
const parts = orgId
.toUpperCase()
.replace(/[^A-ZА-Я0-9]/gu, " ")
.split(" ")
.filter(Boolean);
if (parts.length === 0) return "??";
if (parts.length === 1) return parts[0].slice(0, 2);
return parts[0][0] + parts[1][0];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function UserAvatar() {
const [orgId, setOrgId] = useState<string | null>(null);
// Hydration-safe: read localStorage after mount
useEffect(() => {
setOrgId(getStoredOrgId());
}, []);
const displayLabel = orgId ?? "Demo Org";
const initials = orgId ? orgInitials(orgId) : "DO";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
title={displayLabel}
>
{/* Avatar circle */}
<div
aria-hidden
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{orgId ? (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--accent)",
letterSpacing: "0.02em",
}}
>
{initials}
</span>
) : (
<Building2
size={14}
strokeWidth={1.5}
style={{ color: "var(--accent)" }}
/>
)}
</div>
{/* Org name — hidden on narrow viewports via maxWidth trick */}
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayLabel}
</span>
</div>
);
}

View file

@ -1,22 +0,0 @@
"use client";
/**
* PticaPlaceholderPanel honest "В разработке" panel for cockpit tabs/sections
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
interface Props {
label: string;
hint?: string;
}
export function PticaPlaceholderPanel({ label, hint }: Props) {
return (
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
<div className={styles.soon}>{label}</div>
{hint && <p>{hint}</p>}
</div>
);
}

View file

@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
weights?: Record<string, number> | null;
}
/**
* Собрать options для POST /analyze из состояния панели весов POI.
*
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
* #2782: `profile_id=1` без `profile_user_id` `tram_stop 0.5` вместо `0.4` из
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
*
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me 401) шлём inline
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
*/
export function buildAnalyzeOptions(
weights: Record<string, number>,
profileId: number | null,
profileUserId: string,
): AnalyzeOptions {
if (profileId != null && profileUserId) {
return { profileId, profileUserId };
}
if (profileUserId) {
return { profileUserId, weights };
}
return { weights };
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*

View file

@ -16,6 +16,7 @@
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
* timers, and assert on abort behaviour + the happy path.
*/
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Capture the options passed to useQuery ───────────────────────────────────
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
* flow doesn't pin it (the hook mutates it opaquely through the mock).
*
* `useQuery` is fully mocked (it just records its options, no React state), so
* the rules-of-hooks invariant does not apply to this call disable locally.
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
* dispatcher'а «Cannot read properties of null». `useQuery` по-прежнему
* замокан и просто записывает options; провайдера над хуком нет, значит
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
* тест и гоняет.
*/
function getQueryFn(): CapturedQueryOptions["queryFn"] {
// eslint-disable-next-line react-hooks/rules-of-hooks
useParcelAnalyzeQuery(CAD, 12);
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
const options = captured.options;
if (options === null) throw new Error("useQuery options not captured");
return options.queryFn;

View file

@ -27,14 +27,18 @@ export interface WeightProfileCreate {
description?: string | null;
}
export interface WeightProfileUpdate {
profile_name?: string;
weights?: Record<string, number>;
is_default?: boolean;
description?: string | null;
}
// ── Constants ─────────────────────────────────────────────────────────────────
/**
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) mirrors
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
* (#2782). Их веса уходят в analyze inline см. WeightProfilePanel.
*/
export const SYSTEM_PROFILE_USER_ID = "__system__";
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
@ -91,35 +95,44 @@ export const POI_WEIGHT_MAX = 3;
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
function profilesHeaders(adminToken: string): HeadersInit {
return { "X-Admin-Token": adminToken };
}
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
// с корректным токеном, с мусорным и без заголовка вовсе.
//
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
// (подставленный клиентом X-Authenticated-User туда же не проходит);
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
// ── Hooks ─────────────────────────────────────────────────────────────────────
/** List all weight profiles for a given user_id. */
export function useWeightProfiles(userId: string, adminToken: string) {
/**
* Профили пользователя + системные пресеты (#2790).
*
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
* Без него у пользователя без своих профилей дропдаун пустой пресеты лежали в
* проде с 16.05.2026 и не были видны никому.
*/
export function useWeightProfiles(userId: string) {
return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
{
headers: profilesHeaders(adminToken),
},
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
),
enabled: !!userId && !!adminToken,
enabled: !!userId,
});
}
/** Create a new weight profile. Invalidates the list query on success. */
export function useCreateProfile(adminToken: string) {
export function useCreateProfile() {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileCreate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(BASE_PATH, {
method: "POST",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
}),
onSuccess: (_, variables) => {
@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) {
});
}
/** Update an existing weight profile by id. */
export function useUpdateProfile(
userId: string,
profileId: number,
adminToken: string,
) {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: string) {
const qc = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (profileId) =>
apiFetch<void>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "DELETE",
headers: profilesHeaders(adminToken),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).

View file

@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
*
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
* with latest-wins sequencing handled by the caller (see MassingEconomics).
* with latest-wins sequencing handled by the caller.
*/
export function useRecomputeMassing() {
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({

View file

@ -7,7 +7,7 @@
* и иметь один безопасный канал кодирования кадастрового номера.
*
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
* NspdOpportunityBlock, ParcelDrawer.
*/
/**

View file

@ -9,6 +9,7 @@
*/
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import { abortableSleep } from "@/lib/abortableSleep";
import type {
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
const ANALYZE_POLL_INTERVAL_MS = 2000;
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
/**
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли запрос
* уходит без тела, как и раньше (бэкенд считает по системным весам).
*
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) все они делят один
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
* Контекст держит всех потребителей ключа на одном значении по построению
* забыть прокинуть проп в новую секцию нельзя.
*/
export const AnalyzeWeightsContext = createContext<Record<
string,
number
> | null>(null);
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
const weights = useContext(AnalyzeWeightsContext);
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
// #2790, кэш не сбрасывается на ровном месте.
const weightsKey = weights
? JSON.stringify(Object.entries(weights).sort())
: null;
return useQuery({
queryKey: ["parcel-analyze", cad, horizon],
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
// it through the POST/GET fetches and check it before each poll iteration so
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
cad,
)}/analyze?horizon=${horizon}`;
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
// расхождению между показанными весами и посчитанным скором взяться
// неоткуда (в отличие от profile_id, см. #2782).
const analyzeInit: RequestInit = weights
? { method: "POST", signal, body: JSON.stringify({ weights }) }
: { method: "POST", signal };
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
// Accepted code instead of treating it as a successful payload.
const first = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
// 200 → geometry was cached, full analysis is ready.
if (first.status === 200) {
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
// rather than returning the stub (symmetry with the first request).
const second = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
if (second.status === 200) {
return second.body as ParcelAnalyzeResponse;
}

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
чужой аналитической psql-сессией; вторая попытка деплоя ещё 16 минут. Опасность
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
data/sql то есть следующий блокирующий DDL повторил бы это.
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
CREATE INDEX он не оборвёт, а очередь не соберёт. Срабатывание = красный деплой
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
lock_timeout` 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
CONCURRENTLY` тот ждёт завершения параллельных транзакций через VirtualXactLock,
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
сессия просто держала открытую транзакцию (ACCESS SHARE с CIC вообще не
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
противоположной политики, поэтому granularity файл, а не раннер.
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
задним числом не переписываются. Гейт смотрит только вперёд.
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# каталог миграций -> минимальный NN, с которого правило обязательно.
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
SQL_DIRS: dict[str, int] = {
"data/sql": 189,
"tradein-mvp/backend/data/sql": 250,
}
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
# в очередь и увести за собой запросы приложения.
BLOCKING = re.compile(
r"\b(?:"
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
r")\b",
re.IGNORECASE,
)
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
# по-прежнему обязан прикрыть свой ALTER.
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
NN_PREFIX = re.compile(r"^(\d+)")
def strip_noise(sql: str) -> str:
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
пробелы (сохраняя длину и переводы строк номера строк не съезжают).
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
Тела $$...$$ (DO-блоки) НЕ затираются там живёт исполняемый DDL.
"""
out: list[str] = []
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if ch == "-" and nxt == "-":
while i < n and sql[i] != "\n":
out.append(" ")
i += 1
elif ch == "/" and nxt == "*":
depth = 1 # в PostgreSQL блочные комментарии вложенные
out.append(" ")
i += 2
while i < n and depth:
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
depth += 1
out.append(" ")
i += 2
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
depth -= 1
out.append(" ")
i += 2
else:
out.append("\n" if sql[i] == "\n" else " ")
i += 1
elif ch == "'":
out.append("'")
i += 1
while i < n:
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
out.append(" ")
i += 2
continue
if sql[i] == "'":
break
out.append("\n" if sql[i] == "\n" else " ")
i += 1
if i < n:
out.append("'")
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def scan(sql: str) -> list[str]:
"""-> список претензий к файлу; пустой список = файл в порядке."""
clean = strip_noise(sql)
statements = clean.split(";")
first_blocking: int | None = None
blocking_text = ""
for idx, stmt in enumerate(statements):
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
first_blocking = idx
blocking_text = " ".join(stmt.split())[:80]
break
if first_blocking is None:
return []
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
if set_local is None:
if any(SET_BARE_LT.search(s) for s in statements):
return [
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
]
return [
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
]
problems: list[str] = []
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
problems.append(
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
)
if set_local > first_blocking:
problems.append(
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
)
return problems
def selftest() -> None:
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert scan(ok) == [], scan(ok)
# красное: ровно случай 250 до фикса
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
# красное: правильная строка, но в местах, где она не действует
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert "без LOCAL" in scan(bare)[0]
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
mixed_ok = (
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
)
assert scan(mixed_ok) == [], scan(mixed_ok)
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
failed = False
checked = 0
for dirname, min_nn in SQL_DIRS.items():
sql_dir = Path(dirname)
if not sql_dir.is_dir():
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
return 1
for path in sorted(sql_dir.glob("*.sql")):
m = NN_PREFIX.match(path.name)
if not m or int(m.group(1)) < min_nn:
continue # применено на проде до внедрения гейта — не переписываем
checked += 1
for problem in scan(path.read_text(encoding="utf-8")):
failed = True
print(f"::error file={path}::{problem}")
if failed:
return 1
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
`host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
Список занятых портов не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
живут в bridge-сетях) сервис-контейнер с их портами ни с чем не столкнётся,
и запрещать их значило бы ронять сборку на ровном месте.
Правильный способ дать сборке БД как в ci-tradein.yml: явный `docker run`
в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
Запуск: python3 scripts/check-workflow-ports.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
RESERVED: dict[int, str] = {
22: "sshd хоста",
53: "systemd-resolved (127.0.0.53)",
80: "gendesign-caddy-1 (0.0.0.0:80)",
443: "gendesign-caddy-1 (0.0.0.0:443)",
2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
3000: "gendesign-frontend-1 (127.0.0.1:3000)",
5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
}
WORKFLOW_DIR = Path(".forgejo/workflows")
# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
def scan(text: str) -> list[tuple[int, str, int]]:
"""-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
hits: list[tuple[int, str, int]] = []
in_ports = False
ports_indent = 0
for lineno, raw in enumerate(text.splitlines(), 1):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue # комментарий ничего не запускает (в них живут анти-примеры)
indent = len(raw) - len(raw.lstrip())
if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
in_ports = False
candidates: list[str] = []
if stripped.startswith("ports:"):
rest = stripped[len("ports:") :].strip()
if rest:
candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
else:
in_ports, ports_indent = True, indent
elif in_ports:
candidates.append(stripped)
candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
for candidate in candidates:
for host_port, _container_port in PORT_PAIR.findall(candidate):
if int(host_port) in RESERVED:
hits.append((lineno, stripped, int(host_port)))
return hits
def selftest() -> None:
assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
# безопасные формы и посторонние двоеточия — не должны ронять сборку
assert scan(" ports:\n - 5432\n") == []
assert scan(" ports:\n - 15432:5432\n") == []
assert scan(" image: postgis/postgis:16-3.4\n") == []
assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
assert scan(" # ports:\n # - 5432:5432\n") == []
assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
# выход из блока ports: по отступу
assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
if not WORKFLOW_DIR.is_dir():
print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
return 1
failed = False
for path in sorted(WORKFLOW_DIR.glob("*.yml")):
for lineno, line, port in scan(path.read_text(encoding="utf-8")):
failed = True
print(
f"::error file={path},line={lineno}::публикация порта {port} — он занят "
f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
)
if failed:
return 1
print("✓ ни один workflow не публикует занятый на VPS порт")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -15,7 +15,10 @@ frontend/node_modules/
frontend/.next/
frontend/out/
frontend/dist/
frontend/package-lock.json
# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`,
# которому лок обязателен. Правишь frontend/package.json — регенерируй лок
# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе
# `npm ci` роняет сборку образа и весь deploy откатывается.
frontend/next-env.d.ts
# Docker

46
tradein-mvp/CHANGELOG.md Normal file
View file

@ -0,0 +1,46 @@
# История версий «МЕРА»
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
## 2.1.0 — 2026-08-10
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
отчёты.
### Добавлено
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
уверенности в оценке.
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
аналогов и сделок, формирование выкупной стоимости.
- Аналитика по дому — история размещений объявлений и продаж в доме.
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
- Публичный лендинг «МЕРА».
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
страница истории версий.
### Изменено
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
раннего технического макета.
### Исправлено
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
комнатности не было варианта «Студия», из-за чего для студии подбирались
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
а над результатом показывается предупреждение о сниженной точности и о том,
какие параметры пришлось расширить.
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
адресов улица не распознавалась, и разделы оставались пустыми.
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
на обложке, некорректные допущения о сроке экспозиции.

1
tradein-mvp/VERSION Normal file
View file

@ -0,0 +1 @@
2.1.0

View file

@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
COPY --from=builder --chown=app:app /app/backend/app /app/app
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
COPY --chown=app:app VERSION VERSION
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
# (VERSION-файл выше / "dev" / момент импорта модуля).
#
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
ARG APP_VERSION=""
ARG BUILD_SHA=""
ARG BUILD_DATE=""
ENV APP_VERSION=$APP_VERSION \
BUILD_SHA=$BUILD_SHA \
BUILD_DATE=$BUILD_DATE
USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые

View file

@ -72,7 +72,9 @@ from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_session_svc
from app.services import proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod
from app.services.estimator import LISTINGS_FRESH_DAYS
from app.services.geocoder import geocode, known_city_hint
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
from app.services.proxy_pool import clear_source_bans
from app.services.scheduler import has_running_run
from app.services.scraper_adapters import (
@ -231,6 +233,8 @@ async def scrape_around(
)
else:
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
# run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не
# заводит — снимок пишется вне прогона, поле честно остаётся NULL.
inserted, updated = save_listings(
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
)
@ -370,6 +374,56 @@ async def geocode_missing(
}
def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None:
"""Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки.
verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех
их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же,
как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные
куки вместо починки egress/прокси (инцидент 2026-08-10).
Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session.
Возвращает None, если state это успешно распаршенный state dict (в т.ч. случай
"успех, но userId не найден" этот случай caller должен обработать отдельно).
"""
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
return HTTPException(
status_code=503,
detail=(
"Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). "
"Куки, скорее всего, валидны — блокировка не про них. "
"Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в "
"scrape_proxy_source_bans. Перезаливать куки бесполезно. "
"(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)"
),
)
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
return HTTPException(
status_code=503,
detail=(
"Cian временно недоступен (5xx или сетевой сбой при проверке кук). "
"Повторите проверку позже. Куки не трогать — источник просто не ответил."
),
)
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
return HTTPException(
status_code=500,
detail=(
"Cian изменил вёрстку/схему страницы — auth-state не найден/не "
"распарсился (scraper_kit.cian_state_parser.extract_state, MFE "
"header-frontend). Нужен инженерный фикс парсера, перезалив кук "
"проблему НЕ решит."
),
)
if state is None:
return HTTPException(
status_code=401,
detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.",
)
return None
@router.post("/scrape/cian/upload-cookies", status_code=200)
async def upload_cian_cookies(
cookies: dict[str, str],
@ -402,15 +456,21 @@ async def upload_cian_cookies(
)
state = await cian_session_svc.verify_session(cleaned)
if state is None:
raise HTTPException(
status_code=401,
detail="Cookies invalid or session not authenticated on cian.ru",
)
verify_error = _cian_verify_state_error(state)
if verify_error is not None:
raise verify_error
assert state is not None # narrowed by _cian_verify_state_error above
user_id = state.get("user", {}).get("userId")
if not user_id:
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
raise HTTPException(
status_code=400,
detail=(
"Cian подтвердил аутентификацию (state распарсился), но userId в "
"ответе не найден — структура state неожиданная, куки тут ни при "
"чём, смотрите server logs."
),
)
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
@ -477,15 +537,21 @@ async def cian_auto_login(
)
state = await cian_session_svc.verify_session(cleaned)
if state is None:
raise HTTPException(
status_code=401,
detail="Logged in but session not authenticated (cookies rejected by cian.ru)",
)
verify_error = _cian_verify_state_error(state)
if verify_error is not None:
raise verify_error
assert state is not None # narrowed by _cian_verify_state_error above
user_id = state.get("user", {}).get("userId")
if not user_id:
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
raise HTTPException(
status_code=400,
detail=(
"Cian подтвердил аутентификацию (state распарсился), но userId в "
"ответе не найден — структура state неожиданная, куки тут ни при "
"чём, смотрите server logs."
),
)
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
@ -497,6 +563,12 @@ async def test_cian_auth(
) -> dict:
"""Проверить что текущие сохранённые Cian cookies ещё валидны.
reason различает 5 исходов (см. cian_session_svc.verify_session докстринг):
"banned_403" куки, вероятно, ОК, блокирован IP; "source_unavailable"
Cian недоступен, куки ни при чём; "markup_changed" вёрстка Cian сменилась,
нужен фикс парсера; "session_expired_or_invalid" куки реально протухли;
"no_session_in_db" / "encryption_key_not_configured" конфигурация/данных нет.
Returns: {"authenticated": bool, "userId": <int|null>, "reason": <str|null>}
"""
if not settings.cookie_encryption_key:
@ -507,10 +579,14 @@ async def test_cian_auth(
return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
state = await cian_session_svc.verify_session(cookies)
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "banned_403"}
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "source_unavailable"}
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
return {"authenticated": False, "userId": None, "reason": "markup_changed"}
if state is None:
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
if state.get("_ban"):
return {"authenticated": False, "userId": None, "reason": "banned_403"}
user_id = state.get("user", {}).get("userId")
return {"authenticated": True, "userId": user_id, "reason": None}
@ -1591,8 +1667,22 @@ def update_schedule(
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
from app.services.scheduler import compute_next_run_at
# Compute new next_run_at если window изменился — recompute, иначе keep existing
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
# #2674: такт берётся из default_params — ровно как его читает планировщик
# (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и
# ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после
# правки окна побежал бы через сутки. На суточных источниках баг был невидим —
# для них «завтра» и есть правильный ответ.
# None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError.
_interval_days = payload.default_params.get("interval_days")
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
# Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить
# сейчас»). Иначе считаем от такта.
next_at = payload.next_run_at or compute_next_run_at(
payload.window_start_hour,
payload.window_end_hour,
interval_days=interval_days,
)
row = (
db.execute(
@ -1606,7 +1696,22 @@ def update_schedule(
window_start_hour = EXCLUDED.window_start_hour,
window_end_hour = EXCLUDED.window_end_hour,
default_params = EXCLUDED.default_params,
next_run_at = EXCLUDED.next_run_at,
-- #2674: не двигаем уже назначенный запуск, если двигать не за чем.
-- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего
-- поля (enabled, request_delay_sec в params) заново разыгрывала момент
-- внутри окна и сдвигала прогон. Сохраняем существующий только когда он
-- ещё в будущем И ни окно, ни такт не менялись тогда пересчёт дал бы
-- то же самое окно, только с другим random-смещением.
next_run_at = CASE
WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at
WHEN scrape_schedules.next_run_at > NOW()
AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour
AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour
AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1')
= COALESCE(EXCLUDED.default_params ->> 'interval_days', '1')
THEN scrape_schedules.next_run_at
ELSE EXCLUDED.next_run_at
END,
updated_at = NOW()
RETURNING id, source, enabled, window_start_hour, window_end_hour,
default_params, last_run_id, last_run_at, next_run_at, updated_at
@ -1619,6 +1724,7 @@ def update_schedule(
"we": payload.window_end_hour,
"params": json.dumps(payload.default_params, ensure_ascii=False),
"next_at": next_at,
"explicit": payload.next_run_at is not None,
},
)
.mappings()
@ -1871,9 +1977,16 @@ async def scrape_cian_detail(
Without it debug-only (no DB write).
"""
_assert_allowed_url(offer_url)
from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
try:
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
except CianBlockedError as exc:
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
# этого узла» — разные новости для того, кто дёргает ручку руками.
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
@ -1920,14 +2033,17 @@ async def scrape_cian_newbuilding(
save_newbuilding_enrichment,
)
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
enrichment = await fetch_newbuilding(
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
)
if enrichment is None:
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
saved = False
if house_id is not None:
# save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
# раньше поднимал TypeError на любом вызове с house_id.
# save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
# раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
# записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
save_newbuilding_enrichment(db, house_id, enrichment)
saved = True
@ -2188,12 +2304,30 @@ async def scrape_house_imv_backfill(
class UnifiedScrapeRunRow(BaseModel):
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
таблица подписывала им прогоны, которые никаким sweep не были
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
называет `source`.
"""
run_id: int
source: str
run_type: str | None = None
status: str
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard.
cancellable: bool = False
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала),
# 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не
# установлена; раньше такие прогоны молча получали 'platform'). Без него
# оператор видит только «забанен» и делает вывод «площадка нас палит» на 80%
# наших же отказов.
ban_kind: str | None = None
params: dict | None = None
counters: dict | None = None
total_seen: int | None = None
@ -2209,6 +2343,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
rows: list[UnifiedScrapeRunRow]
class ScrapeRunSourcesResponse(BaseModel):
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
sources: list[str]
class BrowserHealth(BaseModel):
reachable: bool
browsers: dict[str, bool] = Field(default_factory=dict)
@ -2231,18 +2371,33 @@ class ScraperHealthResponse(BaseModel):
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
def _provider_proxy_url(source: str) -> str | None:
"""Effective proxy URL для source (учитывает property-fallback в settings).
def _provider_proxy_url(db: Session, source: str) -> str | None:
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты мёртвая
mobileproxy-подписка, #2613).
Раньше здесь стоял `settings.scraper_proxy_url` одна и та же статичная
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
страница показывала один узел, а трафик шёл через другой слепое пятно ровно
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
забаненный и Avito, и Cian), только теперь на диагностической странице.
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
`*_detail_backfill`) не «похожая логика», иначе страница снова начнёт
расходиться с трафиком.
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
его репутацию у Авито/Циана присваивать узлу отказ по чужой пробе значит
выдавать ему чужой бан.
"""
return {
"avito": settings.scraper_proxy_url,
"cian": settings.cian_proxy_url,
"yandex": settings.yandex_proxy_url,
}.get(source)
try:
return resolve_proxy_url(db, source)
except ProxyPoolExhaustedError:
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
# отказа хуже пустой).
return None
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
@ -2262,7 +2417,11 @@ def list_scrape_runs_unified(
db: Annotated[Session, Depends(get_db)],
source: Annotated[str | None, Query()] = None,
status: Annotated[
Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query()
# 'skipped' (#2658) — пропущенное расписание; без него оператор не может
# спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной
# поверхности, построенной ровно для этого вопроса).
Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None,
Query(),
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
@ -2274,7 +2433,7 @@ def list_scrape_runs_unified(
Query:
source опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...).
status опц. фильтр (done/running/banned/zombie/failed/cancelled).
status опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped).
limit default 50, max 200.
offset default 0.
"""
@ -2289,8 +2448,9 @@ def list_scrape_runs_unified(
UnifiedScrapeRunRow(
run_id=r["run_id"],
source=r["source"],
run_type=r.get("run_type"),
status=r["status"],
cancellable=runs_mod.honors_cancel(str(r["source"])),
ban_kind=r.get("ban_kind"),
params=r.get("params"),
counters=r.get("counters"),
total_seen=r.get("total_seen"),
@ -2305,6 +2465,21 @@ def list_scrape_runs_unified(
)
@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse)
def list_scrape_run_sources(
db: Annotated[Session, Depends(get_db)],
) -> ScrapeRunSourcesResponse:
"""Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs.
#2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в
таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением каждый
пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том
числе) были недоступны для вопроса «что там происходит». Список берётся из
данных: новый source появляется в фильтре сам, без правки кода.
"""
return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db))
async def _probe_browser_health() -> BrowserHealth:
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"
@ -2338,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
@router.get("/scraper/health", response_model=ScraperHealthResponse)
async def scraper_health() -> ScraperHealthResponse:
async def scraper_health(
db: Annotated[Session, Depends(get_db)],
) -> ScraperHealthResponse:
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
- providers: для avito/cian/yandex proxy host/port, rotate_supported
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
- providers: для avito/cian/yandex узел, который пул отдаст ЭТОМУ источнику
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
current_ip (пробинг через этот же узел на ipify).
Все пробинги параллельны (asyncio.gather) и time-boxed суммарно 10с.
"""
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
browser, *ips = await asyncio.gather(
_probe_browser_health(),
@ -2473,6 +2652,12 @@ async def update_scraper_pacing(
class SourceCoverage(BaseModel):
source: str
active_count: int
# #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших,
# а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530
# активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как
# «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом
# рядом — тогда «активно» перестаёт читаться как «живо».
stale_count: int
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
@ -2487,6 +2672,9 @@ class HousesCoverage(BaseModel):
class DataQualityResponse(BaseModel):
sources: list[SourceCoverage]
houses: HousesCoverage
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
# подписывал число, а не хардкодил порог у себя вторым определением.
stale_days: int
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
@ -2499,7 +2687,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [
("lon", "lon IS NOT NULL"),
("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"),
("living_area_m2", "living_area_m2 IS NOT NULL"),
("ceiling_height", "ceiling_height IS NOT NULL"),
# #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели
# переведены на ceiling_height_m, исторические значения перенесены (мигр. 238).
("ceiling_height_m", "ceiling_height_m IS NOT NULL"),
("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"),
]
@ -2513,9 +2702,13 @@ def get_data_quality(
Один проход per source через COUNT(*)...FILTER не N запросов.
Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2,
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
living_area_m2, ceiling_height_m (все источники, #2699), metro_stations.
houses: total, avito_validated_at%, rating_score%, house_type%.
house_reviews: общий count.
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
(stale_days), чтобы UI не заводил второе определение.
"""
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
@ -2523,10 +2716,17 @@ def get_data_quality(
filter_exprs = ", ".join(
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
)
# last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не
# виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных
# строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка
# должна называть ровно её.
sql_listings = text(f"""
SELECT
source,
COUNT(*) AS active_count,
COUNT(*) FILTER (
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
) AS stale_count,
{filter_exprs}
FROM listings
WHERE is_active = true
@ -2534,7 +2734,7 @@ def get_data_quality(
ORDER BY source
""")
rows = db.execute(sql_listings).mappings().all()
rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all()
sources: list[SourceCoverage] = []
for row in rows:
@ -2548,6 +2748,7 @@ def get_data_quality(
SourceCoverage(
source=row["source"],
active_count=int(row["active_count"]),
stale_count=int(row["stale_count"] or 0),
fields=fields,
)
)
@ -2575,7 +2776,7 @@ def get_data_quality(
reviews_count=reviews_count,
)
return DataQualityResponse(sources=sources, houses=houses)
return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS)
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────

View file

@ -58,6 +58,12 @@ async def list_accounts(
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
FROM user_events
-- Событие без имени не аккаунт (#2715: `login_verify_saturated`
-- пишется с пустым именем намеренно отказ случается ДО того, как
-- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её
-- last_seen_at момент атаки), а её кнопка в UI раскрывалась бы в
-- /audit/accounts/{username} с `min_length=1`, то есть в ошибку.
WHERE username <> ''
GROUP BY username
ORDER BY last_seen_at DESC
"""
@ -182,12 +188,16 @@ async def analytics_dashboard(
db.execute(
text(
"""
-- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они
-- честно входят в total_events, но не люди: count(DISTINCT) их
-- игнорирует по NULL, иначе первая же атака навсегда добавила бы
-- фантомного пользователя в счётчик уникальных.
SELECT count(*) AS total_events,
count(DISTINCT username) AS distinct_users,
count(DISTINCT NULLIF(username, '')) AS distinct_users,
count(*) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS events_last_24h,
count(DISTINCT username) FILTER (
count(DISTINCT NULLIF(username, '')) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS active_users_last_24h
FROM user_events
@ -204,7 +214,7 @@ async def analytics_dashboard(
"""
SELECT date_trunc('day', created_at)::date AS day,
count(*) AS events,
count(DISTINCT username) AS users
count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715)
FROM user_events
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
GROUP BY date_trunc('day', created_at)::date
@ -261,6 +271,7 @@ async def analytics_dashboard(
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
max(created_at) AS last_seen
FROM user_events
WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715)
GROUP BY username
ORDER BY events DESC
LIMIT 50

View file

@ -28,22 +28,50 @@ Security:
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон).
- Настоящий ПОТОЛОК ТЕМПА `verify_password_bounded` (#2665): bcrypt считает
282 мс, и ровно столько же он раньше держал заблокированным единственный
событийный цикл, кладя вместе с логином ВЕСЬ API. Теперь bcrypt крутится в
пуле из `login_password_verify_workers` потоков, а число потоков и есть
потолок (проверок/с не больше workers/282мс). Убрать одно без другого
нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса
оставил бы отказ в обслуживании. Сверх очереди 429, не ожидание.
Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины,
иначе потолок бил и по своим легитимный вход с верным паролем во время
флуда получал 429 столько раз, сколько пытался. Ключ IP, поэтому защита
поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси,
общий адрес за NAT, ротация через ботнет) см. docstring той же функции.
Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом
пути оставалась бы единственная работа, время которой зависит от того,
существует ли имя, а bcrypt, который эту разницу ровняет, до него уже не
доходит. След инцидента агрегированный, `_saturated_429`.
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
(`_throttle_delay_s`) см. развёрнутое обоснование там же.
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже).
только username/ip/user_agent/path/method и (для неудач) состояние
счётчика попыток: сколько их за окно и какая задержка применена.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import time
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.password import hash_password, verify_password
from app.core.password import (
PasswordVerifyOverloadedError,
hash_password,
verify_password_bounded,
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
@ -63,6 +91,32 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
window_s=settings.login_rate_limit_window_s,
)
# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со
# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не
# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с
# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не
# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток.
#
# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд
# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над
# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и
# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её
# падение = либо дыра (fail-open), либо отказ входа (fail-closed).
# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо
# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling
# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба.
#
# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он
# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог
# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и
# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно
# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут —
# отвечал по тому же числу, а не по случайному.
_USERNAME_FAIL_LIMITER = SlidingWindowLimiter(
limit=settings.login_username_fail_threshold,
window_s=settings.login_username_fail_window_s,
)
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
@ -84,7 +138,15 @@ _ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
class LoginRequest(BaseModel):
username: str
# max_length=64 — ровно верхняя граница CHECK'а реестра
# (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь
# нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом
# ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и
# только от пустых корзин — при окне в час корзины непустые, освобождать
# нечего. Без границы длины килобайтные имена растили бы память ключами.
# Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"`
# CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу).
username: str = Field(max_length=64)
password: str
@ -92,6 +154,208 @@ class LoginResponse(BaseModel):
ok: bool = True
def _throttle_delay_s(fails_in_window: int) -> float:
"""Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах
по этому имени за окно. 0 пока порог не перебран.
Замедление, а НЕ блокировка намеренно. Жёсткая блокировка учётки после N
неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он
гарантированно выключает вход конкретному человеку (директору, админу)
отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка
защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт
с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее.
Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`:
первые перебранные попытки почти незаметны, а сотни упираются в потолок.
Потолок обязателен: без него задержка становится той же блокировкой, только
растянутой во времени.
Показатель степени зажат (`min(..., 16)`) это не косметика. `min()` считает
ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при
excess>=1025 падает с `OverflowError: int too large to convert to float`
то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps)
защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой.
2**16 = 65536с заведомо больше любого разумного потолка, так что зажим
видимого поведения не меняет, а арифметику делает безусловно конечной.
"""
excess = fails_in_window - settings.login_username_fail_threshold
if excess <= 0:
return 0.0
return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16))
# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не
# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker
# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду
# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные
# логи ровно во время атаки — то есть в момент, когда они нужнее всего.
# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и
# крутить его нечем — меньше секунды возвращает исходную проблему, больше
# ухудшает разрешение по времени, не давая взамен ничего.
_SATURATION_REPORT_WINDOW_S = 1.0
# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи
# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик
# слотов в `app.core.password`: обе строчки исполняются в потоке событийного
# цикла и между чтением и записью нет `await`.
_saturation_rejected = 0
_saturation_reported_at: float | None = None
def _saturated_429(ip: str) -> HTTPException:
"""429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента.
Событие неудачного входа тут не пишется и бюджет неудач по имени не
тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а
трата бюджета означала бы, что насыщением можно заблокировать чужую учётку.
Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в
логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`).
Поэтому на окно приходится одна строка в лог И одно событие
`login_verify_saturated` в `user_events` с числом отказов, накопленных с
прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и
редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная
аномалия обязана быть видна, даже если продолжения не будет.
`since_prev_s` в payload НЕ дубль `created_at`, а единственный способ
прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ:
атака кончилась в 03:00, 900 отказов остались неотчитанными и во вторник
одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником
и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ
накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не
надо. `None` первая запись за жизнь процесса, сравнивать не с чем.
Уровень ERROR, а не WARNING, не косметика: бэкенд поднят с
`LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть
ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся
строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена
прецедента известна (#2674): монитор писал WARNING про протухшие куки — и
событий было ноль. Спама не будет: запись не чаще раза в окно, и все они
группируются в один issue (шаблон сообщения один).
Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей
(#2673), так что уведомление никому не уйдёт — событие будет видно в
интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески
сейчас не на чем, и утверждать её здесь было бы враньём.
`username=""` не заглушка: имя не пишем ПОТОМУ, что отказ случился до
того, как мы на него посмотрели. Записывай мы присланное, атакующий
наполнял бы аудит строками с любым именем на выбор. Пустое имя не аккаунт,
и списки аудита его отфильтровывают (`WHERE username <> ''` в
`app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке
аккаунтов и фантомом в `count(DISTINCT username)`. `ip` адрес последнего
отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов
много, и по одной записи их не восстановить (счётчик восстановит).
Потолок объёма: час непрерывной атаки это 3600 строк в `user_events`
(в таблице за всю её жизнь ~3.4 тысячи), сутки под 86 тысяч. Retention у
таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по
времени. То же давление уходит на квоту проекта в GlitchTip тот же
механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого
окно агрегации растёт с длительностью атаки (экспонента с потолком, как у
`_throttle_delay_s`), это следующий шаг, а не сегодняшний.
"""
global _saturation_rejected, _saturation_reported_at
_saturation_rejected += 1
now = time.monotonic()
since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at
if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S:
rejected, _saturation_rejected = _saturation_rejected, 0
_saturation_reported_at = now
logger.error(
"login rejected: password verify saturated — %d отказов, "
"с прошлой записи %s с, последний ip=%s",
rejected,
"" if since_prev is None else f"{since_prev:.1f}",
ip,
)
schedule_event(
event_type="login_verify_saturated",
username="",
ip=ip,
path="/api/v1/auth/login",
method="POST",
payload={
"rejected": rejected,
# Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0.
"since_prev_s": None if since_prev is None else round(since_prev, 1),
},
)
# Retry-After 1с — порядок времени одной сверки, не окно соседнего
# `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до
# сверки и потому ничего не сообщает о том, существует ли учётка.
return HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
)
async def _reject_invalid_credentials(
db: Session, username: str, ip: str, user_agent: str | None
) -> HTTPException:
"""Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401.
Один код на все ветки отказа (нет такого имени / неверный пароль / доступ
закрыт / password_hash NULL) это не борьба с дублированием, а инвариант:
ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера
и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом
существования учётки ровно в том же виде, что и разные сообщения об ошибке.
Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое
в реестре: несуществующее имя копит неудачи и тормозит так же, как живое.
(`get_user_by_username` сверяет `username = :username` по text-колонке без
нормализации, так что сырое имя тот же ключ, что и у поиска: регистром
счётчик не обойти.)
Возвращает `HTTPException`, а не бросает: `raise await ` не собирается, а
`raise (await )` читается хуже, чем `raise` над возвращённым значением.
*db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db`
в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же
сессию, что `get_db` движок с QueuePool на 5+10 соединений. После SELECT в
`get_user_by_username` сессия держит соединение в открытой транзакции, и сон
внутри её области жизни превращал бы каждую спящую попытку в занятое
соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ
эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу
хуже той блокировки учётки, ради отказа от которой всё это писалось.
"""
fails = _USERNAME_FAIL_LIMITER.record(username)
delay_s = _throttle_delay_s(fails)
schedule_event(
event_type="login_failed",
username=username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
# Состояние глобального счётчика — в аудит: по нему в user_events видно
# именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных
# ip_address), который иначе выглядит как россыпь одиночных неудач.
payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s},
)
if delay_s > 0:
logger.warning(
"login throttle: username=%r fails=%d delay=%.1fs ip=%s",
username,
fails,
delay_s,
ip,
)
# Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна:
# вызывающий немедленно делает raise, а повторный close() в самой
# зависимости идемпотентен.
db.close()
# await, не time.sleep: событийный цикл в это время обслуживает всех
# остальных — тормозим перебор, а не сервис.
await asyncio.sleep(delay_s)
return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
@ -111,6 +375,16 @@ async def login(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый
# запрос не берёт соединение из пула и не делает SELECT по имени: под
# насыщением это была бы единственная работа на пути отказа, а значит и
# единственное, чьё время зависит от существования учётки — bcrypt, который
# эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение
# всё равно остаётся за `verify_password_bounded` ниже (тот же предикат,
# `except` под ним никуда не делся) — здесь только экономия похода в базу.
if verify_slots_saturated(ip):
raise _saturated_429(ip)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
@ -119,20 +393,26 @@ async def login(
)
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
try:
# key=ip — доля слотов на адрес (#2714): один источник не занимает больше
# половины ёмкости, и вход остаётся открыт тем, кто приходит с других
# адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор
# менял бы его каждую попытку, получая полную долю на каждое. Границы
# применимости (IP подделывается за вторым прокси, разделяется за NAT,
# ротируется ботнетом) — в docstring `verify_password_bounded`.
password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip)
except PasswordVerifyOverloadedError:
# Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя —
# ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает,
# но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ —
# тот же самый и с той же аргументацией, что у предчека: один helper,
# чтобы две ветки не разъехались (одинаковость 429 — часть защиты).
raise _saturated_429(ip) from None
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
if user is None or not password_ok:
schedule_event(
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
access_state = user["access_state"]
if access_state is AccessState.TRIAL_EXPIRED:
@ -156,17 +436,9 @@ async def login(
if not access_state.can_sign_in:
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
# → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле:
# заблокированный аккаунт неотличим от несуществующего.
schedule_event(
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
# → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном
# пароле: заблокированный аккаунт неотличим от несуществующего.
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)

View file

@ -21,6 +21,7 @@ from __future__ import annotations
import logging
import re
from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal
from uuid import UUID
@ -30,6 +31,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
logger = logging.getLogger(__name__)
@ -115,17 +117,26 @@ async def create_trade_in_lead(
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
#
# ЭТАП 4 B2C: expires_at (migration 231) — раньше лид хранился бессрочно
# (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at).
# Считаем на insert-time тем же паттерном, что estimator.py делает для
# trade_in_estimates — retention-период вынесен в settings, не хардкод.
expires_at = datetime.now(tz=UTC) + timedelta(days=settings.trade_in_lead_retention_days)
row = (
db.execute(
text(
"""
INSERT INTO trade_in_leads (
estimate_id, phone, consent, source, user_agent,
client_ip, consent_policy_version, consent_text_snapshot
client_ip, consent_policy_version, consent_text_snapshot,
expires_at
)
VALUES (
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot,
:expires_at
)
RETURNING CAST(id AS text), created_at
"""
@ -139,6 +150,7 @@ async def create_trade_in_lead(
"client_ip": client_ip,
"consent_policy_version": _CONSENT_POLICY_VERSION,
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
"expires_at": expires_at,
},
)
.mappings()

View file

@ -0,0 +1,79 @@
"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C.
Auth не нужен в этом файле вся ветка `/api/v1/admin/*` уже гейтится
`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin 403),
тем же паттерном, что app/api/v1/audit.py.
Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного
пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился
(вне этого API телефон/estimate-ссылка/переписка), что запрос на удаление
реально пришёл от владельца данных, а не от третьего лица, знающего чей-то
номер телефона. Идентификация анонима см. app/services/data_erasure.py
module docstring (честно про то, что не всегда разрешимо).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
class DataErasureRequest(BaseModel):
"""Хотя бы одно поле обязательно — см. erase_person_data ValueError guard."""
username: str | None = Field(default=None, max_length=200)
estimate_ids: list[UUID] | None = None
phone: str | None = Field(default=None, max_length=32)
tg_chat_id: int | None = None
@router.post("/privacy/erase")
async def erase_person_data_endpoint(
payload: DataErasureRequest,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, dict[str, int]]:
"""Физически удалить данные человека по одному или нескольким идентификаторам.
Идентификаторы (хотя бы один):
- username B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE
подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки.
- estimate_ids конкретные оценки по UUID (анонимный путь: человек прислал
ссылку/PDF со своим estimate_id) + лиды, привязанные к ним.
- phone лиды с этим номером телефона (независимо от привязки к оценке).
- tg_chat_id Telegram-поддержка (@MERAsupport_bot), включая переписку В
ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см.
app/services/data_erasure.py ВАЖНЫЙ ФАКТ, честно, не скрываем).
422 если ни один идентификатор не передан (не даём случайно вызвать
"удали всё" пустым телом).
"""
if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]):
raise HTTPException(
status_code=422,
detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id",
)
from app.services.data_erasure import erase_person_data
counters = await asyncio.to_thread(
erase_person_data,
db,
username=payload.username,
estimate_ids=payload.estimate_ids,
phone=payload.phone,
tg_chat_id=payload.tg_chat_id,
)
logger.info("admin privacy erase requested -> %s", counters)
return {"deleted": counters}

View file

@ -5,7 +5,9 @@
from __future__ import annotations
import asyncio
import calendar
import json
import logging
from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
@ -52,6 +54,27 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404,
# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись
# по коду ответа; третий потребитель (`/r/<token>`, PR-9) разошёлся бы
# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false
# в SQL — для всех существующих строк (retain_until IS NULL) поведение не
# меняется вообще. Не копировать это выражение по месту — только через
# константу/хелпер ниже. Payments retention, PR #2754.
ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())"
def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool:
"""Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД.
tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf)
`.replace(tzinfo=UTC)`, не переизобретается.
"""
now = datetime.now(tz=UTC)
if expires_at.replace(tzinfo=UTC) > now:
return True
return retain_until is not None and retain_until.replace(tzinfo=UTC) > now
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
@ -146,6 +169,239 @@ def _resolve_target_house_id(
return None
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
# цена неприкосновенна.
def _precision_to_qc_geo(precision: str | None) -> int | None:
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
AggregatedEstimate наружу отдаёт только бакетированный address_precision
(house/street/approximate), не сырой dadata.qc_geo (0..5) тот остаётся
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
в тот же "approximate", наблюдаемое поведение не меняется.
"""
if precision == "house":
return 0
if precision == "street":
return 1
if precision == "approximate":
return 2
return None
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
Только поля, реально персистящиеся в trade_in_estimates при создании
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
repair_state/has_balcony) CRM-only поля (ownership_type/has_mortgage)
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
INSERT, ни _empty_estimate его не пишут) None здесь даёт тот же
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
большинства сохранённых строк (явный радиус выбирает меньшинство).
consent=None + require_consent=False у вызывающего revival не новое
согласие физлица, а служебный recompute уже существующей записи.
"""
return TradeInEstimateInput(
address=row.address,
area_m2=float(row.area_m2),
rooms=row.rooms,
floor=row.floor,
total_floors=row.total_floors,
year_built=row.year_built,
house_type=row.house_type,
repair_state=row.repair_state,
has_balcony=row.has_balcony,
lat=row.lat,
lon=row.lon,
radius_m=None,
consent=None,
)
async def _try_revive_dead_estimate(
db: Session, estimate_id: UUID, row: Any
) -> AggregatedEstimate | None:
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
заявку уже забрал параллельный запрос anti-storm через атомарный
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
значению, прочитанному раньше в Python TOCTOU-гонка между двумя
параллельными GET невозможна, проигравший просто не дублирует работу;
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше НЕ 500.
"""
claim = db.execute(
text(
"""
UPDATE trade_in_estimates
SET revival_attempted_at = NOW()
WHERE id = CAST(:id AS uuid)
AND (median_price <= 0 OR median_price IS NULL)
AND (
revival_attempted_at IS NULL
OR revival_attempted_at
< NOW() - make_interval(mins => CAST(:throttle AS integer))
)
RETURNING id
"""
),
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
).fetchone()
db.commit()
if claim is None:
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
return None
from app.services.estimator import estimate_quality
try:
payload = _payload_from_dead_row(row)
result = await estimate_quality(
payload,
db,
created_by=row.created_by,
client_ip=None,
require_consent=False,
)
except Exception:
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
return None
temp_id = result.estimate_id
if result.median_price_rub <= 0:
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
return None
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
# notion of "recompute this existing row". Copy the computed OUTPUT fields
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
# only the outputs were recomputed.
# #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET —
# it is the client's original request date (printed in /history and in
# AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT
# a recompute output. It previously got clobbered with the throwaway temp
# row's created_at (=NOW() at recompute time), which also silently
# re-sorted the row to the top of `GET /history ORDER BY created_at DESC`.
# revival_completed_at (migration 256) is the audit trail for "when did a
# revival LAST successfully rewrite this row" — distinct from
# revival_attempted_at (255), which is stamped on every claim regardless
# of outcome (throttle loss / recompute failure included).
db.execute(
text(
"""
UPDATE avito_imv_evaluations
SET estimate_id = CAST(:orig AS uuid)
WHERE estimate_id = CAST(:temp AS uuid)
"""
),
{"orig": str(estimate_id), "temp": str(temp_id)},
)
db.execute(
text(
"""
UPDATE trade_in_estimates SET
median_price = :median_price,
range_low = :range_low,
range_high = :range_high,
median_price_per_m2 = :median_ppm2,
confidence = :confidence,
confidence_explanation = :explanation,
n_analogs = :n_analogs,
analogs = CAST(:analogs_json AS jsonb),
actual_deals = CAST(:deals_json AS jsonb),
sources_used = CAST(:sources_json AS jsonb),
data_freshness_minutes = :freshness,
canonical_address = :canonical_address,
house_cadnum = :house_cadnum,
house_fias_id = :house_fias_id,
dadata_qc_geo = :dadata_qc_geo,
dadata_metro = CAST(:dadata_metro_json AS jsonb),
expected_sold_price = :expected_sold_price,
expected_sold_range_low = :expected_sold_range_low,
expected_sold_range_high = :expected_sold_range_high,
expected_sold_per_m2 = :expected_sold_per_m2,
asking_to_sold_ratio = :asking_to_sold_ratio,
ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability,
revival_completed_at = NOW()
WHERE id = CAST(:id AS uuid)
"""
),
{
"id": str(estimate_id),
"median_price": result.median_price_rub,
"range_low": result.range_low_rub,
"range_high": result.range_high_rub,
"median_ppm2": result.median_price_per_m2,
"confidence": result.confidence,
"explanation": result.confidence_explanation,
"n_analogs": result.n_analogs,
"analogs_json": json.dumps(
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
),
"deals_json": json.dumps(
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
),
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
"freshness": result.data_freshness_minutes,
"canonical_address": result.canonical_address,
"house_cadnum": result.house_cadnum,
"house_fias_id": result.house_fias_id,
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
"expected_sold_price": result.expected_sold_price_rub,
"expected_sold_range_low": result.expected_sold_range_low_rub,
"expected_sold_range_high": result.expected_sold_range_high_rub,
"expected_sold_per_m2": result.expected_sold_per_m2,
"asking_to_sold_ratio": result.asking_to_sold_ratio,
"ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability,
},
)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
logger.info(
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
estimate_id,
result.median_price_rub,
result.n_analogs,
result.confidence,
result.reliability,
)
# created_at on the returned object must mirror the DB row (untouched
# original request date, NOT the temp row's NOW()) — see UPDATE above.
return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at})
@router.post("/estimate", response_model=AggregatedEstimate)
async def estimate(
payload: TradeInEstimateInput,
@ -171,8 +427,22 @@ async def estimate(
# явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не
# «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой).
# created_by (#656) прокидываем в estimate_quality для скоупа /history.
# ЭТАП 4 B2C (152-ФЗ): require_consent=True только когда нет
# X-Authenticated-User — сегодня rbac_guard (app/core/rbac.py) уже требует
# этот заголовок на любом non-public пути, так что эта ветка пока
# недостижима в проде (анонимный /estimate ещё не открыт другими частями
# ЭТАП 4/B2C работ) — гейт готов ЗАРАНЕЕ, на момент открытия анонимного
# доступа. client_ip — proof-of-consent (estimate_quality персистит его
# на trade_in_estimates только когда require_consent=True; B2B-пилоты
# остаются NULL, см. estimator.py::_estimate_consent_persist_fields).
try:
result = await estimate_quality(payload, db, created_by=x_authenticated_user)
result = await estimate_quality(
payload,
db,
created_by=x_authenticated_user,
client_ip=_client_ip(request),
require_consent=x_authenticated_user is None,
)
except HTTPException:
raise
except Exception:
@ -235,21 +505,22 @@ def get_estimate(
"""
row = db.execute(
text(
"""
f"""
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at, address, lat, lon,
expires_at, retain_until, address, lat, lon,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by, created_at
asking_to_sold_ratio, ratio_basis, created_by, created_at,
relaxations, reliability
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
AND expires_at > NOW()
AND {ESTIMATE_READABLE_SQL}
"""
),
{"id": str(estimate_id)},
@ -260,6 +531,22 @@ def get_estimate(
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
# пересчитать её на месте (throttled, race-safe — см. докстринг
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
# loop — только сама попытка revival временно занимает свой поток на время
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
# и функция просто продолжает как раньше, отдавая сохранённую строку.
if row.median_price is None or row.median_price <= 0:
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
if revived is not None:
return revived
from app.services.estimator import (
_canonical_sources,
_cv_from_ppm2,
@ -358,6 +645,7 @@ def get_estimate(
analogs=analogs,
actual_deals=actual_deals,
expires_at=row.expires_at,
retain_until=row.retain_until,
target_address=row.address,
target_lat=row.lat,
target_lon=row.lon,
@ -395,6 +683,14 @@ def get_estimate(
cv=cv,
source_counts=source_counts,
created_at=row.created_at,
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
# теряет красный баннер «точность снижена» при открытии по ссылке.
# getattr defensive: старые in-memory test doubles / любая строка без
# этих колонок (не должно случаться после миграции) деградируют в
# дефолт схемы (ok / []), а не падают AttributeError.
relaxations=list(getattr(row, "relaxations", None) or []),
reliability=getattr(row, "reliability", None) or "ok",
)
@ -419,14 +715,15 @@ def estimate_pdf(
SELECT id, median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals, sources_used, data_freshness_minutes,
expires_at,
expires_at, retain_until,
address, lat, lon, area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by
asking_to_sold_ratio, ratio_basis, created_by,
relaxations, reliability
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
@ -439,8 +736,12 @@ def estimate_pdf(
_assert_estimate_access(row.created_by, x_authenticated_user)
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
# PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше
# здесь была независимая Python-проверка expires_at, разошедшаяся с SQL-
# фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при
# годовом retain_until упоминание 24ч в ответе API стало бы ложью.
if not estimate_readable(row.expires_at, row.retain_until):
raise HTTPException(status_code=410, detail="estimate expired")
from app.services.estimator import _qc_geo_to_precision
@ -463,6 +764,7 @@ def estimate_pdf(
analogs=analogs,
actual_deals=actual_deals,
expires_at=row.expires_at,
retain_until=row.retain_until,
target_address=row.address,
target_lat=row.lat,
target_lon=row.lon,
@ -482,6 +784,10 @@ def estimate_pdf(
house_fias_id=row.house_fias_id,
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
metro_nearest=(row.dadata_metro or []),
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
relaxations=list(getattr(row, "relaxations", None) or []),
reliability=getattr(row, "reliability", None) or "ok",
)
input_snapshot = {
"address": row.address,
@ -750,7 +1056,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
trade_in_estimates с непустым address; NULL при отсутствии адресов.
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
кэша (отдельного счётчика попаданий не ведём).
#2660: listings_active сам по себе врал — «активно» на проде не означает
«живо» (деактиватор протухших покрывает не все источники). Рядом отдаём
listings_active_stale сколько из них не виделись listings_stale_days
(= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при
20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем.
"""
from app.services.estimator import LISTINGS_FRESH_DAYS
row = (
db.execute(
text(
@ -760,6 +1074,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
AS geocode_cache_fresh,
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
(SELECT count(*) FROM listings
WHERE is_active
AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval)
AS listings_active_stale,
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
(SELECT count(*) FROM deals) AS deals,
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
@ -776,12 +1094,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
WHERE address IS NOT NULL AND address <> ''
) t) AS repeat_address_pct
"""
)
),
{"fresh_days": LISTINGS_FRESH_DAYS},
)
.mappings()
.fetchone()
)
return dict(row) if row else {}
# Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней»,
# а не заводил второе определение свежести у себя.
return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {}
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
@ -1806,6 +2127,139 @@ def get_street_deals(
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по
# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице
# сделка и объявление могут стоять в разных домах и разных ценовых классах, и
# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами.
# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем
# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана
# уехала с 11.9% на +36.4%, т.е. пользователю написали бы «продали на 36%
# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём
# показывать число, которому нельзя верить.
#
# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ
# пользовательских запросах из trade_in_estimates — тот же address/area/rooms,
# что уходил в виджет; 128 из них дали хотя бы одну пару):
#
# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной
# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки
# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0,
# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт 6.8 п.п.
# шума, 10→15 уже только 2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц).
# Совпадает с уже принятым в продукте порогом малой выборки
# settings.sell_time_sensitivity_min_n_lots = 10.
#
# SANE_MIN/MAX = [60%, +20%] — асимметричны намеренно, у сторон разная природа:
# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН:
# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5,
# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет
# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один
# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132
# = 8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого
# рыночно объяснимого плюса.
# НИЗ. Разрыва нет — минус идёт сплошняком от 5% до 87%, и это ожидаемо:
# у большого отрицательного торга есть механизм (занижение цены в ДКП), в
# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»:
# худший городской бакет (студии, ratio 0.7623) = 23.8%, 60% в 2.5 раза
# глубже. Режет 6 групп из 128 (87 … 64).
#
# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%)
# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся:
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
# строка «медианный торг», и вместо неё уходит median_discount_explanation.
#
# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ,
# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но
# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп
# медиана — 18 сделок на одно различное объявление. До этого порога из 64
# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс —
# `Белинского` 1-комн.: 50 пар, 1 объявление, 50.6%), 50 (78%) — меньше чем на
# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на
# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира
# отличалась от типичной сделки, а не о торге на улице.
#
# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс
# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп,
# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум
# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с
# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще:
# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0.
# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по
# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5),
# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому
# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о
# разбросе которой можно спрашивать, и показывать число = фабриковать точность.
# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп)
# говорят, что он должен быть выше — но ценой почти всей витрины:
# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4;
# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0);
# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5.
# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё
# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и
# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» —
# решение владельца, не гейта; здесь снимается ровно то, что не является
# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя:
# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность).
#
# SANE_MIN ужесточён 60% → 35% (#2672). Исходное подозрение «60% режет живой
# рынок» проверено и ОПРОВЕРГНУТО: до 60% проходило всё, законный механизм
# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую
# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64
# показываемых чисел (41%) лежали ниже 23.7%, худшего объяснимого рынком
# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один
# бакет не глубже), самое глубокое показываемое — 58.5%. Мы гасили «+34%» и
# показывали «58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия
# работала против пользователя: абсурдный плюс сам себя опровергает («продали
# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит
# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за
# полцены. 35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения 37.6 → 33.9.
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → 46.5% показывался.
#
# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число,
# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар:
# на живом Космонавтове (2-комн., медиана 37.6% погашена) шесть из первых
# двенадцати строк — от +42% до +77%, и все против одной и той же цены
# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из
# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в
# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов.
#
# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ:
# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это
# столбец цены сделки, поделённый на одну и ту же константу: он не даёт
# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов;
# б) медиана вне санитарного диапазона — по определению медианы это
# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она.
# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно
# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не
# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20
# строк на проде, где медианы нет, а проценты в строках есть.
# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар
# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55
# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке
# гейта строки гасились бы не там, где надо.
# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся
# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из
# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ
# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются:
# убирается не данные, а наша подпись «торг» под их разностью.
#
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
# сравнивают сделку в одном доме с объявлением в другом.
# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая
# 426 строк из 1 396 (31%), лежащих вне того же диапазона [35%, +20%], по
# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет:
# диапазон калиброван на медианах групп, а у одной сделки законный разброс
# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать
# его = вводить некалиброванный порог, чего #2672 прямо предостерегает.
SALES_VS_LISTINGS_MIN_PAIRS = 10
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0
SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
def get_sales_vs_listings(
@ -1945,12 +2399,122 @@ def get_sales_vs_listings(
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None
# #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts)
# считает сделки, а не наблюдения рынка — одно объявление попадает в пару
# к десяткам сделок улицы (см. шапку секции).
n_distinct_listings = len(
{p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None}
)
# #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не
# к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»).
# Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его
# веток (мало пар → одно объявление → диапазон) прячет вторую причину за
# первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ
# объявлении. Для строк важна причина, а не то, какая ветка сработала раньше.
pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS
median_is_implausible = median_discount is not None and not (
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
<= median_discount
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
)
# #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
# прочитает как поломку, а не как честность.
median_discount_explanation: str | None = None
if median_discount is not None:
if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS:
# Формулировка — ФАКТ про выборку, а не обещание надёжности выше
# порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше —
# пары псевдореплики), обещать «от 10 надёжно» мы не вправе.
median_discount_explanation = (
f"Медианный торг не показываем: пар «сделка ↔ объявление» всего "
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
f"процентных пунктов."
)
elif pairs_stand_on_one_listing:
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
# грамматично при любом значении, «на 1 различных объявлений» — нет.
median_discount_explanation = (
f"Медианный торг не показываем: сделок {len(discounts)}, а разных "
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
)
elif median_is_implausible:
# Типографский минус (U+2212) — как в fmtDiscount на фронте.
shown = f"{median_discount:+.1f}".replace("-", "")
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
# следующим блоком это говорит street_only-дисклеймер (карточка) /
# хвост note (v2-mappers). Проверено скриншотом — две формулировки
# подряд читались как стена текста.
median_discount_explanation = (
f"Медианный торг не показываем: расчёт дал неправдоподобное значение "
f"({shown}%) — такого торга на рынке не бывает."
)
if median_discount_explanation is not None:
logger.info(
"sales-vs-listings: median_discount gated street=%r rooms=%d "
"n_pairs=%d distinct_listings=%d value=%+.2f%%",
street_name,
rooms,
len(discounts),
n_distinct_listings,
median_discount,
)
median_discount = None
# #2672 п.3: под погашенной медианой строки таблицы продолжали показывать
# проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против
# одной и той же цены предложения). Гасим их там, и только там, где причина —
# свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно
# ничего не говорит, поэтому одну строку не трогает (обоснование и цена —
# в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу
# подпись «торг» под разностью, которой не можем ручаться.
if discounts and (pairs_stand_on_one_listing or median_is_implausible):
if pairs_stand_on_one_listing:
# Оба числа названы совместно с фразой медианы: там «сделок N», здесь
# «одна и та же цена» — читателю видно и сколько строк, и на скольких
# объявлениях они стоят.
row_explanation = (
"Проценты по каждой сделке тоже не показываем: все они считаются "
"против одной и той же цены объявления."
)
else:
# Медиана вне диапазона — это утверждение О СТРОКАХ: по определению
# медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже
# вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке
# диапазона оно уже названо предыдущим предложением (вышло бы дважды
# в одном абзаце), а в ветке «мало пар» мы его намеренно не
# показываем — и печатать его в пояснении было бы отказом на словах.
row_explanation = (
"Проценты по каждой сделке тоже не показываем: половина из них — "
"за пределами того, как торгуется рынок."
)
for pair in pairs:
pair.discount_pct = None
median_discount_explanation = (
f"{median_discount_explanation} {row_explanation}"
if median_discount_explanation
else row_explanation
)
logger.info(
"sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d "
"distinct_listings=%d reason=%s",
street_name,
rooms,
len(discounts),
n_distinct_listings,
"one_listing" if pairs_stand_on_one_listing else "implausible_median",
)
logger.info(
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
"linkage=%.1f%% median_disc=%s",
street_name,
total_deals,
deals_with_listings,
n_distinct_listings,
linkage_rate_pct,
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
)
@ -1964,6 +2528,7 @@ def get_sales_vs_listings(
deals_with_listings=deals_with_listings,
linkage_rate_pct=linkage_rate_pct,
median_discount_pct=median_discount,
median_discount_explanation=median_discount_explanation,
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
data_quality="street_only" if total_deals > 0 else "no_data",

View file

@ -0,0 +1,20 @@
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
build date), source `app/core/version.py`.
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) это не
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
process env / уже загруженные при импорте константы (без похода в БД)."""
from __future__ import annotations
from fastapi import APIRouter
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
router = APIRouter()
@router.get("/version")
def get_version() -> dict[str, str]:
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}

View file

@ -109,6 +109,61 @@ class Settings(BaseSettings):
login_rate_limit_window_s: int = Field(
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
)
# Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571).
# Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого
# нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО
# имя, поэтому попытки со всех адресов складываются.
#
# Превышение порога НЕ блокирует учётку (это был бы вектор DoS против
# конкретного человека — злоумышленник выключал бы чужой вход по своему
# желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час
# выбран так, чтобы живой человек с опечатками до него не доходил.
login_username_fail_threshold: int = Field(
default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD"
)
login_username_fail_window_s: int = Field(
default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S"
)
# Потолок задержки одного ответа. Держим невысоким сознательно: задержка —
# это ещё и цена, которую платит легитимный владелец имени, пока его
# перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг».
login_username_throttle_max_delay_s: float = Field(
default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S"
)
# ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ────
# Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши —
# `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в
# `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были
# единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при
# стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см.
# `app.core.password.verify_password_bounded`.
#
# `workers` — это и есть потолок темпа: не больше workers/282мс проверок в
# секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы
# ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала
# блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл
# только вместе с осознанным ответом «во сколько раз мы согласны ускорить
# перебор ради параллельных входов».
# ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must
# be greater than 0») — контейнер уходит в crash-loop, и причина видна только
# в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля.
login_password_verify_workers: int = Field(
default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS"
)
# Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт
# очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут
# workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает
# соединение к БД (сессия реестра открыта после SELECT в
# `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная
# очередь выбрала бы пул и положила API ровно так же, как блокировка цикла,
# только другим способом. 4 из 15 соединений и худшее ожидание
# 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает.
# ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход
# получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего:
# потолок — это workers, а очередь без границы выбирает пул соединений к БД.
login_password_verify_max_inflight: int = Field(
default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT"
)
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и
@ -773,6 +828,64 @@ class Settings(BaseSettings):
# допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
avito_stale_ttl_days: int = 10
# ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ────────────────────
# trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был
# хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT +
# _empty_estimate fallback) — вынесено в настройку, чтобы retention-период
# не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт
# "сессию" клиента, не архив); юридически обоснованный срок хранения адреса
# физлица для анонимного B2C — решение не инженера, см. итоговый комментарий
# к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS.
trade_in_estimate_retention_hours: int = 24
# trade_in_leads.expires_at TTL (дни от момента создания, migration 231).
# У trade_in_leads раньше вообще не было срока хранения — лид (телефон +
# согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для
# НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон
# 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом);
# если лид конвертировался в реальную сделку/договор — для него должен
# действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет
# механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ,
# см. итоговый комментарий к задаче (конкретный юридически обоснованный
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
trade_in_lead_retention_days: int = 180
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
# для всех строк, поднять его до года = соврать в документе клиента про
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
# Единственный источник числа «12 месяцев» на фронте —
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
# экран после оплаты и SQL продления retain_until при оплате (платёжный
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
trade_in_paid_retention_days: int = 365
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
# пересчитать такую строку через тот же estimate_quality(), что и POST
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
# в это число минут на строку — иначе каждый refresh страницы бил бы по
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
trade_in_revival_throttle_minutes: int = 10
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
# это ограничивает ОДНУ транзакцию, не общий прогресс. ENV:
# TRADE_IN_PURGE_BATCH_SIZE.
trade_in_purge_batch_size: int = 500
# ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ─────────────────────
# Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России»
# (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug
@ -877,5 +990,36 @@ class Settings(BaseSettings):
# message_thread_id топика внутри support-группы, в который идут зеркала.
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
# PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью:
# ни один из последующих PR (C/D/E) не должен активироваться без явного
# включения в .env.runtime прод-стека.
tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY")
# Пароль терминала — участвует в подписи Token (Init) и проверке подписи
# входящих нотификаций. SecretStr по прецеденту auth_db_password (строка
# 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs.
tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD")
tbank_api_base_url: str = Field(
default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL"
)
tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL")
tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL")
tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL")
# "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm).
# Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по
# ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow-
# decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает
# "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не
# сверившись с decision-доком.
tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE")
tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED")
tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION")
tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION")
# Kill-switch всего контура. false — checkout/notify (появятся в PR-D)
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
settings = Settings()

View file

@ -5,14 +5,28 @@ bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8)
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
`verify_password` на длинном пароле возвращает False (не raise) сравнение
паролей не должно ронять запрос авторизации.
#2665: из `async def` зови ТОЛЬКО `verify_password_bounded` — см. её docstring.
Синхронный `verify_password` остаётся для sync-кода (сидов, тестов, CLI) и как
тело, которое исполняется в пуле.
Правило про пул относится к СВЕРКЕ, не к хешированию. `hash_password` тот же
cost 12 и те же ~282 мс на цикле сознательно остаётся синхронным в
`app/api/v1/team.py` (заведение сотрудника, смена пароля): это редкая операция
АУТЕНТИФИЦИРОВАННОГО менеджера, её нельзя вызвать анонимно и потому нельзя
превратить в поток. Станет их много переносить тем же приёмом.
"""
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
import bcrypt
from app.core.config import settings
logger = logging.getLogger(__name__)
_BCRYPT_MAX_BYTES = 72
@ -59,3 +73,209 @@ def verify_password(plain: str, hashed: str) -> bool:
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
logger.warning("verify_password: malformed hash rejected: %s", e)
return False
class PasswordVerifyOverloadedError(RuntimeError):
"""Свободных слотов на проверку пароля нет. Вызывающий обязан ответить 429."""
# Пул, в котором крутится bcrypt. `max_workers` — не тюнинг пропускной
# способности, а САМ ПОТОЛОК ТЕМПА: проверок в секунду не больше, чем
# workers / 282мс, независимо от числа соединений. Читается один раз на импорте
# — размер пула по определению статичен (см. `login_password_verify_workers`).
_VERIFY_POOL = ThreadPoolExecutor(
max_workers=settings.login_password_verify_workers,
thread_name_prefix="pw-verify",
)
# Сколько проверок сейчас в работе ИЛИ ждут очереди в пуле. Обычный int без
# лока — намеренно: и инкремент, и декремент выполняются в потоке событийного
# цикла, между чтением и записью нет ни одного `await`, так что чередования
# внутри пары нет. Счётчик, а не `asyncio.Semaphore`: мы никогда не ЖДЁМ на нём
# (сверх лимита — сразу отказ), а int не имеет привязки к конкретному циклу и
# потому одинаково честен под несколькими event loop'ами в тестах.
_verify_inflight = 0
# То же самое, но в разрезе ключа (#2714). Запись живёт РОВНО пока ключ держит
# хотя бы слот и удаляется на нуле: размер словаря ограничен числом слотов
# (`login_password_verify_max_inflight`), а не числом когда-либо виденных
# адресов — иначе перебор с ротацией IP растил бы его без границы.
_verify_inflight_by_key: dict[str, int] = {}
def _per_key_slot_cap() -> int:
"""Сколько слотов из общего лимита разрешено ОДНОМУ ключу.
Половина минимальное деление, при котором один источник, сколько бы он ни
слал, физически не может занять всё: вторая половина остаётся тем, кто
приходит впервые. Настройкой не сделано сознательно это доля, а не
величина, и подкручивать её нечем: 100% возвращает поведение, ради отказа
от которого правка написана.
Читается на каждом вызове, а не на импорте, как `_throttle_delay_s`:
иначе тестовый monkeypatch лимита не влиял бы на долю.
`max(1, )`: при `max_inflight=1` половина округлилась бы в 0, и КАЖДЫЙ вход
получал бы отказ молча (свободных слотов нет ни у кого). Молчаливый отказ
всем ровно тот класс поломки, от которого страхует `ge=1` на самой
настройке; здесь тот же страховочный пол, но от деления.
"""
return max(1, settings.login_password_verify_max_inflight // 2)
def verify_slots_saturated(key: str) -> bool:
"""Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота.
Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт
стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё
равно брал соединение из пула и делал SELECT по имени тогда, когда система
уже перегружена. Хуже того, под насыщением эта выборка оставалась
ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа
для существующего и несуществующего имени, ниже по течению и до него не
доходит, так что разницу «строка найдена / не найдена» ничто не маскировало.
Предчек, а не решение: авторитетная проверка остаётся внутри
`verify_password_bounded` она зовёт ЭТУ ЖЕ функцию, так что разъехаться
двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта»
цел (слот здесь не резервируется и не отдаётся).
Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не
покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается
именно доля, и большинство отказов снова ходило бы в базу.
"""
return (
_verify_inflight >= settings.login_password_verify_max_inflight
or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap()
)
async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
"""`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665).
ДВЕ ПОЛОВИНЫ ОДНОЙ ПРАВКИ, И ЖИВУТ ОНИ ЗДЕСЬ ВМЕСТЕ НЕ ИЗ ЛЮБВИ К ПОРЯДКУ.
Порознь каждая делает хуже, чем было:
- вынести bcrypt в пул, не поставив потолок перебор УСКОРЯЕТСЯ (замер
ниже: 3.6/с 16/с на дефолтном executor'е);
- поставить потолок, не вынося bcrypt 282 мс простоя всего API на каждую
попытку остаются.
Поэтому единственная точка выноса в поток и единственная точка учёта слотов
одна и та же функция: состояние «вынесено, но потолка нет» невыразимо.
Замер в прод-контейнере (2026-08-06, cost 12, все живые хеши `$2b$12$`):
verify_password = 282 мс медиана;
вызов прямо в `async def` 3.6 проверки/с, стойло событийного цикла 836 мс
(это и был «потолок» случайный, ценой отказа в обслуживании всего API);
`asyncio.to_thread` без потолка 16 проверок/с, стойло 6 мс.
Отсюда дефолт `workers=1`: потолок остаётся тем же ~3.5/с, что был, а API
перестаёт стоять. Числа перепроверяемы: tests/test_password.py.
Потолок держится ПРОЦЕССОМ, а не общим хранилищем. Это проверено, а не
предположено: прод-бэкенд запущен `uvicorn app.main:app` без `--workers`
(один процесс), а `REDIS_URL` в окружении tradein-backend НЕ ЗАДАН вовсе
(`printenv | grep -c ^REDIS_URL=` 0, находка эпика #2674 — кэш поиска всю
жизнь стучится в localhost и получает отказ). Потолок на Redis был бы
потолком, который молча не работает.
Ceiling: появятся `--workers N` (или `WEB_CONCURRENCY=N` в `.env.runtime`
uvicorn читает число процессов и оттуда, а файл правится руками на VPS)
темп множится на N, как и у соседних in-memory лимитеров в
app/api/v1/auth.py; тогда потолок надо переносить в общее хранилище,
предварительно убедившись, что оно реально доступно.
ДОЛЯ НА КЛЮЧ (#2714). Слоты — общий котёл, и потолок исправно бил по своим:
пока флуд держал все четыре, легитимный вход с ВЕРНЫМ паролем получал 429
столько раз, сколько пытался. Поэтому *key* (у единственного вызывающего
IP клиента) не берёт больше `_per_key_slot_cap()`: сколько бы один источник
ни слал, половина ёмкости остаётся тем, кто приходит впервые. Учёт по ключу
живёт ЗДЕСЬ ЖЕ и отдаётся тем же `_release_verify_slot` инвариант «одна
точка выноса = одна точка учёта» не делится надвое.
Чего это НЕ делает, и это не оговорка ради приличия. Ключом может быть
только IP, а IP:
- подделывается, если между нами и клиентом окажется ещё один прокси
(сейчас доверенный хоп ровно один Caddy, `ratelimit._client_ip` берёт
правый элемент XFF; появится второй ключ станет клиентским вводом);
- разделяется: за NAT/корпоративным шлюзом вся организация приходит с
одного адреса и делит одну долю с чужим перебором. СОСЕДЯМ ПО АДРЕСУ
СТАЛО ХУЖЕ, и это честный размен, а не побочный эффект: при флуде в
3 запроса/с с того же адреса свои входят 69% попыток против 94% до
правки, а порог, за которым сосед перестаёт входить, падает с ~14 до
~7 запросов/с. Взамен вход С ЧУЖИХ адресов идёт 100% против 37%;
размен принят сознательно офис за одним NAT это единицы адресов,
а «все остальные» это все;
- меняется: ботнет или ротация прокси дают злоумышленнику столько ключей,
сколько ему нужно, и доля на ключ перестаёт быть ограничением.
То есть это ПОДНИМАЕТ СТОИМОСТЬ атаки (одного адреса больше не хватает,
чтобы закрыть вход всем), но не закрывает её. Закрывают принципиально
только доказательство работы на входе или второй фактор отдельный разговор
и отдельная цена.
Raises:
PasswordVerifyOverloadedError: очередь на проверку заполнена
(`login_password_verify_max_inflight`) ЛИБО *key* уже держит свою
долю (`_per_key_slot_cap`). Отказ мгновенный: ждать нельзя, ждущий
запрос держит соединение к БД. Оба случая неразличимы снаружи
намеренно отказ приходит ДО сверки и потому ничего не сообщает о
том, существует ли учётка.
"""
global _verify_inflight
# АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее
# (`verify_slots_saturated`, #2715), но решение принимается здесь и только
# здесь — предчек экономит поход в БД, а не заменяет этот отказ.
if verify_slots_saturated(key):
raise PasswordVerifyOverloadedError
loop = asyncio.get_running_loop()
_verify_inflight += 1
_verify_inflight_by_key[key] = _verify_inflight_by_key.get(key, 0) + 1
try:
work = _VERIFY_POOL.submit(verify_password, plain, hashed)
except BaseException:
# Работа в пул НЕ встала — колбэка не будет, слот отдаём здесь. Иначе
# утёкший слот навсегда отнимает у входа часть и без того малой ёмкости.
_release_verify_slot(key)
raise
# Слот освобождает ЗАВЕРШЕНИЕ РАБОТЫ, а не выход из этой корутины. Отмена
# (клиент отвалился, таймаут) прекращает корутину, но УЖЕ НАЧАТУЮ сверку не
# снимает — поток занят ею все 282 мс. Отдавай мы слот в `finally`, на это
# время слот считался бы свободным: одновременно работающих сверок стало бы
# больше, чем разрешено, и очередь пула поехала бы вслед за ними.
# (Ещё не начатую работу отмена как раз снимает — `cancel()` пробрасывается
# на future пула, — так что вреда от неё нет; проблема ровно в начатой.)
#
# Именно поэтому колбэк висит на future ПУЛА, а не на обёртке из
# `run_in_executor`: у обёртки «готово» наступает и при отмене — тест
# `test_bounded_slot_freed_by_the_work_not_by_cancellation` ловит эту разницу.
work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop, key))
return await asyncio.wrap_future(work)
def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop, key: str) -> None:
"""Возвращает слот по факту завершения работы в пуле (см. вызывающую).
Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчики собственность
потока событийного цикла (на том и держится арифметика без лока), поэтому
декремент переносим в цикл через `call_soon_threadsafe`.
"""
try:
loop.call_soon_threadsafe(_release_verify_slot, key)
except RuntimeError:
# Цикл уже закрыт (остановка процесса) — освобождать нечего и некому.
logger.debug("verify slot release skipped: event loop is closed")
def _release_verify_slot(key: str) -> None:
"""Единственное место, где слот отдают: и общий счётчик, и счётчик ключа.
Оба одним движением и здесь же, а не по одному на каждом пути выхода:
разъедься они, и достаточно забыть одну строчку, чтобы ключ навсегда унёс
с собой долю ёмкости, которую никто уже не вернёт.
"""
global _verify_inflight
_verify_inflight -= 1
left = _verify_inflight_by_key.get(key, 0) - 1
if left > 0:
_verify_inflight_by_key[key] = left
else:
_verify_inflight_by_key.pop(key, None)

View file

@ -114,8 +114,13 @@ class SlidingWindowLimiter:
return self._window_s - (now - bucket[0])
return None
def record(self, key: str) -> None:
"""Регистрирует одну успешную попытку под *key*."""
def record(self, key: str) -> int:
"""Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
пропорционально. Значение можно игнорировать `check()` так и делает.
"""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k]
return len(bucket)
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —

View file

@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
# не секрет, читает только process env — быстрая справка для клиента/
# поддержки/смоук-теста, не должна требовать сессию.
"/api/v1/trade-in/version",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -0,0 +1,83 @@
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
запечённые в образ через build-args в `backend/Dockerfile`
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) там же
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
`date -u +%Y-%m-%dT%H:%M:%SZ`.
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата на момент импорта
модуля. Ничего здесь не должно падать при отсутствии env (потребитель
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
Номер версии НЕ дублируется больше нигде в коде читай `APP_VERSION` отсюда.
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
`@/lib/buildInfo` соответственно; не заводи третий.
"""
from __future__ import annotations
import datetime as dt
import os
from pathlib import Path
_DEFAULT_VERSION = "0.0.0"
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
_MAX_ANCESTORS = 6
def _find_version_file() -> Path | None:
here = Path(__file__).resolve()
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
candidate = ancestor / "VERSION"
if candidate.is_file():
return candidate
return None
def _read_version_file() -> str:
path = _find_version_file()
if path is None:
return _DEFAULT_VERSION
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
return _DEFAULT_VERSION
return text or _DEFAULT_VERSION
def _default_build_date() -> str:
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
# перечитывать на каждый запрос незачем.
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
колонтитул). Никогда не бросает исключение при неразборчивой строке
возвращает её как есть (это футер отчёта, не API-контракт)."""
try:
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return build_date
return parsed.strftime("%d.%m.%Y")
def product_version_line(product_name: str = "Мера") -> str:
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"

View file

@ -29,10 +29,12 @@ from app.api.v1 import (
geocode,
lead,
me,
privacy_admin,
search,
support,
team,
trade_in,
version,
)
from app.core.auth_db import get_auth_engine
from app.core.config import settings
@ -143,6 +145,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
get_auth_engine()
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
# Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис
# ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то
# включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт
# TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще,
# чем молча остаться без терминала/пароля на боевом трафике оплат.
if settings.payments_enabled and (
not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value()
):
raise RuntimeError(
"PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — "
"задай оба в .env.runtime до включения платёжного контура"
)
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs.
try:
@ -199,8 +214,10 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])

View file

@ -40,7 +40,9 @@ class SearchParams(BaseModel):
floors_total_max: int | None = Field(default=None, ge=1)
# --- Quality / cross-source ---
has_kadastr: bool = False
# has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
# фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует,
# так что старые клиенты не ломаются.
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
multi_source_only: bool = False
require_avito: bool = False

View file

@ -48,6 +48,15 @@ class TradeInEstimateInput(BaseModel):
has_mortgage: bool | None = None
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
# ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229).
# Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет
# `created_by is None and not consent -> 422` ДО первого INSERT адреса в
# trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ
# `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго-
# обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
consent: bool | None = None
class AnalogLot(BaseModel):
address: str
@ -187,6 +196,10 @@ class AggregatedEstimate(BaseModel):
analogs: list[AnalogLot]
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
expires_at: datetime
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
retain_until: datetime | None = None
# ── Дополнительные метаданные ──
target_address: str | None = None # geocoded full address
target_lat: float | None = None
@ -306,6 +319,41 @@ class AggregatedEstimate(BaseModel):
cv: float | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime | None = None
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
# #oblast-E priority RESTORED same day — see estimator.py module
# docstring for the full 3-way headline-source rule) ──────────────────
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
# estimator.estimate_quality() progressively relaxes the analog SEARCH
# (room-count adjacency → freshness window → novostroyki segment → radius)
# trying to grow the sample past the threshold; if it's STILL thin,
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
# thin listings median when one is available (restored #oblast-E
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
# only falls back to the thin listings median itself when no corridor
# exists. Real refusal happens only at genuine zero (no listings AND no
# usable anchor/deals).
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
# не понадобился (обычный случай). Возможные значения (дословно, фронт
# может на них завязываться): "снят фильтр по году постройки",
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
# deals corridor because the base listings sample was thin — a source
# SWITCH, not a search widening, but surfaced the same way).
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
# relaxations непусто (выборка набралась только ценой ослаблений); капается
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
# путать с `confidence` (Literal low/medium/high — старая метрика на
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
relaxations: list[str] = Field(default_factory=list)
reliability: Literal["ok", "low", "very_low"] = "ok"
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
area_m2: float | None = None
@ -417,6 +465,11 @@ class ScheduleConfigUpdate(BaseModel):
window_start_hour: int = Field(default=2, ge=0, le=23)
window_end_hour: int = Field(default=5, ge=0, le=23)
default_params: dict[str, Any] = Field(default_factory=dict)
# #2674: явная воля оператора по времени следующего запуска. None (умолчание) —
# «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая
# прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с
# next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом.
next_run_at: datetime | None = None
# ── House analytics (house_placement_history backfill) ───────────────────────
@ -604,6 +657,13 @@ class SalesVsListingsResponse(BaseModel):
deals_with_listings: int # сколько имеют связанный listing
linkage_rate_pct: float # deals_with_listings / total_deals * 100
median_discount_pct: float | None # медиана по парам с listing
# #2666: None вместе с median_discount_pct=None означает «медианы просто нет»
# (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт
# правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги
# SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается.
# Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен
# текст «почему числа нет», иначе пустое место читается как поломка виджета.
median_discount_explanation: str | None = None
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC

View file

@ -19,19 +19,40 @@ from dataclasses import dataclass, field
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
# идентичным прежнему прямому импорту settings.
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
from app.services.scraper_adapters import (
RealMatcherAdapter,
RealProxyProvider,
RealScraperConfig,
)
from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
class _PoolCurlConfig(RealScraperConfig):
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
services.scraper.environment), а этот бэкфилл запускается ручкой
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
`settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
Флаг рубильник раскатки pool-режима для планировщика, а не решение «этому пути
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
"""
@property
def use_proxy_pool_curl(self) -> bool:
return True
@dataclass
class CianPriceHistoryResult:
checked: int = 0
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
result = CianPriceHistoryResult()
t0 = time.time()
delay = get_scraper_delay("cian") # default 5.0s
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (mark_banned на CianBlockedError / mark_health / release).
scraper_config = _PoolCurlConfig()
proxy_provider = RealProxyProvider()
if listing_id is not None:
rows = (
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
url: str = row["source_url"]
try:
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
enrichment = await fetch_detail(url, config=RealScraperConfig())
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
enrichment = await fetch_detail(
url, config=scraper_config, proxy_provider=proxy_provider
)
except NoProxyAvailableError as exc:
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует».
logger.error(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)",
exc,
lid,
i,
len(rows),
)
result.errors += 1
break
except Exception as exc:
logger.warning(
"cian_price_history: fetch failed listing_id=%s url=%s: %s",

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from curl_cffi.requests import AsyncSession
@ -20,9 +21,15 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies критичные для Cian auth — фильтр перед сохранением.
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
# Старые записи оставлены как fallback (backward compat).
@ -146,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
try:
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
# Пусто (env не задан) → прямое подключение (dev/no-op).
_proxy_url = settings.cian_proxy_url
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
# ниже.
_proxy_url = resolve_proxy_url_sync("cian")
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(
impersonate="chrome120",
@ -184,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
return result
except ProxyPoolExhaustedError as exc:
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.error(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,
)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
except Exception as exc:
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
@ -294,6 +316,37 @@ def load_session(db: Session) -> dict[str, str] | None:
return cookies
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (#2658).
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
протухших отдаёт None вызывающий не мог отличить «кук никогда не загружали» от
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
valid_only=False (диагностика после None от load_session) свежайшая запись любая:
валидных по определению нет, нужен именно срок протухшей. valid_only=True та же
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
"""
row = db.execute(
text(
"""
SELECT expires_at_estimate FROM cian_session_cookies
WHERE NOT CAST(:valid_only AS boolean)
OR (expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
ORDER BY uploaded_at DESC
LIMIT 1
"""
),
{"valid_only": valid_only},
).first()
if row is None:
return None
expires_at: datetime | None = row[0]
return expires_at
def mark_session_invalid(db: Session, account_user_id: int) -> None:
"""Flag session как expired/invalid (например после 401 во время scrape)."""
db.execute(

View file

@ -0,0 +1,240 @@
"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C.
WHY:
trade_in has no self-service "delete my data" endpoint at all. Both B2B
pilots (identified by `created_by` username) and future anonymous B2C
users need SOME way to have their personal data physically removed on
request, not just after their retention TTL expires
(app/tasks/purge_expired_trade_in_data.py handles the TTL path, this
module handles the on-demand path).
WHO CAN BE IDENTIFIED, HONESTLY:
- B2B pilot (has a `username`): trivially -- `created_by = username` scopes
every estimate they created; leads/support threads follow from there.
- Anonymous person: has NO username. This function can ONLY act on
identifiers the requester can actually supply:
* `estimate_ids` -- if they still have the link/PDF from their estimate
(the UUID in the URL/QR-code IS their proof of "this is mine").
* `phone` -- if they left a contact-request lead with that phone.
Matched by CANONICAL RU DIGITS on both sides (see
`_ru_phone_norm_sql` below), not an exact string: lead.py stores
`payload.phone` exactly as typed (no E.164 normalization, by
design), so "+7 999 123-45-67", "8 (999) 123-45-67" and
"89991234567" must all find the same row. Covers ONLY the
RU 8-vs-7 trunk-prefix case (exact digit-count identity, no
heuristic truncation) -- see the helper's docstring for why.
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
Telegram chat id -- not guessable/spoofable by a third party the way
a name or IP would be).
If an anonymous person has NONE of these (e.g. they only remember the
street address, or ran an estimate but never saved anything and didn't
log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional
identification. There is no username, no stable session, nothing in the
DB schema today that lets a support operator find "the one estimate this
specific stranger made three days ago" among many. Do not paper over
this: an operator facing that case must say so, not silently pick "the
closest match".
TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit):
Every tg_support_messages row was, at send time, ALSO mirrored by the bot
into the support-group Telegram topic (see app/services/tgbot/bridge.py,
186_tg_support.sql). Deleting `tg_support_users` here only removes the
copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup,
outside this function's reach, and is NOT deleted by anything in this
codebase. A complete erasure across the whole chain requires a SEPARATE
manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in
the supergroup) that is out of scope here. Do not cite this function's
return value as proof of full erasure of the Telegram-side copy.
WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped):
`user_events` (184_user_events.sql) logs `estimate_request` events with a
JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by
`username` (empty string for anonymous callers today) + `ip_address`, with
NO FK to trade_in_estimates (decoupled/append-only by explicit design --
see that migration's comment). This function does NOT purge user_events:
it is an audit/analytics log, not an estimate/lead/support record, and
deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that
overrides an erasure request is a legal call, not an engineering one. Flag
it to whoever handles the request; do not assume it is already covered.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _ru_phone_norm_sql(expr: str) -> str:
"""SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр,
ведущая '7'), для сравнения "разного форматирования одного и того же номера"
(deep-review 2026-08-06, MEDIUM + follow-up).
Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с
ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской
нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина
проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально
НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений
неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми
цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем
неудаление своих. Номера другой длины/страны просто не совпадут ни на
этом шаге, ни дальше -- безопасный отказ, не false positive.
`expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или
`CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит
структуру запроса из литералов, вызывающих её мест ровно два (см.
_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон
не попадает в текст SQL напрямую, только через bind-параметр `:phone`.
"""
stripped = f"regexp_replace({expr}, '\\D', '', 'g')"
return (
f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' "
f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)"
)
# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data
# (колонка trade_in_leads.phone / входной CAST(:phone AS text)).
_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone")
_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)")
def erase_person_data(
db: Session,
*,
username: str | None = None,
estimate_ids: Sequence[UUID] | None = None,
phone: str | None = None,
tg_chat_id: int | None = None,
) -> dict[str, int]:
"""Physically delete a person's data across trade_in tables.
At least one identifier is required (raises ValueError otherwise -- callers
MUST pass an explicit identifier, never "erase everything" by omission).
Order of operations matters: leads are captured/deleted BEFORE estimates,
because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the
estimate row is gone, the join key to find "leads that came from this
person's estimate" is gone too.
Returns per-table deleted-row counters. Callers own committing the ambient
Session lifecycle in whatever way their layer does (this function DOES
commit itself, mirroring app/tasks/*.py conventions, since this is a
one-shot admin operation, not a request-scoped unit of work shared with
other writes).
"""
if not any([username, estimate_ids, phone, tg_chat_id]):
raise ValueError(
"erase_person_data requires at least one identifier: "
"username / estimate_ids / phone / tg_chat_id"
)
counters: dict[str, int] = {
"trade_in_estimates_deleted": 0,
"trade_in_leads_deleted": 0,
"web_support_deleted": 0,
"tg_support_deleted": 0,
}
# 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids
# (анонимный путь -- человек прислал ссылку/PDF) + все id с
# created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после
# DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id
# уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен.
all_estimate_ids: set[UUID] = set(estimate_ids or [])
if username:
owned = (
db.execute(
text("SELECT id FROM trade_in_estimates WHERE created_by = :username"),
{"username": username},
)
.scalars()
.all()
)
all_estimate_ids.update(owned)
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
# телефону (лид мог быть оставлен без attach к оценке вовсе).
#
# ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом
# (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py
# сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная
# E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN),
# т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67"
# ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone`
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
# _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql
# выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую
# '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс,
# без усечения до "последних 10 цифр" (риск ложного совпадения с
# номером другой страны -- см. докстринг helper'а). Номера иных
# форматов/длин сравниваются как есть (просто не совпадут). Параметр --
# CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL
# injection convention, .claude/rules/backend.md); сам SQL-текст
# собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет
# ни одного значения параметра.
ids_param = [str(i) for i in all_estimate_ids]
result = db.execute(
text(
f"""
DELETE FROM trade_in_leads
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
OR (
CAST(:phone AS text) IS NOT NULL
AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL}
)
"""
),
{"ids": ids_param, "phone": phone},
)
counters["trade_in_leads_deleted"] = result.rowcount or 0
# 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations).
if all_estimate_ids:
result = db.execute(
text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": ids_param},
)
counters["trade_in_estimates_deleted"] = result.rowcount or 0
# 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у
# анонима username нет и быть не может, см. 187_web_support_chat.sql).
if username:
result = db.execute(
text("DELETE FROM web_support_threads WHERE username = :username"),
{"username": username},
)
counters["web_support_deleted"] = result.rowcount or 0
# 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально
# доступный анониму без username (см. module docstring). ⚠️ Чистит
# ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ
# затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше.
if tg_chat_id is not None:
result = db.execute(
text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"),
{"chat_id": tg_chat_id},
)
counters["tg_support_deleted"] = result.rowcount or 0
db.commit()
logger.info(
"erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s",
username,
len(all_estimate_ids),
"<redacted>" if phone else None,
tg_chat_id,
counters,
)
return counters
__all__: list[str] = ["erase_person_data"]

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -25,6 +26,13 @@ from app.core.config import settings
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана).
# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас:
# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03:
# куки протухли, единственным следом был WARNING в docker-логе, который к тому же
# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
# test-аккаунт сессии (Sber ID login), 2026-07-04.
@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None:
return cookies
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658).
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
протухших отдаёт None вызывающий не мог отличить «кук никогда не загружали» от
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
valid_only=False (диагностика после None от load_session) свежайшая запись любая:
валидных по определению нет, нужен именно срок протухшей. valid_only=True та же
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
"""
row = db.execute(
text(
"""
SELECT expires_at_estimate FROM domclick_session_cookies
WHERE NOT CAST(:valid_only AS boolean)
OR (expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
ORDER BY uploaded_at DESC
LIMIT 1
"""
),
{"valid_only": valid_only},
).first()
if row is None:
return None
expires_at: datetime | None = row[0]
return expires_at
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
"""Flag session как expired/invalid (например после блока во время scrape)."""
db.execute(

File diff suppressed because it is too large Load diff

View file

@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
from matplotlib.patches import Rectangle
from app.core.config import settings
from app.core.version import product_version_line
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
_BORDER = _LINE
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
# HUD, а не версия PDF-модуля/API.
_REPORT_ENGINE_VERSION = "v2.0.6"
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
"ДАТА", report_date.strftime("%d.%m.%Y")
)
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
version_html = (
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
f"{_html.escape(product_version_line())}</div>"
)
return (
f"<div>"
f'<div style="display:flex;align-items:center;justify-content:space-between;'
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
f'padding-bottom:6pt;margin-bottom:9pt;">'
f'padding-bottom:6pt;margin-bottom:3pt;">'
f"{mark_html}"
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
f"</div>"
f"{version_html}"
f"</div>"
)
@ -529,7 +542,11 @@ def _page_footer(
строка 1 mono meta ( отчёта / дата / срок действия); тонкая градиентная
линия-разделитель; строка 2 точка акцента + wordmark (brand.name НЕ
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
НЕ дублируется единственное место вывода версии в PDF running-header
(_page_header product_version_line()); раньше рядом с wordmark висел
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
версией продукта расходился с header на каждой странице, снесён.
page_note старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
@ -587,9 +604,6 @@ def _page_footer(
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
min-width:0;overflow-wrap:anywhere;">
{_html.escape(brand.name).upper()}</span>
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
flex-shrink:0;white-space:nowrap;">
{_REPORT_ENGINE_VERSION}</span>
</div>
</div>
</div>
@ -1050,6 +1064,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
)
report_num = _report_number(estimate)
# PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа
# (retain_until), НЕ путать со «Срок действия данных» (expires_at,
# актуальность расчёта) над ней — эта строка не трогается. Рендерится
# ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий
# трафик — не видят этой строки вообще, поведение бит-в-бит текущее).
retain_until_row = (
f'<tr><td class="dotted-row">Ссылка доступна до</td>'
f'<td class="bold dotted-row">'
f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}</td></tr>"
if estimate.retain_until is not None
else ""
)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
address_short = full_address.split(",")[0:3]
@ -1146,6 +1173,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
<tr><td class="dotted-row">Срок действия данных</td>
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
{retain_until_row}
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td>
<td class="bold dotted-row">{year_label}</td></tr>
@ -1229,11 +1257,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
return min(prices), max(prices)
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
estimate.analogs is non-empty (the thin listings that triggered the cession
are still shown below as reference cards never cleared, see estimator.py
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
if estimate.n_analogs != 0 or not estimate.analogs:
return ""
return (
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
"для наглядности рынка.</p>"
)
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
already shows this (LowConfidenceBanner); the PDF stayed silent, a
client-visible discrepancy between the two. Empty string (no-op) when
reliability=='ok' and relaxations is empty the common, unrelaxed case,
byte-identical to the report before these fields existed."""
if estimate.reliability == "ok" and not estimate.relaxations:
return ""
if estimate.relaxations:
detail = "Подбор аналогов расширен: " + ", ".join(
_html.escape(r) for r in estimate.relaxations
)
else:
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
# которую каскад ослаблений не смог расширить, см. estimator.py
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
# что и в счётчике выше страницы).
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
return f"""
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
{detail} данные ниже приведены с этой оговоркой.
</div>
"""
# ── Page 2: Listings (market) ────────────────────────────────────────────────
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_total = estimate.n_analogs
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
# listings that triggered that cession are still shown below as display cards
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
# guard comment). Printing raw n_analogs there read as "0 шт." above a
# non-empty examples table — a client-visible contradiction. n_analogs is
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
# no-op in that common case (count stays the honest FULL n_analogs) and only
# changes anything in this one pathological case, where it falls back to
# "how many are actually shown" instead of the dishonest zero.
n_total = max(estimate.n_analogs, len(estimate.analogs))
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
@ -1292,6 +1380,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
examples_rows = _examples_rows(top5)
heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
reliability_note = _reliability_note_html(estimate, n_total)
return f"""
<div style="page-break-after:always;">
@ -1306,6 +1398,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
</table>
{deals_sourced_note}
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
@ -1330,6 +1423,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
</td>
</tr>
</table>
{reliability_note}
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен в объявлениях</p>

View file

@ -13,8 +13,10 @@ WHAT this is:
pipeline, run inside ONE transaction so a crash leaves the table untouched.
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod
confirmed in migration 040 so address is the real building key). The clean source matters:
COALESCE(short_address, full_address, address) the address is the only building key we
have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
prod», is no longer true 2 648 of 9 179 rows carry one and the conclusion no longer
rests on it). The clean source matters:
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
( «вайнера66») preferring the clean field lets such a row cluster with its twin. The canon
@ -92,6 +94,67 @@ BACKFILL (reduces recurrence):
(same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and
does not immediately re-split it.
MERGE JOURNAL the merge is REVERSIBLE (#2690, migration 230):
Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge:
the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity
carry-over, the ids of every child row whose FK moved, the full snapshots of every child row
a UNIQUE collision destroyed, plus the grounds which pass, which cluster-key VALUE fired,
whether the geo guard was on, and the keeperloser distance in metres.
This exists because the merge used to leave no restorable trace: losers were hard-deleted
with their children and the only record of «what went into what» was a log line, in a
container whose logs rotate faster than a day. A day after a run nobody could even NAME the
pairs, and the only rollback was restoring the whole database.
Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction restores the loser
rows, points the children back, re-inserts the destroyed children, and un-does the identity
carry-over on the keeper, reporting per record what it could and could not restore.
NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no
keeper rule and no guard. It only makes whatever the pass decides reversible which is the
precondition for revisiting those decisions at all (#2690, #1772).
distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
asymmetry merge allowed without a proximity check was invisible in data before; now
«how many merges happened beyond N metres, on which key» is one query.
KEY there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
#2690 asked for a cluster key that does not come from the normalized address, so that two
rows merge on two independent statements of identity rather than one restated twice. Every
field `houses` carries was checked against the live table. None qualifies:
cadastral_number 2 648 filled, ALL 2 648 values DISTINCT collapses nothing. Provenance:
all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
DaData's answer to our address string, not a second observation of the
building. (The other cadastre we hold, listings.building_cadastral_number,
is the KNN geo-nearest hint 20.1% of its values cover >1 ГАР building;
#2674 refused it as an identity key and that stands.)
house_fias_id 3 678 filled, ALL DISTINCT the FIAS pass merges 0 rows today. Same
DaData provenance.
gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
the canon (the guid restates it), 17 do not and 5 of those 17 are
>250 m apart, worst 5 064 km. Still circular, still noisy.
zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
loader sets it WHERE gar_house_guid = <guid>, i.e. it IS the ГАР guid for
4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
source+ext_house_id, cian_internal_house_id, yandex_jk_id
distinct by construction / 39 / 0 rows nothing to cluster.
coordinates a real independent observation, but not an IDENTITY: neighbours share a
yard. It is already used the only way it can be as the guard.
year_built+total_floors
a FALSE witness, not a corroborator: of the 391 same-canon pairs the
guard cannot judge, only 18 agree on both fields (357 have a NULL), while
306 pairs the guard rejected at >250 m DO agree it would confirm merges
that are provably wrong.
Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued see the
residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
(median 1 084 m) those are not duplicates at all, the canon key is wrong about them.
IDEMPOTENCY:
Every UPDATE/DELETE keys off a temp mapping of (loserkeeper). On a clean table the
mapping is empty every statement touches 0 rows no-op. Re-running is safe.
@ -105,8 +168,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas
from __future__ import annotations
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
@ -132,9 +197,24 @@ _COMPLETENESS_EXPR = """
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
#
# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома
# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём
# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
#
# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
_KEEPER_ORDER = f"""
(h.geom IS NOT NULL) DESC,
listing_cnt DESC,
listing_cnt DESC NULLS LAST,
({_COMPLETENESS_EXPR}) DESC,
h.id ASC
"""
@ -166,37 +246,17 @@ _CANON_KEY_EXPR = """
"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
def _ranked_cte(cluster_key_case: str) -> str:
"""Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
row shares one fias by construction).
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- CANON pass True: the canon strips город/район, so same-street-number buildings in
different region-66 towns share a canon; the guard stops the cross-town over-merge.
- FIAS pass False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
geom-first keeper rule simultaneously repairs the broken coordinate).
Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
(`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about a census
built from its own copy of the clustering would drift from the pass it describes and the
drift would be invisible (it is the same class of error as #2690's cluster key: two
expressions that look alike and are not).
`cluster_key_case` is a STATIC module constant (never runtime data) no value injection.
"""
geo_guard = (
"""
-- GEO GUARD (canon pass only #2187). tradein_canon_addr strips город/район, so two
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
-- keeper (<=250 m covers one building's geocode spread, prod: Мраморская 34к4 dupes at
-- 222 m; region-66 towns are km+ apart 250 m is safe from cross-town). >250 m, or NULL
-- geom on either side, left as separate rows (conservative never over-merges).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
if apply_geo_guard
else ""
)
return f"""
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
WITH clustered AS (
SELECT
id,
@ -248,12 +308,55 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
FROM dup_houses dh
JOIN houses h ON h.id = dh.id
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
)"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
row shares one fias by construction).
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
- CANON pass True: the canon strips город/район, so same-street-number buildings in
different region-66 towns share a canon; the guard stops the cross-town over-merge.
- FIAS pass False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
geom-first keeper rule simultaneously repairs the broken coordinate).
`cluster_key_case` is a STATIC module constant (never runtime data) no value injection.
"""
geo_guard = (
"""
-- GEO GUARD (canon pass only #2187). tradein_canon_addr strips город/район, so two
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
-- keeper (<=250 m covers one building's geocode spread, prod: Мраморская 34к4 dupes at
-- 222 m; region-66 towns are km+ apart 250 m is safe from cross-town). >250 m, or NULL
-- geom on either side, left as separate rows (conservative never over-merges).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
if apply_geo_guard
else ""
)
return f"""
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
{_ranked_cte(cluster_key_case)}
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
-- DIFFERENT house_fias_id provably different buildings the cluster key collapsed (canon
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
-- cluster) and for canon clusters where at most one side carries a fias.
SELECT id AS loser_id, keeper_id, norm_address
--
-- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690):
-- cluster_key records WHICH key value fired, distance_m how far apart the two rows were.
-- distance_m is computed even when the geo guard is OFF for this pass that is precisely
-- the case where nothing else records the distance, and #2690 had no way to ask
-- «how many merges happened at distances the guard would have blocked» from data.
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
THEN ST_DistanceSphere(loser_geom, keeper_geom)
END AS distance_m
FROM ranked
WHERE rn > 1
AND id <> keeper_id{geo_guard}
@ -272,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
#
# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
# out is counted here, bucketed by the reason it survived.
#
# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
#
# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
# rejected» are opposite facts:
# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
# run; non-zero is a tripwire on the pass itself, not a census entry.
# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
_RESIDUAL_SQL = text(
f"""
{_ranked_cte(_CANON_KEY_EXPR)}
SELECT
count(*) FILTER (WHERE rn > 1) AS residual_rows,
COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
AS residual_no_geom,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
AS residual_mergeable
FROM (
SELECT rn,
COALESCE(lc.listing_cnt, 0) AS lcnt,
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
THEN ST_DistanceSphere(loser_geom, keeper_geom)
END AS dist,
(NULLIF(loser_fias, '') IS NOT NULL
AND NULLIF(keeper_fias, '') IS NOT NULL
AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
FROM ranked
LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
) r
"""
)
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
_STEPS: list[tuple[str, str]] = [
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
@ -282,6 +433,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id_fk = m.keeper_id
FROM _1772_dup_mapping m
WHERE l.house_id_fk = m.loser_id
RETURNING m.loser_id, l.id AS child_id
""",
),
(
@ -291,6 +443,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hph.house_id = m.loser_id
RETURNING m.loser_id, hph.id AS child_id
""",
),
(
@ -300,6 +453,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hr.house_id = m.loser_id
RETURNING m.loser_id, hr.id AS child_id
""",
),
(
@ -309,6 +463,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hrc.house_id = m.loser_id
RETURNING m.loser_id, hrc.id AS child_id
""",
),
(
@ -318,6 +473,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE ev.house_id = m.loser_id
RETURNING m.loser_id, ev.id AS child_id
""",
),
# ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ─────
@ -333,6 +489,7 @@ _STEPS: list[tuple[str, str]] = [
AND hs2.ext_source = hs.ext_source
AND hs2.ext_id = hs.ext_id
)
RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot
""",
),
(
@ -342,6 +499,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hs.house_id = m.loser_id
RETURNING m.loser_id, hs.id AS child_id
""",
),
# ── UNIQUE(normalized_address): delete colliding losers, re-point rest ─────
@ -356,6 +514,7 @@ _STEPS: list[tuple[str, str]] = [
WHERE haa2.house_id = m.keeper_id
AND haa2.normalized_address = haa.normalized_address
)
RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot
""",
),
(
@ -365,6 +524,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE haa.house_id = m.loser_id
RETURNING m.loser_id, haa.id AS child_id
""",
),
# ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ──
@ -386,6 +546,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -395,6 +556,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hpd.house_id = m.loser_id
RETURNING m.loser_id, hpd.id AS child_id
""",
),
# ── UNIQUE(house_id): one evaluation per keeper ───────────────────────────
@ -412,6 +574,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -421,6 +584,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hie.house_id = m.loser_id
RETURNING m.loser_id, hie.id AS child_id
""",
),
# ── UNIQUE(house_id, ext_item_id) ─────────────────────────────────────────
@ -438,6 +602,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -447,6 +612,7 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE hs.house_id = m.loser_id
RETURNING m.loser_id, hs.id AS child_id
""",
),
# ── UNIQUE(house_id, audit_batch) ─────────────────────────────────────────
@ -464,6 +630,7 @@ _STEPS: list[tuple[str, str]] = [
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
) d
WHERE t.id = d.id AND d.rn > 1
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
""",
),
(
@ -473,10 +640,98 @@ _STEPS: list[tuple[str, str]] = [
SET house_id = m.keeper_id
FROM _1772_dup_mapping m
WHERE ama.house_id = m.loser_id
RETURNING m.loser_id, ama.id AS child_id
""",
),
]
# ── MERGE JOURNAL (#2690) ─────────────────────────────────────────────────────
#
# Every child of houses(id) except `listings` references it through a column named house_id;
# listings uses house_id_fk. The undo function reads the column name back out of the journal
# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible.
_FK_COLUMN = {"listings": "house_id_fk"}
# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the
# set cannot drift away from them. Compared against pg_catalog before every merge — see
# _assert_all_fk_children_handled.
_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset(
(tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS}
)
# Live FK children of houses(id), read from the catalog rather than trusted from a comment.
_FK_CHILDREN_SQL = text(
"""
SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table,
a.attname AS fk_column
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.confrelid = CAST('houses' AS regclass)
AND c.contype = 'f'
"""
)
# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row
# is the row as it stood, and keeper_before precedes the identity carry-over.
_JOURNAL_INSERT_SQL = text(
"""
INSERT INTO house_merge_log (
batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m,
norm_address, loser_id, keeper_id, loser_row, keeper_before
)
SELECT
CAST(:batch_id AS uuid),
CAST(:run_id AS bigint),
CAST(:initiator AS text),
CAST(:merge_pass AS text),
m.cluster_key,
CAST(:geo_guard AS boolean),
m.distance_m,
m.norm_address,
m.loser_id,
m.keeper_id,
to_jsonb(l.*),
to_jsonb(k.*)
FROM _1772_dup_mapping m
JOIN houses l ON l.id = m.loser_id
JOIN houses k ON k.id = m.keeper_id
"""
)
# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which
# were destroyed by a UNIQUE collision.
_JOURNAL_CHILDREN_SQL = text(
"""
UPDATE house_merge_log
SET children_repointed = CAST(:children_repointed AS jsonb),
children_deleted = CAST(:children_deleted AS jsonb)
WHERE batch_id = CAST(:batch_id AS uuid)
AND loser_id = CAST(:loser_id AS bigint)
"""
)
def _assert_all_fk_children_handled(db: Session) -> None:
"""Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle.
This is what makes the journal's promise true rather than merely documented. An unhandled
child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses`
would destroy its rows silently no re-point step touches them, no RETURNING records them,
and the journal would claim a complete snapshot it does not have. Migration 133 already
broke on prod for exactly this (a missed child); there the failure was loud. Here it would
be silent, which is worse. Aborting the transaction costs one skipped merge cycle.
"""
live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()}
unhandled = live - _HANDLED_CHILDREN
if unhandled:
raise RuntimeError(
"merge_duplicate_houses: houses(id) has FK children the merge does not handle: "
f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. "
"Add a re-point step to _STEPS (and its RETURNING) before merging again."
)
# Delete the loser houses — all FK children are re-pointed or CASCADE by now.
_DELETE_LOSERS_SQL = text(
"""
@ -580,6 +835,15 @@ class DedupMergeResult:
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
children_repointed: int = 0 # survivor child rows moved loser→keeper
# Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
# Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
# so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
residual_listings: int = 0 # listings hanging on them (the user-visible size)
residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
dry_run: bool = False
duration_sec: float = field(default=0.0)
@ -590,6 +854,12 @@ class DedupMergeResult:
"listings_repointed": self.listings_repointed,
"children_deleted": self.children_deleted,
"children_repointed": self.children_repointed,
"residual_rows": self.residual_rows,
"residual_listings": self.residual_listings,
"residual_no_geom": self.residual_no_geom,
"residual_far": self.residual_far,
"residual_cross_fias": self.residual_cross_fias,
"residual_mergeable": self.residual_mergeable,
"dry_run": int(self.dry_run),
"duration_sec": int(self.duration_sec),
}
@ -600,14 +870,20 @@ def _run_merge_pass(
*,
build_sql: Any,
pass_label: str,
geo_guard: bool,
batch_id: str,
run_id: int | None,
initiator: str,
result: DedupMergeResult,
) -> None:
"""Run ONE merge pass (fias- or canon-key) inside the caller's open transaction.
Builds a fresh loserkeeper mapping for this pass's cluster key, re-points every FK child
(UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and
backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back the
caller owns the single transaction wrapping both passes.
Builds a fresh loserkeeper mapping for this pass's cluster key, writes the MERGE JOURNAL
(#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto
the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto
`result`. NEVER commits/rolls back the caller owns the single transaction wrapping both
passes, which is also what makes the journal atomic with the merge: there is no ordering in
which the rows vanish but the journal entry does not land (and dry_run rolls back both).
"""
# Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table
# explicitly — the second pass must rebuild the same-named table within the one transaction.
@ -616,8 +892,8 @@ def _run_merge_pass(
mapping = db.execute(
text(
"SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping "
"ORDER BY keeper_id, loser_id"
"SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m "
"FROM _1772_dup_mapping ORDER BY keeper_id, loser_id"
)
).all()
if not mapping:
@ -627,32 +903,73 @@ def _run_merge_pass(
result.losers_deleted += len(mapping)
result.clusters_merged += len({row.keeper_id for row in mapping})
# Audit log: every loser→keeper move with its address, for traceability.
# JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them.
db.execute(
_JOURNAL_INSERT_SQL,
{
"batch_id": batch_id,
"run_id": run_id,
"initiator": initiator,
"merge_pass": pass_label,
"geo_guard": geo_guard,
},
)
# Container logs rotate faster than a day (#2690), so this line is a convenience, not the
# record — house_merge_log is. Distance is logged too: it is the one number that says
# whether a merge would have survived the geo guard.
for row in mapping:
logger.info(
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r",
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r "
"distance_m=%s batch=%s",
pass_label,
row.loser_id,
row.keeper_id,
row.norm_address,
"n/a" if row.distance_m is None else f"{row.distance_m:.0f}",
batch_id,
)
# Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the
# rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is
# left of them).
repointed: dict[int, dict[str, list[int]]] = {}
deleted: dict[int, dict[str, list[Any]]] = {}
for label, sql in _STEPS:
res = db.execute(text(sql))
rowcount = res.rowcount or 0
if label == "listings":
result.listings_repointed += rowcount
elif label.endswith("(collision-delete)") or label.endswith("(dedup)"):
rows = db.execute(text(sql)).all()
rowcount = len(rows)
table = label.split("(")[0]
if label.endswith("(collision-delete)") or label.endswith("(dedup)"):
result.children_deleted += rowcount
elif label.endswith("(re-point)") or label in (
"house_placement_history",
"house_reviews",
"house_reliability_checks",
"external_valuations",
):
result.children_repointed += rowcount
for r in rows:
deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot)
else:
key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}"
for r in rows:
repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id)
if label == "listings":
result.listings_repointed += rowcount
else:
result.children_repointed += rowcount
logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount)
# JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1.
touched = sorted(set(repointed) | set(deleted))
if touched:
db.execute(
_JOURNAL_CHILDREN_SQL,
[
{
"batch_id": batch_id,
"loser_id": loser_id,
"children_repointed": json.dumps(repointed.get(loser_id, {})),
"children_deleted": json.dumps(deleted.get(loser_id, {}), default=str),
}
for loser_id in touched
],
)
# Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill.
db.execute(_CARRY_OVER_IDENTITY_SQL)
db.execute(_DELETE_LOSERS_SQL)
@ -660,7 +977,56 @@ def _run_merge_pass(
db.execute(_BACKFILL_ALIASES_SQL)
def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
def _measure_residual(db: Session, result: DedupMergeResult) -> None:
"""Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
Read-only; runs after both passes, so it describes the table as the run leaves it (under
dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
`_run_merge_pass` because the census is about the CANON key only and must be taken once per
call, not once per pass.
Never fails the merge: the merge itself is the product, the census is instrumentation, and a
census that can abort a committed-by-now transaction would be worse than a missing number.
"""
try:
rows = db.execute(_RESIDUAL_SQL).all()
except Exception:
logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
return
if not rows:
return
r = rows[0]
result.residual_rows = int(r.residual_rows or 0)
result.residual_listings = int(r.residual_listings or 0)
result.residual_no_geom = int(r.residual_no_geom or 0)
result.residual_far = int(r.residual_far or 0)
result.residual_cross_fias = int(r.residual_cross_fias or 0)
result.residual_mergeable = int(r.residual_mergeable or 0)
logger.info(
"merge_duplicate_houses: residual rows=%d listings=%d "
"(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
result.residual_rows,
result.residual_listings,
result.residual_no_geom,
result.residual_far,
result.residual_cross_fias,
result.residual_mergeable,
)
if result.residual_mergeable:
logger.warning(
"merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
"left work on the table (expected 0)",
result.residual_mergeable,
)
def merge_duplicate_houses(
db: Session,
*,
dry_run: bool = False,
run_id: int | None = None,
initiator: str = "manual",
) -> dict[str, int]:
"""Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper.
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
@ -673,16 +1039,45 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
empty mapping in each pass every statement is a 0-row no-op.
Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a
merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line
and stored on every journal row of this call.
Returns the counter dict (DedupMergeResult.to_counters()).
"""
start = time.monotonic()
result = DedupMergeResult(dry_run=dry_run)
batch_id = str(uuid.uuid4())
try:
# Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled.
_assert_all_fk_children_handled(db)
# Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity).
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result)
_run_merge_pass(
db,
build_sql=_BUILD_MAPPING_SQL_FIAS,
pass_label="fias",
geo_guard=False,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
result=result,
)
# Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard.
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result)
_run_merge_pass(
db,
build_sql=_BUILD_MAPPING_SQL,
pass_label="canon",
geo_guard=True,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
result=result,
)
# Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
# a run that merged nothing is exactly the run whose remainder needs a number.
_measure_residual(db, result)
if result.losers_deleted == 0:
# Clean table — both passes empty. Roll back (we only opened temp tables).
@ -709,12 +1104,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
db.commit()
logger.info(
"merge_duplicate_houses: COMMITTED clusters=%d losers=%d "
"listings_repointed=%d children_deleted=%d children_repointed=%d",
"listings_repointed=%d children_deleted=%d children_repointed=%d "
"batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))",
result.clusters_merged,
result.losers_deleted,
result.listings_repointed,
result.children_deleted,
result.children_repointed,
batch_id,
batch_id,
)
except Exception:
logger.exception("merge_duplicate_houses: FAILED — rolling back")
@ -754,7 +1152,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
counters = merge_duplicate_houses(db, dry_run=dry_run)
counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule")
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s",

View file

@ -30,6 +30,7 @@ from dataclasses import dataclass, field
from typing import Literal
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.house_type_normalizer import normalize_house_type
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5
# ── house_type normalisation ─────────────────────────────────────────────────
# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type),
# значения — вокабуляр Avito IMV.
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
"panel": "panel",
"brick": "brick",
"monolith": "monolithic",
"monolithic": "monolithic",
"monolith_brick": "monolithic", # Avito API не принимает гибриды
"block": "block",
"wood": "wood",
}
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
def _map_house_type(raw: str | None) -> str:
if not raw:
return _HOUSE_TYPE_DEFAULT
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT)
def _map_house_type(raw: str | None) -> str | None:
"""Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём.
Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он
знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock /
aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное
('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру
тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому
'monolithbrick' в словарь не попадал.
#2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он
есть, но не распознан. Панель почти самый дешёвый класс (медиана по нашим же
2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k
/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем
без типа + 75 домов с camelCase-типом (56 из них monolithBrick, 11.7% к
monolithic) уехали как панель. Теперь неизвестный тип None дом помечается
и запрос к площадке не тратится (см. _process_one_house).
"""
canon = normalize_house_type(raw)
if canon is None:
return None
return _HOUSE_TYPE_TO_IMV.get(canon)
def _map_renovation_type(repair_state: str | None) -> str:
"""listings.repair_state → renovation_type вокабуляра Avito IMV.
Переиспользуем _IMV_REPAIR_MAP эстиматора единственный источник правды для
этого соответствия (needs_repairrequired / standardcosmetic / goodeuro /
excellentdesigner). Импорт ленивый: estimator тянет scraper_adapters, а тот
импортирует этот модуль (circular см. блок импортов выше).
#2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как
«косметический ремонт», хотя мода по объявлениям этих же домов совсем другая
(standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 косметика
лишь 36%).
Неизвестный ремонт (498 домов из 2685 ни одного объявления с repair_state)
ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic'
(=standard) это одновременно МОДА и МЕДИАННАЯ категория популяции
(standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка.
У типа дома такой догадки нет: 'panel' почти край шкалы, а не её середина.
Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при
неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not
None`), а домовой дефолтит иначе теряем ещё ~32% домов очереди поверх тех,
что уже отсекает неизвестный тип дома.
"""
from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок
mapped = _IMV_REPAIR_MAP.get(repair_state)
if mapped is None and repair_state:
# Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте
# (сырых repair-значений в listings больше, чем нормализованных). Паритет
# с house_type_normalizer, который такой случай уже логирует.
logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state)
return mapped or "cosmetic"
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────
@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
AS integer) AS floor,
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
AS integer) AS total_floors,
mode() WITHIN GROUP (ORDER BY house_type) AS house_type
mode() WITHIN GROUP (ORDER BY house_type) AS house_type,
mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state
FROM listings
WHERE house_id_fk = :hid
AND rooms IS NOT NULL
@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
"floor": floor,
"floor_at_home": floor_at_home,
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
"renovation_type": "cosmetic",
"renovation_type": _map_renovation_type(row["repair_state"]),
# has_balcony/has_loggia остаются константами намеренно (#2674): покрытие
# listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки
# противоречат друг другу (по has_balcony «есть» у 62%, а по
# balcony_loggia самый частый случай — loggia 5650 против balcony 2794).
# Мода по одному-двум объявлениям на таком покрытии — шум, а не данные.
"has_balcony": True,
"has_loggia": False,
}
@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
)
# 3. Suggestions
# #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/
# total_floors — колонки есть с миграции 064, но писатель их не заполнял
# (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в
# suggestions.items[].imageLink, метрики квартиры парсятся из title.
for sug in result.suggestions:
db.execute(
text("""
INSERT INTO house_suggestions (
house_id, ext_item_id, title, address, price_rub,
area_m2, rooms, floor, total_floors,
exposure_days, publish_date,
item_link, metro_name, metro_distance,
item_link, image_link, metro_name, metro_distance,
has_good_price_badge, raw_payload, fetched_at
) VALUES (
:hid, :ext, :title, :addr, :price,
CAST(:area AS numeric), :rooms, :floor, :total_floors,
:exp, :pdate,
:link, :mname, :mdist,
:link, :img, :mname, :mdist,
:gpb, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
title = EXCLUDED.title,
price_rub = EXCLUDED.price_rub,
area_m2 = EXCLUDED.area_m2,
rooms = EXCLUDED.rooms,
floor = EXCLUDED.floor,
total_floors = EXCLUDED.total_floors,
exposure_days = EXCLUDED.exposure_days,
publish_date = EXCLUDED.publish_date,
item_link = EXCLUDED.item_link,
image_link = EXCLUDED.image_link,
metro_name = EXCLUDED.metro_name,
metro_distance = EXCLUDED.metro_distance,
has_good_price_badge = EXCLUDED.has_good_price_badge,
@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
"title": sug.title,
"addr": sug.address,
"price": sug.price_rub,
"area": sug.area_m2,
"rooms": sug.rooms,
"floor": sug.floor,
"total_floors": sug.total_floors,
"exp": sug.exposure_days,
"pdate": sug.publish_date,
"link": sug.item_url,
"img": sug.image_link,
"mname": sug.metro_name,
"mdist": sug.metro_distance,
"gpb": sug.has_good_price_badge,
@ -500,8 +577,33 @@ async def backfill_house_imv(
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше
# (поведение байт-в-байт идентично доспринтовому).
#
# #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не
# опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар
# берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1
# (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire
# («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от
# fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток
# каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем
# 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока
# avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через
# ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже:
# запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch
# камуфокса на каждый дом (server.py::_ensure_browser).
if settings.avito_imv_use_browser_fetcher:
async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf:
# lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig.
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
_cfg = RealScraperConfig()
async with BrowserFetcher(
source="avito",
endpoint=settings.browser_http_endpoint,
proxy_provider=RealProxyProvider(),
use_pool=_cfg.use_proxy_pool_browser,
# #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути —
# фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease).
environment=_cfg.environment,
) as _bf:
await _run_loop(_bf)
else:
await _run_loop(None)
@ -652,6 +754,14 @@ async def _process_one_house(
_mark_status(db, hid, "no_params", "no listings with rooms+area")
return "no_params"
# #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо
# вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и
# занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же
# путь, что и при отсутствии комнат/площади.
if params["house_type"] is None:
_mark_status(db, hid, "no_params", "unknown house_type")
return "no_params"
address = house.get("address") or house.get("full_address")
if not address:
_mark_status(db, hid, "no_address", "house.address is NULL")

View file

@ -13,9 +13,13 @@
POI-score его не улавливал (POI ranking цена).
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
location_index_pct = (медиана /м² сопоставимых активных листингов в радиусе точки
location_index_pct = (медиана /м² сопоставимых листингов в радиусе точки
медиана /м² по всему ЕКБ) / медиана по ЕКБ * 100
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
(scraped_at в пределах LISTINGS_FRESH_DAYS `is_active` на проде не равно «живо») И
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5)
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
@ -45,6 +49,11 @@ from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
from app.services.estimator import LISTINGS_FRESH_DAYS
logger = logging.getLogger(__name__)
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
#
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
#
# было (только is_active) 30 222 строк 172 984 ₽/м²
# + только свежесть 11 453 строк 163 363 ₽/м²
# + только сегмент 11 219 строк 147 632 ₽/м²
# стало (оба) 7 715 строк 147 368 ₽/м²
#
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из 14.8%
# сдвига городской медианы он даёт 14.7 п.п. Девелоперский прайс новостроек
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
# пула 15 265 — новостройки, и гард выносит их заодно.
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
# 0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
# назад. Пул 31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
#
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
#
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
#
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта.
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND price_per_m2 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
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 lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
AND price_per_m2 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
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 lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
db.execute(
_MEDIAN_PPM2_CITYWIDE_SQL,
{
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH,

View file

@ -75,7 +75,10 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
"ceiling_height": ["cian_detail", "yandex_valuation"],
# #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses`
# никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не
# могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ
# (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже.
}
# ---------------------------------------------------------------------------
@ -88,7 +91,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
"area_m2": ["cian_serp", "avito_detail"],
"living_area_m2": ["cian_serp"],
"kitchen_area_m2": ["cian_serp", "avito_detail"],
"ceiling_height": ["cian_detail"],
# #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую
# эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника,
# а не только cian_detail.
"ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"],
"floor": ["cian_serp", "avito_detail"],
"total_floors": ["cian_serp", "avito_detail"],
"year_built": ["cian_serp"],

View file

@ -1,11 +1,40 @@
"""House cross-source matching — tiered algorithm.
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
New (confidence 1.0): INSERT new canonical house.
`match_or_create_house` (путь скрейпинга, создаёт дома):
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
New (confidence 1.0): INSERT new canonical house.
`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно
имеет Tier 0.5 fias_exact у него ЕСТЬ источник ФИАС (DaData /suggest в
`estimator.resolve_target_house`), см. docstring функции.
ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources):
fingerprint 58.97% · new 22.65% · geo_proximity 18.36% ·
**cadastr_exact 0 · fias_exact 0** верхние тиры не срабатывали НИ РАЗУ.
Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id`
нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в
`app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих
(`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`)
передать его было НЕКОМУ. Регресс сторожит
tests/test_matching_tier_reachability_2674.py.
Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot.
building_cadastral_number` адаптер сюда), но площадки кадастр не отдают:
`listings.cadastral_number` 0/93 408, а все 28 504 заполненных
`listings.building_cadastral_number` на 100% из локального гео-зеркала ЕГРН
(`tasks/cadastral_geo_match.py`, KNN 50 м), т.е. появляются ПОСЛЕ матчинга и
обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр
не инъективен 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы
over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка
начнёт отдавать настоящий кадастр но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» по дому на
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
"""
@ -46,9 +75,8 @@ def match_or_create_house(
*,
year_built: int | None = None,
building_cadastral_number: str | None = None,
cadastral_number: str | None = None,
house_fias_id: str | None = None,
source_url: str | None = None,
city: str | None = None,
) -> tuple[int | None, float, str]:
"""Match existing house or create new canonical record.
@ -58,21 +86,30 @@ def match_or_create_house(
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
house row. Closes finding #1 from 2026-05-24 audit.
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
Args:
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact additive and
optional, existing callers are unaffected.
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=)`,
он же `listings.city`) НЕЗАВИСИМОЕ от строки адреса наблюдение города
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
ключ Tier-2a вырождается в один нормализованный адрес и становится
глобально уникальным. Опционален: вызывающие без sweep-контекста
(estimate-путь, ad-hoc скрипты) передают None поведение прежнее.
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает
здесь признак приходит другим каналом (какой город запрашивала развёртка).
Returns:
(house_id, confidence [0.0, 1.0], method {
'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint',
'cadastr_exact', 'source_exact', 'fingerprint',
'geo_proximity', 'new', 'no_house_number'
})
house_id is None only for the 'no_house_number' terminal case below.
Method values:
'cadastr_exact' matched by cadastral number (confidence 1.0)
'fias_exact' matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95)
'source_exact' already in house_sources for this source+ext_id (confidence 1.0)
'fingerprint' matched by address fingerprint (confidence 0.9)
'geo_proximity' matched by geo within 30 m (confidence 0.7)
@ -87,7 +124,16 @@ def match_or_create_house(
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
"""
cad = building_cadastral_number or cadastral_number
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
# где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из
# сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка,
# ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]`
# (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы
# никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ
# проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без
# кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы
# два дома — то самое дробление, против которого Tier 0 и заведён.
cad = building_cadastral_number
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
fp = address_fingerprint(address, lat, lon)
@ -136,34 +182,10 @@ def match_or_create_house(
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
return (house_id, 1.0, "cadastr_exact")
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
if house_fias_id:
row = (
db.execute(
text(
"SELECT id FROM houses "
"WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) "
"ORDER BY id ASC LIMIT 1"
),
{"fias": house_fias_id},
)
.mappings()
.first()
)
if row:
house_id = int(row["id"])
_upsert_house_source(
db,
house_id=house_id,
ext_source=ext_source,
ext_id=ext_id,
method="fias_exact",
confidence=0.95,
)
_insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id)
return (house_id, 0.95, "fias_exact")
# Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
# Tier 1: source+ext_id already registered in house_sources
row = (
@ -203,18 +225,40 @@ def match_or_create_house(
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
# scope, only relevant once the oblast sweep is enabled.
#
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
# residual needs sweep-context and is out of this fix's scope.
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
# (resolved city None) run Tier-2a/2b exactly as before.
#
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
# The sweep already knows which city it was crawling and stamps it on the listing row
# (save_listings → listings.city); that observation just never reached this guard, so
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
# The address token still wins when present (it describes THIS card; the sweep city
# describes the batch).
_resolved_city = None
if lat is None and lon is None:
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
# «сшитыми» городами по метке listings.city):
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
# города → страж срабатывает;
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
# пункт; это ограничение данных, а не недоделка стража.
if _skip_oblast_alias:
logger.info(
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
_resolved_city,
city,
norm_addr,
ext_source,
)

View file

@ -246,7 +246,23 @@ def _upsert_listing_source(
source_url: str | None,
source_data: dict | None,
) -> None:
"""Insert or refresh listing_sources row for this source+ext_id."""
"""Insert or refresh listing_sources row for this source+ext_id.
Отметки времени statement_timestamp(), НЕ NOW() (#2731). Этот upsert вызывается
ПОСТРОЧНО из save_listings (hook `_link_listing_to_house`), а транзакция batch'а
коммитится один раз в конце, поэтому NOW() (== transaction_timestamp) давал одну
метку на весь вызов: прод-замер 2026-08-06 219 строк на 1 метку в 11:00,
235/1 в 10:00, 297/1 в 09:00, и так каждый час.
Чинится вместе с listings.scraped_at/last_seen_at, а не отдельно: сегодня
listings.last_seen_at = listing_sources.last_seen_at у 100% пар (2407 из 2407 за
сутки) именно потому, что обе колонки берут одну транзакционную метку. Почини
только одну вторая осталась бы замороженной на старте batch'а, и расхождение
выросло бы с миллисекунд (честная разница двух записей) до длительности прогона.
Все три колонки пишутся ОДНИМ statement'ом, поэтому statement_timestamp() даёт им
одинаковое значение; clock_timestamp() развёл бы их на микросекунды.
"""
raw = json.dumps(source_data) if source_data is not None else None
db.execute(
text("""
@ -257,15 +273,15 @@ def _upsert_listing_source(
price_rub, area_m2, floor, rooms_count, raw_payload
) VALUES (
CAST(:lid AS bigint), :s, :e,
CAST(:c AS real), :m, NOW(), NOW(),
:url, NOW(),
CAST(:c AS real), :m, statement_timestamp(), statement_timestamp(),
:url, statement_timestamp(),
CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc,
CAST(:raw AS jsonb)
)
ON CONFLICT (ext_source, ext_id) DO UPDATE SET
confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence),
last_seen_at = NOW(),
last_scraped_at = NOW(),
last_seen_at = statement_timestamp(),
last_scraped_at = statement_timestamp(),
price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub),
area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2),
floor = COALESCE(EXCLUDED.floor, listing_sources.floor),

View file

@ -0,0 +1,20 @@
"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C).
Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты
(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/
конструктора. Причина параллельный PR-B вводит эти поля в `config.py`,
а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина)
делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо)
§3/§9 для полной схемы разбивки.
- `token.py` подпись `Token` запросов + проверка подписи нотификаций
(никогда не кидает исключение на враждебном входе).
- `notification.py` строгий типизированный разбор тела нотификации ПОСЛЕ
проверки подписи (`parse_notification`) сырой `dict` дальше не уходит.
- `receipt.py` сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги.
- `tbank_client.py` httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`.
Docs: https://developer.tbank.ru/eacq/intro
"""
from __future__ import annotations

View file

@ -0,0 +1,116 @@
"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи.
Вызывать `parse_notification()` только когда `token.verify_notification_token(...)`
уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно он только
превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы
сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись
в БД).
ПОЧЕМУ строгий разбор самостоятельный слой защиты, а не формальность:
алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между
ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут
"перекладываться" между лексикографически соседними ключами так, что итоговая
строка для SHA-256 не меняется, хотя значения полей меняются. Проверено
живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"`
даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла"
из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически
и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует,
что банк прислал именно ту сумму, которую записал у себя платёжный сервис
это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом
Token, который реально пришлёт банк на проде).
КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) единственная реальная защита
от описанного выше перекладывания: `amount_kopecks` из `parse_notification()`
ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись,
созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же
нотификации) ДО того, как нотификация будет принята как валидное событие.
Если сумма из нотификации не совпадает с суммой в БД это либо подделанная
нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и
подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс то и
другое должно быть отказом, а не «примерно похоже, примем».
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
class NotificationParseError(ValueError):
"""Поле нотификации не соответствует ожидаемому типу — отказ, не догадка."""
@dataclass(frozen=True, slots=True)
class TBankNotification:
"""Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи.
`amount_kopecks` здесь то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а
НЕ подтверждённый источник истины сам по себе. См. docstring модуля
сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне.
"""
success: bool
status: str
order_id: str
payment_id: str
terminal_key: str
amount_kopecks: int
def parse_notification(payload: dict[str, Any]) -> TBankNotification:
"""Строгий разбор `payload` в `TBankNotification`.
Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password)
is True` эта функция подпись не проверяет.
Правила (без исключений, без «примерно разберём»):
- `Success` только настоящий `bool` (не строка `"true"`, не `1`);
- `Amount` только `int`; `bool` подкласс `int` в Python
(`isinstance(True, int) is True`), поэтому проверяется и отсекается
ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы
как сумма;
- `Status`, `OrderId`, `PaymentId`, `TerminalKey` только непустой `str`.
Любое несоответствие `NotificationParseError` с указанием поля,
ожидаемого типа и того, что реально пришло.
"""
if not isinstance(payload, dict):
raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}")
return TBankNotification(
success=_require_strict_bool(payload, "Success"),
status=_require_nonempty_str(payload, "Status"),
order_id=_require_nonempty_str(payload, "OrderId"),
payment_id=_require_nonempty_str(payload, "PaymentId"),
terminal_key=_require_nonempty_str(payload, "TerminalKey"),
amount_kopecks=_require_strict_int(payload, "Amount"),
)
def _require_strict_bool(payload: dict[str, Any], key: str) -> bool:
value = payload.get(key)
if not isinstance(value, bool):
raise NotificationParseError(
f"{key} должен быть bool, получено {type(value).__name__}={value!r}"
)
return value
def _require_strict_int(payload: dict[str, Any], key: str) -> int:
value = payload.get(key)
# bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int),
# иначе True/False молча прошли бы как Amount=1/Amount=0.
if isinstance(value, bool) or not isinstance(value, int):
raise NotificationParseError(
f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}"
)
return value
def _require_nonempty_str(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value:
raise NotificationParseError(
f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}"
)
return value

View file

@ -0,0 +1,143 @@
"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга.
Продукт продаёт УСЛУГУ (не товар) везде фиксированы `PaymentObject="service"`
и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу,
без предоплат/кредита/частичных расчётов).
Схема (`Receipt` в `Init`, ФФД 1.05) источник, снят живым запросом
2026-08-06: https://developer.tbank.ru/eacq/api/init
- `Email` ИЛИ `Phone` обязательно хотя бы одно (перекрёстный required).
- `Taxation` обязателен: `osn|usn_income|usn_income_outcome|esn|patent`.
- `Items[].Name` <=128 символов, обязателен.
- `Items[].Price`/`Quantity`/`Amount` числа, В КОПЕЙКАХ; `Amount` это
произведение `Price * Quantity` (дословно из API-reference).
- `Items[].Tax` ставка НДС. Актуальный список 2026 (Init API reference):
`none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`.
`vat20`/`vat120` В СПИСКЕ НЕТ сняты, не использовать (см. recon §6/§11
в `mera-tbank-acquiring-recon.md`, корень репо).
ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые
вложенные `dict`/`list` из подписи) это архитектурно гарантировано самой
функцией `token.sign`, а не соглашением здесь.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
TaxRate = Literal[
"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"
]
Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"]
_ALLOWED_TAX_RATES: frozenset[str] = frozenset(
{"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"}
)
_ALLOWED_TAXATION: frozenset[str] = frozenset(
{"osn", "usn_income", "usn_income_outcome", "esn", "patent"}
)
_MAX_ITEM_NAME_LEN = 128
_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference)
class ReceiptBuildError(ValueError):
"""Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка."""
@dataclass(frozen=True, slots=True)
class ReceiptItem:
"""Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки."""
name: str
price_kopecks: int
quantity: int = 1
tax: TaxRate = "none"
@property
def amount_kopecks(self) -> int:
"""Items[].Amount = Price * Quantity (дословно из API reference)."""
return self.price_kopecks * self.quantity
def to_payload(self) -> dict[str, Any]:
if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN:
raise ReceiptBuildError(
f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, "
f"получено {len(self.name)}"
)
if self.price_kopecks <= 0:
raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)")
if self.quantity <= 0:
raise ReceiptBuildError("Items[].Quantity должен быть > 0")
if self.tax not in _ALLOWED_TAX_RATES:
raise ReceiptBuildError(
f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка "
f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются"
)
return {
"Name": self.name,
"Price": self.price_kopecks,
"Quantity": self.quantity,
"Amount": self.amount_kopecks,
"Tax": self.tax,
"PaymentMethod": "full_payment",
"PaymentObject": "service",
}
def build_receipt(
*,
items: list[ReceiptItem],
taxation: Taxation,
email: str | None = None,
phone: str | None = None,
) -> dict[str, Any]:
"""Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции).
Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется
`Receipt` строится независимо от `Init`-payload заказа. Сверка на
вызывающей стороне (`service.py`, следующий PR) через
`receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест
`test_receipt_total_matches_order_amount_invariant` в
`tests/test_payments_receipt.py`, который проверяет именно эту сверку.
"""
if not items:
raise ReceiptBuildError("Receipt.Items не может быть пустым")
if len(items) > _MAX_ITEMS:
raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций")
if taxation not in _ALLOWED_TAXATION:
raise ReceiptBuildError(
f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})"
)
email_norm = (email or "").strip() or None
phone_norm = (phone or "").strip() or None
if not email_norm and not phone_norm:
raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)")
payload: dict[str, Any] = {
"Taxation": taxation,
"Items": [item.to_payload() for item in items],
}
if email_norm:
payload["Email"] = email_norm
if phone_norm:
payload["Phone"] = phone_norm
return payload
def receipt_total_kopecks(receipt: dict[str, Any]) -> int:
"""Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`."""
items = receipt.get("Items")
if not isinstance(items, list):
return 0
total = 0
for item in items:
if isinstance(item, dict):
amount = item.get("Amount")
if isinstance(amount, int):
total += amount
return total

View file

@ -0,0 +1,279 @@
"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel).
Стиль и обработка ошибок по образцу
`app.services.tgbot.client.TelegramClient`: единственные нужные методы,
не тянем отдельный SDK ради пяти HTTP-вызовов.
Модуль НЕ импортирует `app.core.config` все параметры (`terminal_key`,
`password`, `base_url`) передаются в конструктор явно аргументами.
Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`):
параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D.
Docs: https://developer.tbank.ru/eacq/api
Ретраи:
- Сетевые ошибки (timeout/connect) и HTTP 5xx экспоненциальный backoff,
capped на `_MAX_BACKOFF_S`.
- Любая 4xx НЕ ретраится (запрос некорректен / права не те повтор
транспортного вызова не поможет), сразу `TBankApiError`.
- Бизнес-отказ (HTTP 200, но `Success: false` в теле) тоже НЕ
ретраится: это содержательный ответ банка, а не сбой транспорта.
БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода
около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между
попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на
нотификацию окно порядка 10 с этот бюджет в 74 с в него заведомо не
укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч.
`get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации)
ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН обработчик обязан
только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с
банком (`GetState`/`CheckOrder`) асинхронно, вне HTTP-цикла ответа банку.
БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*`
логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details`
из ответа банка.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from app.services.payments.token import sign
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S = 15.0
_MAX_BACKOFF_S = 30.0
_DEFAULT_MAX_RETRIES = 3
DEFAULT_BASE_URL = "https://securepay.tinkoff.ru"
class TBankApiError(Exception):
"""T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`)."""
def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None:
self.method = method
self.error_code = error_code
self.message = message
self.details = details
text = f"T-Bank API {method} failed: [{error_code}] {message}"
if details:
text += f"{details}"
super().__init__(text)
def _error_from_body(response: httpx.Response) -> tuple[str, str, str]:
"""Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус."""
try:
data = response.json()
except ValueError:
return str(response.status_code), (response.text or "")[:200], ""
if not isinstance(data, dict):
return str(response.status_code), str(data)[:200], ""
error_code = str(data.get("ErrorCode", response.status_code))
message = str(data.get("Message", ""))
details = str(data.get("Details", ""))
return error_code, message, details
class TBankClient:
"""Клиент Т-Банк эквайринга на `httpx.AsyncClient`.
Каждый вызов отдельное короткоживущее соединение (без общего
connection-pool между вызовами; частота вызовов в checkout-потоке
низкая, держать долгоживущий клиент не нужно тот же паттерн, что
`TelegramClient`).
"""
def __init__(
self,
*,
terminal_key: str,
password: str,
base_url: str = DEFAULT_BASE_URL,
timeout: float = _DEFAULT_TIMEOUT_S,
) -> None:
self._terminal_key = terminal_key
self._password = password
self._base = f"{base_url.rstrip('/')}/v2"
self._timeout = timeout
def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит."""
body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload}
body["Token"] = sign(body, self._password)
return body
async def _request(
self,
method: str,
payload: dict[str, Any],
*,
max_retries: int = _DEFAULT_MAX_RETRIES,
) -> dict[str, Any]:
"""POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу."""
body = self._signed_payload(payload)
url = f"{self._base}/{method}"
attempt = 0
while True:
attempt += 1
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(url, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
if attempt > max_retries:
logger.error(
"tbank client: %s — network error после %d попыток: %s",
method,
attempt,
exc,
)
raise TBankApiError(method, "network_error", str(exc)) from exc
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tbank client: %s — network error (попытка %d/%d) — retry через %.0fs",
method,
attempt,
max_retries,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code >= 500:
if attempt > max_retries:
error_code, message, details = _error_from_body(response)
logger.error(
"tbank client: %s — HTTP %d после %d попыток, сдаёмся",
method,
response.status_code,
attempt,
)
raise TBankApiError(method, error_code, message, details)
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
logger.warning(
"tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
method,
response.status_code,
attempt,
max_retries,
backoff,
)
await asyncio.sleep(backoff)
continue
if response.status_code >= 400:
# 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет.
error_code, message, details = _error_from_body(response)
raise TBankApiError(method, error_code, message, details)
try:
data = response.json()
except ValueError as exc:
raise TBankApiError(method, "invalid_json", str(exc)) from exc
if not isinstance(data, dict):
raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект")
if not data.get("Success"):
error_code = str(data.get("ErrorCode", response.status_code))
message = str(data.get("Message", ""))
details = str(data.get("Details", ""))
raise TBankApiError(method, error_code, message, details)
return data
async def init_payment(
self,
*,
order_id: str,
amount_kopecks: int,
description: str = "",
notification_url: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
receipt: dict[str, Any] | None = None,
pay_type: str | None = None,
data: dict[str, str] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`.
КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной):
после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"`
таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же
`order_id` неизвестно, дошёл ли исходный запрос до банка до обрыва
соединения. Слепой повтор может создать ВТОРОЙ холд на тот же
`OrderId`. Разбираться нужно через `check_order(order_id=...)`
он возвращает уже существующие платежи по заказу и только по его
результату решать, нужен ли новый `Init`.
"""
payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks}
if description:
payload["Description"] = description
if notification_url:
payload["NotificationURL"] = notification_url
if success_url:
payload["SuccessURL"] = success_url
if fail_url:
payload["FailURL"] = fail_url
if receipt:
payload["Receipt"] = receipt
if pay_type:
payload["PayType"] = pay_type
if data:
payload["DATA"] = data
return await self._request("Init", payload)
async def get_state(self, *, payment_id: str) -> dict[str, Any]:
"""`POST /v2/GetState` — статус платежа по `PaymentId`."""
return await self._request("GetState", {"PaymentId": payment_id})
async def check_order(self, *, order_id: str) -> dict[str, Any]:
"""`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции)."""
return await self._request("CheckOrder", {"OrderId": order_id})
async def confirm(
self,
*,
payment_id: str,
amount_kopecks: int | None = None,
receipt: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`).
КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после
`TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов
`cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог
прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд
`Confirm` состоялся на стороне банка, а ответ до клиента не дошёл.
В этом случае `cancel()` вернёт клиенту уже захваченные деньги.
Правильная последовательность: сначала `get_state(payment_id=...)`,
и только по актуальному статусу решать, нужен ли `cancel()`.
"""
payload: dict[str, Any] = {"PaymentId": payment_id}
if amount_kopecks is not None:
payload["Amount"] = amount_kopecks
if receipt:
payload["Receipt"] = receipt
return await self._request("Confirm", payload)
async def cancel(
self,
*,
payment_id: str,
amount_kopecks: int | None = None,
receipt: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан)."""
payload: dict[str, Any] = {"PaymentId": payment_id}
if amount_kopecks is not None:
payload["Amount"] = amount_kopecks
if receipt:
payload["Receipt"] = receipt
return await self._request("Cancel", payload)

View file

@ -0,0 +1,141 @@
"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций.
Docs (проверено живым запросом к doc-порталу, 2026-08-06):
- https://developer.tbank.ru/eacq/intro/developer/token формирование Token.
- https://developer.tbank.ru/eacq/intro/developer/notification
(раздел «Проверить токен уведомлений») тот же алгоритм для входящих
нотификаций.
Алгоритм (идентичен для исходящего запроса и для проверки нотификации):
1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем
`None`, исключаем значения-`dict`/`list` (документация формулирует это
как «кроме параметра Token и вложенных объектов (Data, Receipt)»
здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые
вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items`
или `Shops`, отсекаются одинаково, потому что все они не примитивы).
2. `bool` `"true"`/`"false"` (нижний регистр); `int` строка через `str()`;
`str` как есть. `float` НЕ поддерживается падаем явной ошибкой (формат
дробных чисел не задокументирован Т-Банком, см. `_stringify_value`).
3. Добавляем пару `Password: <пароль_терминала>`.
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
doc-портала оба подтверждены живым запросом, не выдуманы.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from typing import Any
logger = logging.getLogger(__name__)
_EXCLUDED_KEYS = frozenset({"Token"})
class TokenSigningError(ValueError):
"""Поле не может быть однозначно сериализовано в подписываемую строку."""
def _stringify_value(value: bool | int | float | str) -> str:
"""Приводит плоское значение к строке по правилам Т-Банка.
`bool` проверяем ДО `int`: в Python `bool` подкласс `int`
(`isinstance(True, int) is True`), поэтому порядок веток важен
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
`float` НЕ поддерживается падаем с `TokenSigningError`, а не
угадываем формат. Документация Т-Банка не описывает сериализацию
дробных чисел в подписи; прежняя реализация (`format(value, "f")` +
rstrip нулей) была неподтверждённой догадкой, и она расходится с тем,
что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы
как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"`
Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`)
в этом API всегда целые копейки (`int`); для денежного пути правильнее
явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
raise TokenSigningError(
f"float в подписываемых полях не поддерживается (получено {value!r}) — "
"формат дробных чисел не описан в документации Т-Банка, см. docstring "
"_stringify_value"
)
return str(value)
def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]:
"""Плоские поля payload, готовые к конкатенации: без Token/None/dict/list."""
result: dict[str, str] = {}
for key, value in payload.items():
if key in _EXCLUDED_KEYS or value is None:
continue
if isinstance(value, dict | list):
continue
result[key] = _stringify_value(value)
return result
def sign(payload: dict[str, Any], password: str) -> str:
"""Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...).
`payload` тело запроса ДО добавления поля `Token` (поле `Password`
самому передавать не нужно функция добавляет его сама и удаляет
участие любых вложенных объектов автоматически).
"""
fields = _flatten_signable_fields(payload)
fields["Password"] = password
raw = "".join(fields[key] for key in sorted(fields))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def verify_notification_token(payload: dict[str, Any], password: str) -> bool:
"""Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`.
`payload` полное тело нотификации, включая присланный `Token` (сам
алгоритм сборки исключает ключ `Token` из подписи см. `_EXCLUDED_KEYS`).
Возвращает `False`, если в payload нет строкового непустого `Token`
(нечего сравнивать) вызывающая сторона обязана трактовать это как
отказ в обработке нотификации, а не как «пропустить проверку».
НИКОГДА не поднимает исключение на любом враждебном/мусорном входе
(не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри
`sign()`) возвращает `False`. Это обязательное свойство для публичной
ручки нотификации (PR-D): необработанное исключение здесь это
неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный
от `"OK"`, банк трактует как временный сбой и ретраит уведомление
почасово в течение суток. Конкретные причины двух проверок ниже:
- `payload` не `dict` (например список) `.get()` кинул бы
`AttributeError` без явной проверки типа;
- `Token` с не-ASCII символами `hmac.compare_digest` на строках
требует ASCII и иначе кидает `TypeError` (документированное
ограничение stdlib, не баг).
"""
if not isinstance(payload, dict):
return False
received_token = payload.get("Token")
if not isinstance(received_token, str) or not received_token or not received_token.isascii():
return False
try:
expected_token = sign(payload, password)
except Exception:
# Мусорное поле где-то ещё в payload (например float — см.
# `_stringify_value`) не должно валить проверку подписи в исключение.
logger.warning(
"verify_notification_token: sign() упал на входящем payload — трактуем как отказ",
exc_info=True,
)
return False
try:
return hmac.compare_digest(expected_token, received_token)
except TypeError:
return False

View file

@ -21,8 +21,10 @@ from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.scheduler import (
Handler,
reschedule_after_minutes,
@ -39,39 +41,97 @@ logger = logging.getLogger(__name__)
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки
# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу
# видно, встал ли сбор из-за кук или из-за конкурентного прогона.
SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing"
SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired"
SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid"
def _alert_cian_cookies(source: str, detail: str) -> None:
"""Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning).
В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR)
(scheduler_main.py) ERROR-запись сама становится событием, а прежний
`capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в
недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в
docker-логах и в строке scrape_runs, которая переживает редеплой.
"""
logger.error(
"scheduler: %s пропущен — %s. Перезалейте куки Циана через админку "
"(до этого backfill истории стоит)",
source,
detail,
)
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
Cookies отсутствуют/протухли defer next_run_at на следующее окно и skip
(иначе get_due_schedules переотбирает schedule каждые 60с и verify_session
долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run.
"""
import sentry_sdk
Cookies отсутствуют/протухли пишем строку прогона status='skipped' с причиной,
двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
schedule каждые 60с и verify_session долбит Cian круглосуточно).
from app.services.cian_session import load_session, verify_session
#2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в
docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения
last_run_at снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт
стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках
исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки
ещё валидны (COOKIE_EXPIRY_WARN_DAYS) обновление кук ручное, ему нужен запас.
"""
from app.services.cian_session import (
COOKIE_EXPIRY_WARN_DAYS,
load_session,
session_expires_at,
verify_session,
)
source: str = schedule_row["source"]
now = datetime.now(tz=UTC)
cookies = load_session(db)
if cookies is None:
logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB")
expires_at = session_expires_at(db)
if expires_at is None:
reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД"
elif expires_at <= now:
reason = SKIP_CIAN_COOKIES_EXPIRED
detail = (
f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
)
else:
reason = SKIP_CIAN_COOKIES_INVALID
detail = "куки Циана помечены невалидными (last_invalid_at)"
_alert_cian_cookies(source, detail)
kit_runs.mark_skipped(db, source=source, reason=reason, details=detail)
kit_defer_next_run_at(db, schedule_row)
return False
state = await verify_session(cookies)
if state is None:
logger.warning(
"scheduler: cian_history_backfill — cookies expired or invalid, skipping run"
)
try:
sentry_sdk.capture_message(
"cian_history_backfill skipped: Cian session cookies expired — "
"please re-upload via admin UI",
level="warning",
)
except Exception:
pass # sentry_sdk not initialised in dev
# verify вернул именно None (401 / isAuthenticated=false) — куки числятся
# валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
# недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
detail = "Циан не принимает куки (разлогин)"
_alert_cian_cookies(source, detail)
kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
kit_defer_next_run_at(db, schedule_row)
return False
# Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора.
# valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких
# аккаунтах свежайшая-любая может быть чужой протухшей строкой).
expires_at = session_expires_at(db, valid_only=True)
if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS):
logger.error(
"scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, "
"иначе %s встанет молча",
expires_at.date().isoformat(),
(expires_at - now).total_seconds() / 86400,
source,
)
return True
@ -156,12 +216,26 @@ async def _job_deactivate_stale(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.core.config import settings as _settings
from app.tasks.deactivate_stale_avito import deactivate_stale_listings
from app.tasks.deactivate_stale_avito import (
DEFAULT_MIN_CONFIRMATIONS,
DEFAULT_REVISIT_FLOOR_QUANTILE,
deactivate_stale_listings,
)
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")
staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
# источнику пороги приходят из default_params (миграция 219).
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
# Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
# незасеянное расписание не должно снимать объявления по порогу ниже собственного
# хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
revisit_floor_quantile: float = params.get(
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
@ -173,6 +247,8 @@ async def _job_deactivate_stale(
ttl_days=ttl_days,
segments=segments,
staleness_column=staleness_column,
min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile,
),
)
@ -282,6 +358,19 @@ async def _job_domclick_detail_backfill(
await run_domclick_detail_backfill(db, run_id=run_id, params=params)
# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ─────
async def _job_house_coords_from_listings(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_house_coords_from_listings(db, run_id=run_id, params=params),
)
# ── geoportal_coords_backfill — sync local exact match в executor (#1967) ─────
async def _job_geoportal_coords_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -341,17 +430,38 @@ async def _job_house_imv_backfill(
only_status=only_status,
heartbeat=_heartbeat,
)
ctx.runs.mark_done(
db,
run_id,
{
"checked": result.checked,
"saved": result.saved,
"skipped": result.skipped,
"errors": result.errors,
"duration_sec": int(result.duration_sec),
},
)
counters = {
"checked": result.checked,
"saved": result.saved,
"skipped": result.skipped,
"errors": result.errors,
"duration_sec": int(result.duration_sec),
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
# ложной тревогой про капчу.
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked,
"new_count": result.saved,
}
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».
# На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50.
# Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная
# пустота, она по-прежнему done.
if result.saved == 0 and result.errors > 0:
ctx.runs.mark_failed(
db,
run_id,
f"saved=0 при errors={result.errors} (checked={result.checked})",
counters,
)
else:
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
try:
@ -360,6 +470,75 @@ async def _job_house_imv_backfill(
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ─────────
# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py)
# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник
# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех).
# Это не мёртвый код, а оборванная проводка: нечему было его вызвать.
async def _job_domrf_kapremont_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings.
Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не
дублируем логику). Lifecycle не свой mark_done/mark_failed здесь, как у
_job_yandex_newbuilding_sweep.
Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные
колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых
прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же
промах, что чинили у house_imv_backfill).
"""
from app.services.domrf_kapremont_loader import (
backfill_houses_from_domrf,
load_domrf_kapremont,
propagate_listings_year_from_houses,
)
def _run() -> dict[str, int]:
load_counts = load_domrf_kapremont(db)
db.commit()
houses_counts = backfill_houses_from_domrf(db)
listings_counts = propagate_listings_year_from_houses(db)
db.commit()
return {
"kr11_rows": load_counts["kr11_rows"],
"upserted": load_counts["upserted"],
"houses_updated": houses_counts["houses_updated"],
"listings_updated": listings_counts["listings_updated"],
# см. докстринг: выделенные колонки прогона + гейт «нулевой прогон».
"total_seen": load_counts["kr11_rows"],
"new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data
batch_size = params.get("batch_size")
max_batches = params.get("max_batches")
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: purge_expired_trade_in_data(
db, run_id, batch_size=batch_size, max_batches=max_batches
),
)
# ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ─────
async def _job_house_dedup_merge(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -394,8 +573,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь
их даёт build_registry(_default_kit_handlers). Здесь 19 именованных + 1 wildcard
их даёт build_registry(_default_kit_handlers). Здесь именованные + 1 wildcard
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
`ctx` принят для симметрии контракта; сами Handler-job'ы получают ctx во время
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
@ -427,6 +607,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"geoportal_coords_backfill": Handler(
_job_geoportal_coords_backfill, "geoportal_coords_backfill"
),
"house_coords_from_listings": Handler(
_job_house_coords_from_listings, "house_coords_from_listings"
),
"geocode_missing_listings": Handler(
_job_geocode_missing_listings, "geocode_missing_listings"
),
@ -439,6 +622,10 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
"purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),
"proxy_healthcheck": Handler(
_job_proxy_healthcheck,
"proxy_healthcheck",

View file

@ -0,0 +1,301 @@
"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825).
ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы
`cian_proxy_url`/`yandex_proxy_url`, все три прямая проекция ENV `SCRAPER_PROXY_URL`,
см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые
строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` /
`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2)
аккуратно вела учёт банов по паре «узел × источник» но эти прямые сессии её никогда
не читали и месяц ходили через узел, забаненный и Avito, и Cian.
ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже
реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/
reap_stale_leases) она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии
(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь короткие
одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
и без мутаций.
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
(тот же карантинный порог, что у acquire), нет активной строки в
scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов меньший consecutive_fails,
при равенстве более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку:
это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер.
FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный
`SCRAPER_PROXY_URL` РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
1. Пул ПУСТ (в `scrape_proxies` вообще нет строк dev/staging без БД-пула, легитимный
сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН пула для этого
окружения попросту не существует, идти больше некуда. `logger.warning`.
2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для
этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL`
ЗАПРЕЩЁН: инцидент 2026-08-10 это ровно случай (2), узел статичной переменной был
тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему
к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError`
вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на
env в обход учёта банов.
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials url
несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback
warning с текстом «пуст» (сценарий 1); при exhaustion error с разбивкой
banned_for_source/unhealthy_or_disabled (сценарий 2) тексты НАМЕРЕННО разные, чтобы
их нельзя было спутать в логах/алертах.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from urllib.parse import urlsplit
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings as _settings
from app.core.db import SessionLocal as _SessionLocal
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
logger = logging.getLogger(__name__)
__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"]
class ProxyPoolExhaustedError(RuntimeError):
"""Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source`
(все забанены именно для этого источника / нездоровы / выключены).
Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run
с понятным логом), а НЕ уйти в обход пула через статичный
`settings.scraper_proxy_url` тот самый узел мог быть источником текущего
инцидента (см. module docstring, сценарий 2).
"""
def __init__(
self,
source: str,
*,
pool_total: int,
banned_for_source: int,
unhealthy_or_disabled: int,
) -> None:
self.source = source
self.pool_total = pool_total
self.banned_for_source = banned_for_source
self.unhealthy_or_disabled = unhealthy_or_disabled
super().__init__(
f"proxy pool exhausted for source={source!r}: pool_total={pool_total} "
f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}"
)
@dataclass(frozen=True)
class _Candidate:
id: int
url: str
label: str | None
def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
"""host:port для логов — НИКОГДА не credentials из url (userinfo)."""
if label:
return label
try:
parts = urlsplit(url)
host = parts.hostname or "?"
return f"{host}:{parts.port}" if parts.port else host
except ValueError:
return f"proxy#{proxy_id}"
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел."""
row = (
db.execute(
text(
"""
SELECT id, url, label
FROM scrape_proxies
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = scrape_proxies.id
AND b.source = CAST(:source AS text)
AND b.banned_until > now()
)
ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id
LIMIT 1
"""
),
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
)
.mappings()
.fetchone()
)
# Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно,
# оставляем управление транзакцией вызывающему коду (тот же db может быть в
# середине более широкой операции).
if row is None:
return None
return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"])
@dataclass(frozen=True)
class _ExhaustionDiag:
"""Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2
в докстринге модуля). Используется исключительно для diagnostic-лога/исключения."""
pool_total: int
banned_for_source: int
unhealthy_or_disabled: int
def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag:
"""Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул
пусто не платим за агрегаты в happy-path (кандидат найден с первого запроса)."""
row = (
db.execute(
text(
"""
SELECT
count(*) AS pool_total,
count(*) FILTER (
WHERE NOT enabled
OR consecutive_fails >= CAST(:max_fails AS integer)
) AS unhealthy_or_disabled,
count(*) FILTER (
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
AND EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = scrape_proxies.id
AND b.source = CAST(:source AS text)
AND b.banned_until > now()
)
) AS banned_for_source
FROM scrape_proxies
"""
),
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
)
.mappings()
.fetchone()
)
if row is None: # pragma: no cover — count(*) всегда возвращает строку
return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0)
return _ExhaustionDiag(
pool_total=int(row["pool_total"]),
banned_for_source=int(row["banned_for_source"]),
unhealthy_or_disabled=int(row["unhealthy_or_disabled"]),
)
def resolve_proxy_url(db: Session, source: str) -> str | None:
"""Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары
«узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов
"кандидата нет":
- пул пуст (0 строк в `scrape_proxies`) fallback на
`settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий;
- пул не пуст, все отсеяны (баны/health/disabled) `ProxyPoolExhaustedError`
(`logger.error`), fail-closed БЕЗ прохода через статичный env.
БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД)
трактуем КАК пустой пул (не можем подтвердить exhaustion небезопасно поднимать
error/исключение по неполным данным), `logger.warning` + explicit (не silent
failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и
все отсеяны; здесь мы вообще ничего не знаем о пуле.
"""
try:
candidate = _pick_candidate(db, source)
except Exception:
logger.warning(
"proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), "
"лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)",
source,
exc_info=True,
)
try:
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
# "current transaction is aborted") — если db переживёт этот вызов
# (долгоживущая caller-сессия, напр. avito_detail_backfill/
# yandex_detail_backfill), последующие запросы на ней иначе все падали
# бы с той же ошибкой, маскируя реальную причину.
db.rollback()
except Exception:
logger.warning(
"proxy_egress: source=%s -- rollback после сбоя пула тоже не удался",
source,
exc_info=True,
)
return _settings.scraper_proxy_url
if candidate is not None:
logger.info(
"proxy_egress: source=%s -> pool proxy id=%d (%s)",
source,
candidate.id,
_safe_label(candidate.id, candidate.label, candidate.url),
)
return candidate.url
diag = _diagnose_no_candidate(db, source)
if diag.pool_total == 0:
# Сценарий 1: пул для этого окружения попросту не сконфигурирован
# (dev/staging без БД-пула) — легитимный fallback.
fallback = _settings.scraper_proxy_url
if fallback:
logger.warning(
"proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), "
"окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL "
"(fallback)",
source,
)
else:
logger.warning(
"proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL "
"не задан, идём прямым подключением без прокси",
source,
)
return fallback
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
logger.error(
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
"не прошёл фильтр для этого источника (banned_for_source=%d, "
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "
"статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)",
source,
diag.pool_total,
diag.banned_for_source,
diag.unhealthy_or_disabled,
)
raise ProxyPoolExhaustedError(
source,
pool_total=diag.pool_total,
banned_for_source=diag.banned_for_source,
unhealthy_or_disabled=diag.unhealthy_or_disabled,
)
def resolve_proxy_url_sync(source: str) -> str | None:
"""Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для
вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`).
`ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed)
вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы).
"""
db = _SessionLocal()
try:
return resolve_proxy_url(db, source)
finally:
db.close()

View file

@ -77,6 +77,42 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
прогона.
Два тракта два диагноза (#2723):
- ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет
consecutive_fails / enabled / exit_ip. Такт каждый прогон healthcheck (30 мин).
- браузерная проба (`_run_browser_probe` сайдкар camoufox с ЭТИМ прокси
навигация) отвечает на «через узел работает браузерный тракт» и владеет
browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228).
Такт свой, редкий (BROWSER_PROBE_MINUTES) она стоит запуска camoufox.
Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе
дешёвая проба каждые 30 минут стирает вердикт дорогого тракта узел, мёртвый для
браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел
(он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686.
«Непригоден для браузера» это НЕ исключение из пула: acquire() лишь отдаёт такой
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
для всех узлов на один зашитый адрес robots.txt Авито. Прокси-узел не «жив/мёртв»
вообще: замер на проде 09.08.2026 узел id=1 отдаёт 200 на Авито и Яндексе и 500
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
достаться (browser_fetcher.PROBE_SOURCES affinity), по РАБОЧЕМУ хосту площадки
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
`bff-search-web.domclick.ru`, куда ходит сбор, 500).
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
сущности не заводим эта ровно про пару и её уже читает acquire): подтверждённый
отказ строка бана с reason=_PROBE_BAN_REASON, успех снятие СВОЕЙ строки.
Чужие строки (бан, распознанный боевым сбором) проба не трогает robots.txt
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
креста: хоть одна зелёная площадка ok; все красные транспортом провал узла.
Отказ одной площадки узел глобально не пятнает иначе мы бы своими руками
вернули то самое схлопывание диагнозов.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@ -90,9 +126,13 @@ import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings as _settings
logger = logging.getLogger(__name__)
__all__ = [
"BROWSER_PROBE_MINUTES",
"BROWSER_UNFIT_THRESHOLD",
"DISABLED_RECHECK_MINUTES",
"DISABLE_THRESHOLD",
"MAX_CONSECUTIVE_FAILS",
@ -105,7 +145,9 @@ __all__ = [
"acquire",
"clear_source_bans",
"mark_banned",
"mark_browser_health",
"mark_health",
"mark_source_probe",
"reap_stale_leases",
"release",
"run_proxy_healthcheck",
@ -155,6 +197,47 @@ SOURCE_BAN_PURGE_DAYS = 7
_HEALTH_PROBE_URL = "https://api.ipify.org"
_HEALTH_PROBE_TIMEOUT_S = 10.0
# ── браузерная проба узла (#2723) ────────────────────────────────────────────
# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026):
# - одна браузерная проба = 8.3с и один запуск camoufox;
# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час
# (≈1000 и ≈190 в сутки);
# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки.
# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки,
# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт
# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена,
# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно
# ловится в разы раньше, чем сейчас (не ловится вовсе).
BROWSER_PROBE_MINUTES = 360
# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в
# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция
# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте
# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck
# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале
# намеренно не обновляется (см. mark_browser_health).
BROWSER_UNFIT_THRESHOLD = 2
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
# - одна проба 918 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
# acquire снова выдаёт узел, не спросив.
#
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
_PROBE_BAN_REASON = "probe:browser"
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
@ -188,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600).
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity см.
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
domclick останется без прокси вообще хуже, чем голодание исходного источника,
которое фикс призван устранить. Кандидат участвует в fallback, только если его
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос)
т.е. выдача не обнулит доступность выделенной affinity целиком.
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
fallback заберёт его под чужой источник, «свой» останется без прокси вообще хуже,
чем голодание исходного источника, которое фикс призван устранить. Кандидат
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
enabled-узел (EXISTS-подзапрос) т.е. выдача не обнулит доступность выделенной
affinity целиком.
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до
рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а
Авито/Яндекс через него работают резервация держала узел за источником, которому
он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде
сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и
следующий выделенный узел должен получить её сразу, а не после повторного разбора.
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
@ -213,7 +303,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT id, url, kind, rotate_url
SELECT id, url, kind, rotate_url, browser_unfit_since
FROM scrape_proxies
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
@ -226,7 +316,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
AND b.source = :provider
AND b.banned_until > now()
)
ORDER BY last_ok_at NULLS LAST, id
-- browser_unfit последним (#2723): узел, живой для HTTP, но не для
-- браузера, из пула НЕ исключается только уходит в конец очереди.
ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -247,7 +339,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since
FROM scrape_proxies AS sp
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
@ -283,7 +375,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
)
)
ORDER BY sp.last_ok_at NULLS LAST, sp.id
-- см. ORDER BY основного запроса (#2723)
ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -323,6 +416,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
)
if row["browser_unfit_since"] is not None:
# Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит
# пригодных свободных не осталось. Голодание хуже работы через плохой узел
# (та же политика, что у защиты последнего узла в mark_banned), но молчать об
# этом нельзя: для браузерного источника это заведомо обречённый прогон.
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s "
"(жив для HTTP, браузерный тракт через него не работает). Выдан потому, "
"что пригодных свободных узлов нет — пул надо пополнять (#2638).",
proxy_id,
provider,
row["browser_unfit_since"],
)
return ProxyLease(
id=proxy_id,
url=str(row["url"]),
@ -480,9 +586,182 @@ def mark_health(
)
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
def mark_browser_health(
db: Session,
proxy_id: int,
ok: bool,
*,
fail_kind: str | None = None,
detail: str = "",
) -> str:
"""Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков.
ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от
«у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того
же. mark_health отвечает на «узел жив вообще» и владеет
consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает
браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/
browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное:
- успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы
(через consecutive_fails=0) узел, мёртвый для браузера, выходил из карантина
каждые 30 минут и снова забирал прогон;
- провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел:
он жив, просто не для этого тракта.
ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см.
scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и
"page" (площадка отдала пустое) узлу не принадлежат засчитывать их значило бы
пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть
повторить #2686 ещё раз и уже с последствиями для всего пула.
ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН
(streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом
провале поле остаётся старым следующий же прогон healthcheck (~30 мин) повторит
пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы
полный BROWSER_PROBE_MINUTES.
Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что
помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored"
(провал не принадлежит узлу).
"""
if ok:
row = (
db.execute(
text(
"""
UPDATE scrape_proxies AS sp
SET browser_fail_streak = 0,
browser_unfit_since = NULL,
browser_check_at = now(),
updated_at = now()
-- prev pre-image строки: RETURNING отдаёт УЖЕ обновлённые
-- значения (browser_unfit_since там всегда NULL), а нам нужно
-- знать, была ли это реанимация непригодного узла.
FROM (
SELECT id, browser_unfit_since
FROM scrape_proxies
WHERE id = CAST(:id AS bigint)
) AS prev
WHERE sp.id = prev.id
RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit
"""
),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
was_unfit = bool(row["was_unfit"]) if row is not None else False
logger.info(
"proxy_pool: browser probe OK id=%d (%s)%s",
proxy_id,
detail,
" — узел снова пригоден для браузера" if was_unfit else "",
)
return "refit" if was_unfit else "ok"
if fail_kind != "proxy":
logger.warning(
"proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу "
"(fail_kind=%s): %s — browser_fail_streak не трогаем",
proxy_id,
fail_kind,
detail,
)
return "ignored"
row = (
db.execute(
text(
"""
UPDATE scrape_proxies
SET browser_fail_streak = browser_fail_streak + 1,
browser_unfit_since = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
AND browser_unfit_since IS NULL
THEN now() ELSE browser_unfit_since
END,
browser_check_at = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
THEN now() ELSE browser_check_at
END,
updated_at = now()
WHERE id = CAST(:id AS bigint)
RETURNING browser_fail_streak, browser_unfit_since
"""
),
{"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
if row is None:
logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id)
return "ignored"
streak = int(row["browser_fail_streak"])
if streak >= BROWSER_UNFIT_THRESHOLD:
logger.warning(
"proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для "
"обычного HTTP, но браузерный тракт через него не работает: %s. Узел "
"ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() "
"теперь отдаёт его последним (#2723).",
proxy_id,
streak,
detail,
)
return "unfit"
logger.warning(
"proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не "
"достигнут — перепроверим на следующем прогоне): %s",
proxy_id,
streak,
BROWSER_UNFIT_THRESHOLD,
detail,
)
return "fail"
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары
принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего
узла) | "missing" (нет такого proxy_id).
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
clear_source_bans умеет фильтровать по ней (`only_reason`).
ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026:
пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через
ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр
«снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ:
присвоенная строка становится «своей», и следующая успешная проба снимает ею бан,
который поставил боевой сбор по настоящему отказу площадки. Плюс теряется
происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба
не смогла») разные факты с разными последствиями (ровно ловушка #2764), а ban_count
начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых
пары с 6 ч до 12 ч).
Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет),
ИЛИ она уже наша (та же метка обычная эскалация), ИЛИ мы боевой сбор (`live_reason`).
Иначе ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно»
только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен
УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит делать нечего.
АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее
(площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится
ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой и её же зелёный
robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально
и хуже. Цена перехвата ban_count наследуется (отдых чуть длиннее заслуженного);
обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об
эскалации боевых банов пары.
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
@ -541,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
(падает как обычно) caller решает, ловить или нет.
"""
# Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё
# (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ").
live_reason = f"banned:{source}"
effective_reason = reason or live_reason
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
db.execute(
@ -612,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
) AS integer)),
reason = CAST(:reason AS text),
updated_at = now()
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
-- свою же (обычная эскалация) или перебиваем боевым сбором он сильнее
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
WHERE scrape_proxy_source_bans.banned_until <= now()
OR scrape_proxy_source_bans.reason = CAST(:reason AS text)
OR CAST(:reason AS text) = CAST(:live_reason AS text)
RETURNING ban_count, banned_until
"""
),
{
"proxy_id": proxy_id,
"source": source,
"reason": f"banned:{source}",
"reason": effective_reason,
"live_reason": live_reason,
"base_hours": SOURCE_BAN_BASE_HOURS,
"max_hours": SOURCE_BAN_MAX_HOURS,
"max_fails": MAX_CONSECUTIVE_FAILS,
@ -638,15 +928,44 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
row["banned_until"],
row["ban_count"],
)
return
return "banned"
# 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита
# последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога
# (на решение уже не влияет), но диагноз должен называть то, что произошло.
holder = (
db.execute(
text(
"""
SELECT reason, banned_until
FROM scrape_proxy_source_bans
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND source = CAST(:source AS text)
AND banned_until > now()
"""
),
{"proxy_id": proxy_id, "source": source},
)
.mappings()
.fetchone()
)
if holder is not None and holder["reason"] != effective_reason:
logger.info(
"proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт "
"%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, "
"иначе проба присвоила бы чужой бан и потом сняла бы его как свой)",
proxy_id,
source,
holder["reason"],
holder["banned_until"],
effective_reason,
)
return "deferred"
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
current = (
db.execute(
text(
"SELECT enabled, disabled_reason FROM scrape_proxies "
"WHERE id = CAST(:id AS bigint)"
"SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)"
),
{"id": proxy_id},
)
@ -655,17 +974,25 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
)
if current is None:
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
else:
logger.warning(
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
"источнику (голодание хуже, чем работа через забаненный узел).",
proxy_id,
source,
)
return "missing"
logger.warning(
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
"источнику (голодание хуже, чем работа через забаненный узел).",
proxy_id,
source,
)
return "protected"
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
def clear_source_bans(
db: Session,
proxy_id: int,
*,
source: str | None = None,
reason: str,
only_reason: str | None = None,
) -> int:
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
@ -688,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
SOURCE_BAN_BASE_HOURS.
`reason` идёт только в лог (человекочитаемый повод «manual enable», «ip rotated»).
`only_reason` ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
целиком. None снимать всё, как и раньше.
"""
rows = db.execute(
text(
@ -695,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
DELETE FROM scrape_proxy_source_bans
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
RETURNING source
"""
),
{"proxy_id": proxy_id, "source": source},
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
).fetchall()
db.commit()
if rows:
@ -712,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
return len(rows)
def mark_source_probe(
db: Session,
proxy_id: int,
*,
source: str,
ok: bool,
fail_kind: str | None = None,
detail: str = "",
) -> str:
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
Пара то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
`acquire(source)` `scrape_proxy_source_bans`; новой сущности не заводим.
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
другая здесь судится ПАРА, а не узел):
- "sidecar" общая зависимость лежит, к паре отношения не имеет "ignored".
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
- "proxy" через этот узел до площадки не доходит транспорт
(NS_ERROR_PROXY_*, camoufox не поднялся) бан пары;
- "page" дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
(200 + «Ошибка Циан» вместо robots.txt) тоже бан пары.
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
ПАРЫ виноват ровно он: собирать через такой узел эту площадку нельзя.
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная
половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не
присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь
присвоенная строка уже «своя».
Защита последнего узла и эскалация срока целиком из `mark_banned`, здесь ничего
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
пишется (голодание хуже работы через плохой узел).
Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned`
("banned" | "deferred" | "protected" | "missing") счётчик пар считает баном
только реально записанный бан.
"""
if ok:
cleared = clear_source_bans(
db,
proxy_id,
source=source,
reason=f"browser probe OK for source={source} ({detail})",
only_reason=_PROBE_BAN_REASON,
)
return "cleared" if cleared else "ok"
if fail_kind not in ("proxy", "page"):
logger.warning(
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
"(fail_kind=%s): %s — вердикт не пишем",
proxy_id,
source,
fail_kind,
detail,
)
return "ignored"
logger.warning(
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
"пары, узел остаётся первосортным для остальных площадок (#2800)",
proxy_id,
source,
fail_kind,
detail,
)
return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
@ -775,6 +1183,102 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
return False, None, None, "other"
def _probe_sources_for(affinity: str) -> list[str]:
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
Спрашивать площадки, которым узел всё равно не выдадут, платить за диагностику,
которой никто не воспользуется.
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
свободных нет) такая пара останется без вердикта и решится как раньше, по факту
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
бы зря. Если fallback станет частым снять условие, цена известна: N_узлов × 4.
"""
from scraper_kit.browser_fetcher import PROBE_SOURCES
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
async def _run_pair_probes(
db: Session, proxy_id: int, url: str, kind: str, affinity: str
) -> tuple[str, dict[str, int]]:
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
Два уровня вердикта, и они не пересекаются:
- ПАРА (`mark_source_probe` scrape_proxy_source_bans) по каждой площадке
отдельно, это то, что читает `acquire(source)`;
- УЗЕЛ (`mark_browser_health` browser_fail_streak/browser_unfit_since) по
итогу ВСЕГО креста: хоть одна площадка ответила браузерный тракт через узел
работает (ok); все отказали транспортом отказ узла. Отказ ОДНОЙ площадки
узел глобально не пятнает иначе на месте вылеченного схлопывания диагнозов
появилось бы новое.
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
healthcheck ipify-часть уже отработала и её результат записан. Диагностика не
имеет права ломать то, что диагностирует.
"""
from scraper_kit.browser_fetcher import probe_proxy_via_browser
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
fail_kinds: list[str] = []
any_ok = False
last_detail = ""
for source in _probe_sources_for(affinity):
try:
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
if not ok and fail_kind == "proxy":
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
except Exception:
logger.warning(
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
proxy_id,
source,
exc_info=True,
)
continue
counters["pair_checked"] += 1
last_detail = detail
if ok:
any_ok = True
else:
fail_kinds.append(fail_kind or "other")
outcome = mark_source_probe(
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
)
if outcome == "banned":
counters["pair_banned"] += 1
elif outcome == "cleared":
counters["pair_cleared"] += 1
if counters["pair_checked"] == 0:
return "ignored", counters # крест не состоялся — узел не судим
if any_ok:
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
return (
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
counters,
)
def _mask(url: str) -> str:
"""Скрыть пароль в proxy-url для логов (scheme://user:***@host)."""
if "@" not in url or "//" not in url:
@ -808,9 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
В конце purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
(см. комментарий у самого DELETE: отложенность это и есть сброс ban_count).
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
САЙДКАР по одной навигации на каждую площадку, которую этот узел может
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла в отдельные
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
проба измеряла бы то, чем никто не пользуется.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed, revived, bans_purged}.
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
"""
reaped = reap_stale_leases(db)
@ -818,7 +1333,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
SELECT id, url, kind, enabled, disabled_reason
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
(browser_check_at IS NULL
OR browser_check_at < now() - make_interval(
mins => CAST(:browser_probe_minutes AS integer)
)) AS browser_probe_due
FROM scrape_proxies
WHERE enabled
OR last_check_at IS NULL
@ -828,7 +1347,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ORDER BY id
"""
),
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
{
"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES,
"browser_probe_minutes": BROWSER_PROBE_MINUTES,
},
)
.mappings()
.all()
@ -838,6 +1360,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ok_count = 0
failed = 0
revived = 0
browser_checked = 0
browser_ok = 0
browser_unfit = 0
browser_refit = 0
pair_checked = 0
pair_banned = 0
pair_cleared = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
@ -860,6 +1389,27 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
else:
failed += 1
# Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода
# мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая
# трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер
# ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
# не тот узел», из-за которого #2723 и появилась.
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
outcome, pair_counters = await _run_pair_probes(
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
)
browser_checked += 1
pair_checked += pair_counters["pair_checked"]
pair_banned += pair_counters["pair_banned"]
pair_cleared += pair_counters["pair_cleared"]
if outcome in ("ok", "refit"):
browser_ok += 1
if outcome == "refit":
browser_refit += 1
elif outcome == "unfit":
browser_unfit += 1
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
@ -882,13 +1432,21 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
"bans_purged=%d",
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
"pair_checked=%d pair_banned=%d pair_cleared=%d",
reaped,
checked,
ok_count,
failed,
revived,
purged,
browser_checked,
browser_ok,
browser_unfit,
browser_refit,
pair_checked,
pair_banned,
pair_cleared,
)
return {
"reaped": reaped,
@ -897,4 +1455,16 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"failed": failed,
"revived": revived,
"bans_purged": purged,
# Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed:
# схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль.
"browser_checked": browser_checked,
"browser_ok": browser_ok,
"browser_unfit": browser_unfit,
"browser_refit": browser_refit,
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
# «браузер через узел работает, но две площадки его не пускают».
"pair_checked": pair_checked,
"pair_banned": pair_banned,
"pair_cleared": pair_cleared,
}

View file

@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу
отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн,
что DEFAULT_UA в zhkh_flats_loader.py).
При сетевой ошибке / HTTP 5xx / таймауте логируем warning, возвращаем
available=False. Отсутствие папки/файла квартала available=False (штатный
случай до публикации квартала, до начала следующего месяца после конца квартала).
УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только
запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)):
- Портал ответил не-200 на листинг каталога/папки ERROR. Каталог единственная
опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча
врал целыми кварталами. Такое обязано быть событием.
- Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога
ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html).
Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали),
но цена асимметрична ложное срабатывание стоит одного события в месяц (такт
28 дней), пропуск стоит квартала молчания.
- Таймаут / сетевая ошибка WARNING, как раньше. Это транспортный блип раз в месяц
(такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный
deals_freshness_monitor ERROR-ом по max(deal_date).
- Папки/файла квартала нет INFO. Штатное состояние до публикации: квартал выходит
4 раза в год, поллер ходит 12 большинство прогонов ЗАКОННО пустые.
- Квартал вышел INFO + ЯВНОЕ событие capture_message(level="info"), см.
poll_rosreestr_new_quarter.
"""
from __future__ import annotations
@ -63,6 +77,7 @@ from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx
import sentry_sdk
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -243,7 +258,8 @@ async def check_new_quarter_available(
try:
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if index_resp.status_code != 200:
logger.warning(
# ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ".
logger.error(
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
index_resp.status_code,
_DATA_SETS_BASE_URL,
@ -265,7 +281,9 @@ async def check_new_quarter_available(
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
folder_resp = await client.get(folder_url, follow_redirects=True)
if folder_resp.status_code != 200:
logger.warning(
# ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается —
# это уже не «ещё не опубликовали», а поломка портала.
logger.error(
"rosreestr_poll: unexpected HTTP %d listing folder %s"
"treating Q%d %d as unavailable",
folder_resp.status_code,
@ -309,7 +327,13 @@ async def check_new_quarter_available(
)
return True
logger.info(
# ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip
# либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже
# врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ").
# Ветка может сработать и легитимно — файл появился в листинге раньше, чем
# докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание
# стоит максимум одного события в месяц, а пропуск стоит квартала молчания.
logger.error(
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable",
@ -338,12 +362,14 @@ async def check_new_quarter_available(
exc,
)
return False
except Exception as exc:
logger.warning(
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable",
except Exception:
# ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал
# парсер href'ов), а не сбой сети. Под WARNING он молча превращался в
# «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами.
logger.exception(
"rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable",
quarter,
year,
exc,
)
return False
@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
)
# #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт
# много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и
# теряется на редеплое. Отсюда явный capture_message вместо logger.error:
# событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой».
# Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в
# год, а повтор до самого импорта — это и есть нужное напоминание (#2670).
try:
sentry_sdk.capture_message(
f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year}"
"нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)",
level="info",
)
except Exception:
# Алертинг best-effort: падение отправки события не должно валить поллер.
logger.warning("rosreestr_poll: capture_message failed", exc_info=True)
return {
"available": available,

View file

@ -464,8 +464,16 @@ async def pull_sber_indices(
# path or its filter dims are stale (sber renames slugs / changes
# dimension codes). Surface it loudly with the slug + filter so the
# next breakage is diagnosable instead of a silent error-counter bump.
#
# #2674: "loudly" было сказано, но написано WARNING — тише, чем
# соседние 5xx/сетевые ветки, и НЕ событие в скрапере
# (LoggingIntegration event_level=ERROR). При этом 404 — самая
# ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а
# переименованный slug будет 404-ить каждый месяц, пока человек не
# перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк
# перестаёт обновляться.
if exc.response.status_code == 404:
logger.warning(
logger.error(
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s"
"dataset-path invalid? slug renamed or filter dims stale "
"(re-capture /dataset/v1/<slug> via dashboard route-interception)",

View file

@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска
Что осталось в этом модуле НЕ scheduler-loop, а функции с живыми потребителями вне
удалённой machinery:
- `compute_next_run_at` читается admin.py (операторский предпросмотр "next run").
С #2674 это re-export kit-версии, а не вторая копия формулы.
- `has_running_run` читается admin.py (UI-индикатор "уже бежит").
- `import_rosreestr_dkp` job-тело, вызываемое kit-handler'ом
product_handlers._job_rosreestr_dkp (lazy import).
@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
from __future__ import annotations
import logging
import random
from datetime import UTC, datetime, time, timedelta
from typing import Any
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через
# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py
# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра».
# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
from scraper_kit.orchestration.scheduler import compute_next_run_at
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod
__all__ = ["compute_next_run_at", "has_running_run"]
logger = logging.getLogger(__name__)
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
@ -43,54 +52,6 @@ logger = logging.getLogger(__name__)
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
def compute_next_run_at(
window_start_hour: int,
window_end_hour: int,
*,
now: datetime | None = None,
interval_days: int = 1,
) -> datetime:
"""Pick random datetime в window [start, end) UTC, через interval_days суток после now.
interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly.
Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа
1 прежнее ежедневное поведение.
Если window_end_hour <= window_start_hour cross-midnight window
(например 223 окно 22:00-23:59 ИЛИ 00:00-02:59).
"""
now = now or datetime.now(tz=UTC)
interval_days = max(1, int(interval_days))
# Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше).
target = (now + timedelta(days=interval_days)).date()
if window_end_hour > window_start_hour:
# Обычное окно (например 2..5 → 02:00-04:59)
start_seconds = window_start_hour * 3600
end_seconds = window_end_hour * 3600
rand_seconds = random.randint(start_seconds, end_seconds - 1)
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds)
else:
# Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
# Длина окна = (24-start) + end часов
total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600
rand_seconds = random.randint(0, total_seconds - 1)
# Если rand попадает в первую часть (start..24)
first_half = (24 - window_start_hour) * 3600
if rand_seconds < first_half:
# interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day.
# interval_days>1: всегда целевая дата (стаггер на N суток вперёд).
today_ok = interval_days == 1 and now.hour < window_start_hour
base_date = now.date() if today_ok else target
return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta(
seconds=window_start_hour * 3600 + rand_seconds
)
else:
# Во второй части (0..end), целевого дня
offset = rand_seconds - first_half
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset)
def has_running_run(db: Session, source: str) -> bool:
"""Есть ли активный run для source (status='running')."""
row = db.execute(
@ -115,7 +76,16 @@ async def _execute_cian_backfill(
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
before and after the batch call for zombie-detection visibility.
НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал
живости слался ровно один раз до батча, а `reap_zombies` меряет именно
heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод-
прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том
что у пятерых внутри окна писались строки offer_price_history (у прогона 304 до
5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346).
Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что
после ложной пометки собственный финал прогона становится no-op (отсюда нулевые
counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик
может запустить второй такой же батч поверх работающего.
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
NULL via LEFT JOIN so re-running after a partial completion naturally skips
@ -124,9 +94,33 @@ async def _execute_cian_backfill(
Params (from default_params jsonb):
batch_size: int rows per run (listings + houses counted separately).
"""
from app.tasks.cian_history_backfill import backfill_cian_history
from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
batch_size = int(params.get("batch_size", 100))
def _counters(result: CianBackfillResult) -> dict[str, int]:
return {
"listings_processed": result.listings_processed,
"listings_succeeded": result.listings_succeeded,
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
"houses_processed": result.houses_processed,
"houses_succeeded": result.houses_succeeded,
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
}
def _heartbeat(progress: CianBackfillResult) -> None:
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
ронять уже идущую работу прогон в худшем случае вернётся к прежнему
поведению (пометка 'zombie' на 6-м часу)."""
try:
runs_mod.update_heartbeat(db, run_id, _counters(progress))
except Exception:
logger.warning(
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
run_id,
exc_info=True,
)
counters: dict[str, int] = {
"listings_processed": 0,
"listings_succeeded": 0,
@ -145,17 +139,10 @@ async def _execute_cian_backfill(
do_listings=True,
do_houses=True,
do_valuations=False,
on_progress=_heartbeat,
)
counters = {
"listings_processed": result.listings_processed,
"listings_succeeded": result.listings_succeeded,
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
"houses_processed": result.houses_processed,
"houses_succeeded": result.houses_succeeded,
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
"duration_sec": int(result.duration_sec),
}
counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
runs_mod.mark_done(db, run_id, counters)
logger.info(
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",

View file

@ -2,13 +2,39 @@
Таблица scrape_runs создана в 015_scrape_runs.sql.
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL —
синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается,
сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются
ТОЙ ЖЕ сессией, что и работа задачи, и если рабочая транзакция всё это время
оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий,
сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал
время НАЧАЛА работы, а не её конца.
Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик
counters.duration_sec): у 153 заявленная длительность превышала собственное окно
finished_at started_at более чем в 1.5 раза, у 133 окно было меньше секунды при
работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс это не разброс,
а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей
транзакции. Крайний случай прогон 346 (cian_history_backfill): 18230 с работы,
окно 32 мс.
Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед
финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят
поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов),
newbuilding_enrich, cian_history_backfill нет.
Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине
отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших
прогонов (reap_zombies, порог 6 ч).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from collections.abc import Callable, Collection, Mapping
from functools import cache
from typing import Any
import sentry_sdk
@ -29,6 +55,232 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается
# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й
# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника
# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд,
# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на
# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от
# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма.
#
# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не
# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не
# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз
# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из
# них всегда будет либо спамом, либо молчанием.
STREAK_ALERT_MAX_PERIOD = 16
# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок
# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда»
# невозможно по построению, а не по счастливому совпадению чисел.
STREAK_SCAN_LIMIT = 500
def _streak_alert_due(streak: int, threshold: int) -> bool:
"""Достиг ли стрик очередной вехи напоминания (#2670).
True на threshold, 2×, 4×, 8× и дальше на каждом кратном
STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше
на N-й подряд неудаче; меняется только то, что он не последний.
"""
if streak < threshold or streak % threshold:
return False
mult = streak // threshold
if mult % STREAK_ALERT_MAX_PERIOD == 0:
return True
return mult & (mult - 1) == 0
def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
"""Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми)."""
streak = 0
for row in rows:
if not is_bad(row):
break
streak += 1
return streak
# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218)
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
# статус» и «явное поле причины»:
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
# статус, пришлось бы дублировать её в каждом потребителе.
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот
# класс оборванной проводки, из-за которого задача и появилась.
# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform':
# на проде оба прогона, помеченных после миграции 218, получили 'platform' по
# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не
# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а
# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения.
BAN_KIND_UNKNOWN = "unknown"
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
"""Первое присутствующее из ``keys`` как int; None — ни одного ключа нет."""
for key in keys:
val = counters.get(key)
if val is not None:
try:
return int(val)
except (TypeError, ValueError):
return None
return None
# #2703: ключи, которыми задача сообщает СВОЙ бизнес-результат. Список намеренно
# короткий и состоит из синонимов ОДНОЙ величины — «сколько объявлений отдала выдача»:
# total_seen — если задача посчитала сама;
# lots_fetched — все city/newbuilding-sweep'ы (21 источник, 455 прогонов на проде);
# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше
# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов
# реально дали ноль.
# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650
# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь
# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched
# …), а у refresh_search_matview counters пусты буквально ({} во всех 55 строках) и у
# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ
# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их
# словарь, а честно признаёт, что мерить нечем — см. _run_result_count.
_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched")
def _run_result_count(counters: Mapping[str, Any] | None) -> int | None:
"""Бизнес-результат прогона; **None = прогон его не сообщил** (≠ ноль).
Ровно это различие и было потеряно: сторож читал колонку ``total_seen``, у
которой DEFAULT 0, поэтому «не измерено» и «измерено, ноль» выглядели одинаково.
"""
return _pick_int(counters or {}, *_RESULT_COUNTER_KEYS)
@cache
def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> None:
"""Один раз на процесс: у источника нет ключа, по которому сторож судит (#2703).
Не алерт алертить не о чем, судить не о чем тоже. Это делает слепую зону
ВИДИМОЙ: раньше её признаком был вечно молчащий сторож, выглядящий настроенным.
"""
logger.warning(
"zero-result watchdog неприменим к source=%s: counters не содержат ни одного "
"результатного ключа %s (есть: %s) — прогоны этого источника больше не считаются "
"нулевыми по умолчанию (#2703)",
source,
_RESULT_COUNTER_KEYS,
", ".join(keys) or "<пусто>",
)
def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
"""Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
Возвращает текст причины (для error) либо None, если прогон таким не является.
Третий исход, у которого не было терминального статуса. Развёртка различает:
1. «площадка отбила» попытки разбора были, структура не извлеклась ни разу
`mark_banned` в самих sweep'ах (#2642, cian/yandex);
2. «площадка честно отдала пустоту» валидный ответ, ноль предложений
`done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
3. «мы не дошли» якорь упал по таймауту или исключению ДО того, как
что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
и статуса ему никто не выдал прогон уходил в `done`.
Признак собственная бухгалтерия прогона, а не список известных антибот-маркеров:
`errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
по умолчанию).
Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала
`errors_count < anchors_total`, статус остаётся 'done' (частичный сбор сбор).
Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
(yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 каждый ровно 240 с,
таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
yandex_city_sweep_pervouralsk 1 от 09.08 155 мс, исключение до первого запроса).
НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
пустоты (errors_count=0) они остаются 'done'.
"""
anchors = _pick_int(counters, "anchors_total")
errors = _pick_int(counters, "errors_count")
if not anchors or anchors <= 0 or errors is None or errors < anchors:
return None
if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
return None
return (
f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
)
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
_PHASE_MIN_ATTEMPTS = 3
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
случай, когда не сделано НИЧЕГО; этот когда целое направление работы отказало на
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
detail_failed=50, errors_count=0, status=done` каждая detail-страница отдавала
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
Признак собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список это
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) поэтому
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
42 прогона (1.3%) 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52
это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
"""
for key in sorted(counters):
if not key.endswith("_attempted"):
continue
phase = key[: -len("_attempted")]
attempted = _pick_int(counters, key)
failed = _pick_int(counters, f"{phase}_failed")
if attempted is None or failed is None:
continue
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
return (
f"phase-honest-status: фаза '{phase}' отказала полностью — "
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
f"'failed' без диагноза (#2700)"
)
return None
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@ -40,41 +292,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
Приоритет ключей:
- total_seen 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
- new_count 'new_count' (если уже есть) иначе 'lots_inserted'
- total_seen _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
- new_count 'new_count' (если уже есть) иначе 'lots_inserted'
Возвращает (total_seen, new_count); None для ключа, которого нет в counters
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
"""
def _pick(*keys: str) -> int | None:
for key in keys:
val = counters.get(key)
if val is not None:
try:
return int(val)
except (TypeError, ValueError):
return None
return None
return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
завершённых запусков для данного source имеют статус 'failed' или 'banned'.
"""Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд
(статусы 'failed'/'banned') у данного source.
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу т.е. запрос
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.
Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см.
_streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не
повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде
это дало avito_full_load 31 неудача подряд, 34 дня без единого успешного
прогона, один алерт за всё время.
Стрик прерывается любым завершением, кроме failed/banned, по данным прода это
достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47
завершённых прогонах), поэтому лестница не вырождается в постоянный алерт.
Best-effort: весь блок обёрнут в try/except сбой запроса или неинициализированный
Sentry НЕ должен нарушать вызывающий mark_* путь.
"""
if sentry_sdk is None:
return
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
try:
# Берём последние N+1 завершённых (non-running) запусков по source.
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
# Завершённые (non-running) прогоны источника, самые свежие первыми.
rows = db.execute(
text(
"""
@ -85,31 +333,21 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
LIMIT :limit
"""
),
{"source": source, "limit": n + 1},
{"source": source, "limit": STREAK_SCAN_LIMIT},
).fetchall()
if len(rows) < n:
# Ещё не набралось N завершённых запусков вообще — алерт не нужен.
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
capped = streak >= STREAK_SCAN_LIMIT
if not capped and not _streak_alert_due(streak, n):
return
# Первые N должны быть все failed/banned.
first_n = rows[:n]
if not all(r.status in ("failed", "banned") for r in first_n):
return
# (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
# должны были отправить алерт раньше и не стоит дублировать.
if len(rows) > n and rows[n].status in ("failed", "banned"):
return
# Стрик ровно достиг порога — отправляем алерт.
sentry_sdk.capture_message(
f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
f"Scraper source '{source}' has {streak} consecutive failed/banned runs — "
"manual intervention may be required (expired cookies / ban / broken parser).",
level="error",
)
logger.error(
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak
)
except Exception:
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
@ -117,15 +355,23 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
"""Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
завершённых 'done' запусков для source имеют total_seen=0 (#2625).
завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625).
Отличается от _alert_if_consecutive_failures: статус здесь формально 'done'
(errors_count=0) деградация невидима существующему failed/banned алерту.
Причина обычно капча/пустая выдача источника, у которого нет (или не сработал)
детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py).
Anti-spam: тот же N-й-стрик паттерн, что у _alert_if_consecutive_failures
алерт срабатывает ровно когда стрик достигает порога, не на каждом запуске сверх.
Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures
(#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина».
#2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может
прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53
источников не заполняет ничто значит у них он читал 0 ВСЕГДА, в том числе у
полностью успешного прогона, стрик не прерывался никогда, и после первого
события сторож замолкал навсегда, продолжая выглядеть настроенным. Теперь
признак берётся из counters, а «не измерено» (None) стрик ПРЕРЫВАЕТ ложный
вечный стрик стал невозможен по построению, а слепая зона логируется явно.
Best-effort: весь блок обёрнут в try/except сбой запроса или неинициализированный
Sentry НЕ должен нарушать вызывающий mark_done путь.
@ -134,35 +380,47 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
try:
# Те же non-running статусы, что у _alert_if_consecutive_failures — стрик
# 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done-
# с-результатом/cancelled), не только успешным сбором.
# с-результатом/cancelled/прогон без результатной метрики), не только успешным
# сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней
# «не измерено» неотличимо от «ноль» (#2703).
rows = db.execute(
text(
"""
SELECT status, total_seen FROM scrape_runs
SELECT status, counters FROM scrape_runs
WHERE source = :source
AND status IN ('failed', 'banned', 'done', 'cancelled')
ORDER BY finished_at DESC NULLS LAST
LIMIT :limit
"""
),
{"source": source, "limit": n + 1},
{"source": source, "limit": STREAK_SCAN_LIMIT},
).fetchall()
if len(rows) < n:
if not rows:
return
def _is_zero_done(r: Any) -> bool:
return r.status == "done" and (r.total_seen or 0) == 0
"""Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ.
first_n = rows[:n]
if not all(_is_zero_done(r) for r in first_n):
Так недостижимое условие прерывания невозможно по построению: источник,
чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не
запирает анти-спам «один раз на стрик» в «один раз навсегда».
"""
return r.status == "done" and _run_result_count(r.counters) == 0
if _run_result_count(rows[0].counters) is None:
# Свежайший завершённый прогон не сообщил результата — судить нечем.
# Логируем (один раз на источник за процесс) вместо молчаливого нуля.
_warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {})))
return
if len(rows) > n and _is_zero_done(rows[n]):
streak = _leading_streak(rows, _is_zero_done)
capped = streak >= STREAK_SCAN_LIMIT
if not capped and not _streak_alert_due(streak, n):
return
sentry_sdk.capture_message(
f"Scraper source '{source}' has {n} consecutive 'done' runs with zero "
f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero "
"lots fetched — captcha/layout-change likely undetected "
"(manual check recommended).",
level="error",
@ -170,7 +428,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
logger.error(
"sentry alert sent: source=%s has %d consecutive zero-result 'done' runs",
source,
n,
streak,
)
except Exception:
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
@ -199,16 +457,23 @@ def _alert_on_run_id(
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
"""INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
started_at пишется СВОЕЙ транзакцией (db.commit() ниже) откат рабочей
транзакции задачи его уже не достаёт (#2702).
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / );
отдельной колонки run_type больше нет она 3244 прогона подряд молчала
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
Returns run_id (bigint).
"""
row = db.execute(
text(
"""
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
VALUES (
:source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp()
)
RETURNING id
"""
),
@ -220,7 +485,7 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
"""UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки.
"""UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
@ -231,7 +496,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
text(
"""
UPDATE scrape_runs
SET heartbeat_at = NOW(),
SET heartbeat_at = clock_timestamp(),
counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -248,6 +513,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
db.commit()
# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле.
# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы
# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а
# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том
# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27).
# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех
# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import
# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не
# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста
# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась.
_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"})
_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load")
def honors_cancel(source: str) -> bool:
"""True, если джоб этого source опрашивает отмену и реально остановится."""
return source in _CANCEL_HONORING_EXACT or any(
key in source for key in _CANCEL_HONORING_SUBSTRINGS
)
def is_cancelled(db: Session, run_id: int) -> bool:
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
row = db.execute(
@ -258,17 +544,38 @@ def is_cancelled(db: Session, run_id: int) -> bool:
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
"""Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count.
"""Финализация run: status='done', finished_at + counters + total_seen/new_count.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
в выделенные колонки иначе admin/observability показывает 0 (audit #1926).
#2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
каждый якорь и собрано ноль см. _sweep_run_did_nothing. Проверка стоит здесь, а
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
страж, который надо не забыть позвать, это тот же дефект оборванной проводки,
из-за которого задача и появилась.
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
ничего», этот про «одно направление работы мертво, а суммарный сбор это прячет».
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
logger.error("%s run_id=%d", did_nothing, run_id)
mark_failed(db, run_id, did_nothing, counters)
return
phase_dead = _phase_totally_failed(counters)
if phase_dead is not None:
logger.error("%s run_id=%d", phase_dead, run_id)
mark_failed(db, run_id, phase_dead, counters)
return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
SET status = 'done',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -307,7 +614,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
text(
"""
UPDATE scrape_runs
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
SET status = 'failed',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
error = :error, counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -330,11 +638,30 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
_alert_on_run_id(db, run_id)
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
def mark_banned(
db: Session,
run_id: int,
error: str,
counters: dict[str, int],
*,
ban_kind: str = BAN_KIND_UNKNOWN,
) -> None:
"""Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
багом, и важно СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
теряет). Cooldown 2-4 часа.
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha);
- BAN_KIND_INFRA упала НАША инфраструктура (браузерный сайдкар/прокси);
- BAN_KIND_UNKNOWN (дефолт) причина не установлена.
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
разводить нечего, ничего и не знает а не «знает, что виновата площадка».
Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом.
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
он мог оставить сессию в error state rollback сбрасывает состояние.
@ -348,8 +675,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
text(
"""
UPDATE scrape_runs
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
SET status = 'banned',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
error = :error, counters = CAST(:counters AS jsonb),
ban_kind = :ban_kind,
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running'
@ -360,6 +689,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
"run_id": run_id,
"error": error[:1000],
"counters": json.dumps(counters),
"ban_kind": ban_kind,
"total_seen": total_seen,
"new_count": new_count,
},
@ -371,13 +701,117 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
_alert_on_run_id(db, run_id)
def mark_backfill_finished(
db: Session,
run_id: int,
counters: dict[str, int],
*,
source: str,
aborted_by_blocks: bool = False,
fail_hint: str | None = None,
ban_kinds: Collection[str] = (),
) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
Все три detail-backfill'а (avito/yandex/domclick) финализировались ОДНИМ
mark_done: прогон, который сделал N попыток и не обогатил НИ ОДНОГО объявления,
отчитывался успехом. На проде (2026-08-06) это 78 прогонов из 158
avito 23/76 (в т.ч. 5 прогонов по 1500-1600 попыток с нулём обогащений),
yandex 31/52 (все attempted=5 failed=5), domclick 24/30 (494 попытки 0).
Существующие алерты этот класс не ловили: _alert_if_consecutive_failures
считает только failed/banned, а _alert_if_consecutive_zero_results смотрит
total_seen, которого в counters backfill'ов нет вовсе (всегда 0 → стрик не
прерывается никогда анти-спам молчит после первого раза).
Правила (порядок важен), по образцу #2657 для domclick_city_sweep:
- попыток не было (attempted=0) 'done', честная пустота: кандидатов нет;
- есть блоки источника И (прогон оборван брейкером ИЛИ ноль результата)
'banned': external constraint, не наш баг (и триггер ротации IP #2611);
- ноль результата без блоков 'failed': это наша поломка (парсер/сеть/БД);
- иначе (обогатили хоть что-то) 'done', в т.ч. частичный прогон.
`gone` (404 у avito) считается результатом наравне с `enriched`: прогон,
который подтвердил снятие объявлений, работу сделал.
`fail_hint` самая частая причина отказа этого прогона (задача считает её сама,
см. avito_detail_backfill._failure_signature). Дописывается в текст статуса,
потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
контейнера на этот вопрос отвечать не могут они исчезают при пересоздании
контейнера, то есть на первом же деплое после ночного прогона.
`ban_kinds` диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
пишется; разошлись (или их типы ничего не доказывают) 'unknown'. Смешанный
прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
попалась последней какая из них оборвала прогон, мы не знаем (#2764).
"""
attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0)
blocked = int(counters.get("blocked") or 0)
produced = enriched + int(counters.get("gone") or 0)
hint = f"; причина: {fail_hint}" if fail_hint else ""
if attempted == 0:
mark_done(db, run_id, counters)
return
if blocked and (aborted_by_blocks or produced == 0):
reason = (
f"backfill-honest-status: {source} остановлен блоками источника — "
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
kinds = set(ban_kinds)
mark_banned(
db,
run_id,
reason,
counters,
ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN,
)
return
if produced == 0:
reason = (
f"backfill-honest-status: {source} без результата — 0 обогащено из "
f"{attempted} попыток (failed={counters.get('failed', 0)}, "
f"blocked={blocked}){hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_failed(db, run_id, reason, counters)
return
mark_done(db, run_id, counters)
def mark_cancelled(db: Session, run_id: int) -> bool:
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
"""Set status='cancelled' если currently 'running'. Returns True если cancelled.
Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel:
там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard.
Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому
гейт стоит здесь на общем узле всех пяти.
"""
row = db.execute(
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
{"run_id": run_id},
).fetchone()
if row is not None and not honors_cancel(str(row.source)):
logger.warning(
"mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — "
"задача продолжила бы работать под статусом 'cancelled'",
run_id,
row.source,
)
return False
result = db.execute(
text(
"""
UPDATE scrape_runs
SET status = 'cancelled', finished_at = NOW()
SET status = 'cancelled', finished_at = clock_timestamp()
WHERE id = :run_id AND status = 'running'
RETURNING id
"""
@ -445,8 +879,8 @@ def list_all(
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
total_seen, new_count, started_at, finished_at,
SELECT id AS run_id, source, status, params, counters,
ban_kind, total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs
WHERE {where_sql}
@ -460,3 +894,22 @@ def list_all(
.all()
)
return total, [dict(r) for r in rows]
def distinct_sources(db: Session) -> list[str]:
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
#2674: фильтр источников в админке был захардкожен тремя площадками
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
точным значением все три пункта фильтра давали пустую выдачу, а 76%
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
Список обязан приходить из данных: новый source появляется в фильтре сам,
без правки кода.
Игнорирует фильтры /scrape/runs иначе выбор источника вырезал бы из
выпадающего списка все остальные.
"""
rows = db.execute(
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
).fetchall()
return [str(r.source) for r in rows]

View file

@ -66,8 +66,8 @@ class RealMatcherAdapter:
*,
year_built: int | None = None,
building_cadastral_number: str | None = None,
cadastral_number: str | None = None,
source_url: str | None = None,
city: str | None = None,
) -> tuple[int | None, float, str]:
# house_id is None when the matcher refuses a numberless address without a
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
@ -80,8 +80,8 @@ class RealMatcherAdapter:
lon,
year_built=year_built,
building_cadastral_number=building_cadastral_number,
cadastral_number=cadastral_number,
source_url=source_url,
city=city,
)
def upsert_listing_source(

View file

@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
where.append("total_floors <= CAST(:fl_total_max AS integer)")
args["fl_total_max"] = params.floors_total_max
if params.has_kadastr:
where.append("cadastral_number IS NOT NULL")
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
segment_clause = _SEGMENT_SQL[params.segment]
if segment_clause is not None:

View file

@ -111,7 +111,7 @@ async def backfill_yandex_addresses(
Returns:
YandexAddressBackfillResult with checked/saved/skipped/errors counters.
"""
from app.core.config import settings
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
result = YandexAddressBackfillResult()
t0 = time.time()
@ -130,7 +130,23 @@ async def backfill_yandex_addresses(
request_delay_sec,
)
_proxy_url = settings.scraper_proxy_url
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул
# пуст (легитимный dev/staging-сценарий).
try:
_proxy_url = resolve_proxy_url(db, "yandex")
except ProxyPoolExhaustedError as exc:
# Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
# мог быть источником текущего инцидента.
logger.error(
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
"пропущен, ни один листинг не обработан",
exc,
)
result.duration_sec = time.time() - t0
return result
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(

View file

@ -10,8 +10,22 @@ Legacy listings (older than 2h or outside radius) are never enriched.
Solution: single snapshot SELECT at start (guarantees termination), same proxy
session path as the detail-phase of `run_avito_city_sweep`
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
rotate IP on every block, abort after max_consecutive_blocks (mark_done not
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at).
rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
блоками прогона 'banned' (#2674, runs.mark_backfill_finished): работу он не
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны
3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и
выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси.
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
отказа пишется в текст статуса прогона (_failure_signature) иначе она живёт
только в логах контейнера, а те исчезают на первом же деплое.
ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения
(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон
3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ финализатор
диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ
сайдкара в AvitoBlockedError, так что передавать было бы нечего.
"""
from __future__ import annotations
@ -19,7 +33,9 @@ from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from collections import Counter
from dataclasses import dataclass, field
from urllib.parse import urlparse
@ -30,7 +46,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
)
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -47,12 +63,14 @@ from scraper_kit.providers.avito.detail import (
save_detail_enrichment,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.snapshot_writer import upsert_listing_snapshot
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod
from app.services.proxy_egress import resolve_proxy_url
from app.services.scraper_adapters import RealScraperConfig
# #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше
@ -97,6 +115,38 @@ _OBLAST_AVITO_URL_PATTERNS = tuple(
)
# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться
# в ОДНУ строку, иначе перепись бесполезна.
_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+")
def _failure_signature(exc: BaseException) -> str:
"""Подпись причины отказа: тип исключения + текст без URL.
Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили
`attempted=1576, failed=1576, blocked=0` и ничего больше. Кто отказал,
площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот
пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают
события уровня ERROR, а поштучные отказы WARNING. Разница между этими
двумя диагнозами разные владельцы задачи (#2686, #2698), поэтому она
обязана переживать перезапуск контейнера, то есть лежать в самом прогоне.
Тип исключения первый разряд диагноза (AvitoBlockedError = площадка
показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт;
ValueError = ответ пришёл, но не разобран), текст второй.
"""
message = _URL_IN_MESSAGE_RE.sub("<url>", str(exc)).strip()
return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__
def _top_failure(census: Counter[str]) -> str | None:
"""Самая частая причина отказа с её долей; None — отказов не было."""
if not census:
return None
reason, hits = census.most_common(1)[0]
return f"{reason} ({hits} из {sum(census.values())})"
@dataclass
class AvitoDetailBackfillResult:
"""Counters for one backfill run."""
@ -137,15 +187,25 @@ async def run_avito_detail_backfill(
budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5.
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_done (incl. partial/block-abort) / mark_failed (exception only).
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
mark_failed напрямую только при исключении.
"""
batch_size = int(params.get("batch_size", 800))
oblast_batch_size = int(params.get("oblast_batch_size", 100))
budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
# Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы —
# нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток,
# получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600
# запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка
# мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый
# прогон, а 25 отказов подряд без единого успеха — уже не невезение.
max_consecutive_failures = int(params.get("max_consecutive_failures", 25))
warm_batch = int(params.get("warm_batch", 500))
research_every = int(params.get("research_every", 50))
block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0))
@ -209,12 +269,19 @@ async def run_avito_detail_backfill(
elif not use_curl:
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
# строим shared сессию через auv, как делает run_avito_city_sweep (kit).
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только
# если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все
# забанены/нездоровы для avito -- resolve_proxy_url бросает
# ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь --
# штатный except Exception ниже (mark_failed + logger.exception + raise)
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
own_session = True
session = AsyncSession(
impersonate="chrome120",
timeout=25,
headers=DOCUMENT_HEADERS,
proxies=http_proxies(settings.scraper_proxy_url),
proxies=http_proxies(resolve_proxy_url(db, "avito")),
)
scraper._cffi = session
@ -241,7 +308,7 @@ async def run_avito_detail_backfill(
text(
"""
WITH ekb AS (
SELECT id, source_url, 'ekb' AS city_scope
SELECT id, source_url, price_rub, 'ekb' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
@ -254,7 +321,7 @@ async def run_avito_detail_backfill(
LIMIT CAST(:batch_size AS int)
),
oblast AS (
SELECT id, source_url, 'oblast' AS city_scope
SELECT id, source_url, price_rub, 'oblast' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
@ -264,9 +331,9 @@ async def run_avito_detail_backfill(
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:oblast_batch_size AS int)
)
SELECT id, source_url, city_scope FROM ekb
SELECT id, source_url, price_rub, city_scope FROM ekb
UNION ALL
SELECT id, source_url, city_scope FROM oblast
SELECT id, source_url, price_rub, city_scope FROM oblast
"""
),
{
@ -305,8 +372,17 @@ async def run_avito_detail_backfill(
)
consecutive_blocks = 0
consecutive_failures = 0
aborted_by_blocks = False
do_sleep = False
items_since_warm = 0
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
# #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один —
# он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает
# mark_backfill_finished, один узел на все три backfill'а).
block_ban_kinds: set[str] = set()
for idx, row in enumerate(snapshot):
# Budget guard
@ -425,8 +501,9 @@ async def run_avito_detail_backfill(
if use_curl:
items_since_warm += 1
consecutive_blocks = 0
consecutive_failures = 0
except AvitoListingGoneError:
except AvitoListingGoneError as gone_exc:
# #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed.
# Координатные дыры в lat-null очереди в основном dead-листинги;
# browser-mode рендерит их «Ошибка 404» без item-view → раньше это
@ -436,12 +513,31 @@ async def run_avito_detail_backfill(
# и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope
# (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь.
counters.gone += 1
# 404 — честный ответ площадки, значит тракт цел: серия отказов
# прерывается (блочный брейкер 404 не трогает — см. #2034).
consecutive_failures = 0
failure_census[_failure_signature(gone_exc)] += 1
try:
with db.begin_nested():
db.execute(
text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
{"id": row["id"]},
)
# #2674: 404 с площадки — самый достоверный сигнал снятия,
# фиксируем его в дневной истории (listings_snapshots.status
# был константой 'active' у всех строк, 394 299). Тот же
# SAVEPOINT, что и UPDATE флага: снимок без флага (или
# наоборот) невозможен. price_rub из snapshot-SELECT —
# .get() консервативен ради mock-снапшотов старых тестов.
gone_price = row.get("price_rub")
if gone_price is not None:
upsert_listing_snapshot(
db,
listing_id=row["id"],
price_rub=gone_price,
run_id=run_id,
status="closed",
)
except Exception:
logger.warning(
"avito_detail_backfill: run_id=%d failed to mark listing %s "
@ -462,6 +558,8 @@ async def run_avito_detail_backfill(
except (AvitoBlockedError, AvitoRateLimitedError) as e:
consecutive_blocks += 1
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
block_ban_kinds.add(ban_kind_of_exception(e))
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
@ -482,6 +580,7 @@ async def run_avito_detail_backfill(
counters.enriched,
counters.attempted,
)
aborted_by_blocks = True
break
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
@ -518,12 +617,14 @@ async def run_avito_detail_backfill(
exc_info=True,
)
except TimeoutError:
except TimeoutError as e:
# asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias).
# Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший
# fetch отменён → листинг failed, переходим к следующему (loop не зависает,
# run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем.
counters.failed += 1
consecutive_failures += 1
failure_census[_failure_signature(e)] += 1
logger.warning(
"avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip",
run_id,
@ -537,6 +638,8 @@ async def run_avito_detail_backfill(
except Exception as e:
counters.failed += 1
consecutive_failures += 1
failure_census[_failure_signature(e)] += 1
logger.warning(
"avito_detail_backfill: run_id=%d listing %s failed: %s",
run_id,
@ -548,15 +651,35 @@ async def run_avito_detail_backfill(
except Exception:
pass
if consecutive_failures >= max_consecutive_failures:
logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без "
"единого успеха, частая причина: %s. enriched=%d attempted=%d",
run_id,
consecutive_failures,
_top_failure(failure_census) or "неизвестна",
counters.enriched,
counters.attempted,
)
break
if counters.attempted % 25 == 0:
current_counters = counters.to_dict()
runs_mod.update_heartbeat(db, run_id, current_counters)
counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict()
runs_mod.mark_done(db, run_id, current_counters)
runs_mod.mark_backfill_finished(
db,
run_id,
current_counters,
source="avito_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
fail_hint=_top_failure(failure_census),
ban_kinds=block_ban_kinds,
)
logger.info(
"avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"blocked=%d gone=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,

View file

@ -0,0 +1,323 @@
"""Перенос координат из объявлений в дом, когда объявления между собой согласны (#2771).
ЗАЧЕМ. Дом получает координаты РОВНО ОДИН РАЗ в момент создания, из породившего его
объявления (`matching/houses.py`). Объявление без координат создаёт дом без координат;
позже объявление геокодируется (`geoportal_coords_backfill`, `geocode_missing_listings`
обе живые ежедневные задачи), но пишут они в `listings`, а `ON CONFLICT` у вставки дома
обновляет только `address`. Обратно в дом координаты не возвращались никогда: из 52
источников расписания `houses.lat`/`houses.lon` не писал ни один. Замер на проде 2026-08-07:
1945 домов из 9625 (20.2%) без координат, при этом у 1490 из них объявления с координатами
УЖЕ ЕСТЬ. Эта задача недостающее звено проводки, и именно ПОВТОРЯЮЩЕЕСЯ: однократный
скрипт закрыл бы дыру на день, а приток новых домов без координат никуда не девается.
ЧТО ДЕЛАЕТ. Для каждого дома с lat IS NULL берёт geom всех связанных объявлений, считает
центроид и РАЗБРОС максимальное расстояние от центроида до объявления (ST_DistanceSphere,
метры на сфере). Разброс <= max_spread_m центроид пишется в дом; иначе дом НЕ трогается
и попадает в счётчик skipped_scattered.
ПОРОГ 125 м почему столько (замер на проде 2026-08-07, ST_DistanceSphere, не приближение
«градусы × 111320»). Гистограмма разброса по 258 домам с >1 различной точкой не даёт обрыва:
плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до
400 м, и масса из 67 домов дальше 400 м. Раз в данных обрыва нет, порог берётся не из них,
а из ПОТРЕБИТЕЛЯ: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием
при ST_DistanceSphere <= 250 м. Записанный сюда центроид оценка со своей погрешностью
ровно в этот разброс; если разрешить каждому дому ошибаться на все 250 м, два дома с
максимальной ошибкой разъедутся на 500 м, и ограждение отвергнет верную пару (или сведёт
неверную). Отдаём своей погрешности ПОЛОВИНУ бюджета ограждения 125 м. Цена решения
мала и потому оно не несущее: 125 м пропускает 1382 дома, 250 м 1403, разница 21 дом,
и это не потеря, а отсрочка (дом остаётся NULL и попадёт в следующий прогон, если его
объявления сойдутся).
ЧЕГО НЕ ДЕЛАЕТ. Не обещает роста числа слияний дублей. Сейчас гео-ограждение отбраковывает
проигравших с NULL geom, потому что проверить тождество нечем; после переноса у него
появляется ВОЗМОЖНОСТЬ оценить пару часть сольётся, часть будет отвергнута правильно
(дома в разных городах). Ни то ни другое здесь не предрешается.
ДОМА С БОЛЬШИМ РАЗБРОСОМ не задача переноса, а дефект сопоставителя: в одну запись дома
сшиты объявления из разных населённых пунктов (худший на проде «ул. Кирова,4», разброс
290 км). Они не переносятся, считаются отдельным счётчиком и логируются поимённо (топ-5),
чтобы пропуск был виден, а не читался как «покрыто всё».
ИДЕМПОТЕНТНОСТЬ. UPDATE только по `h.lat IS NULL` (тот же приём, что в
`backfill_listings_coords_geoportal`). Повторный прогон не трогает уже проставленные
координаты и на чистой базе no-op с нулевыми счётчиками.
geom СТРОИТСЯ ЯВНО. Триггер `houses_set_geom_trg` на проде BEFORE INSERT OR UPDATE OF
lat, lon (проверено 2026-08-07), то есть geom построился бы и сам; UPDATE всё равно пишет
geom явно как это делает `_update_listing_coords` в geoportal-задаче чтобы правка не
зависела от живости триггера и не оказалась беззвучной, если его когда-нибудь снимут.
Запуск:
python -m app.tasks.backfill_house_coords_from_listings
python -m app.tasks.backfill_house_coords_from_listings --max-spread-m 125
Ежедневно через in-app scheduler (source='house_coords_from_listings', migration 239).
"""
from __future__ import annotations
import argparse
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# Половина 250-метрового гео-ограждения house_dedup_merge (#2187) — обоснование в docstring.
DEFAULT_MAX_SPREAD_M = 125.0
# «Разброс в километрах» — порог отдельного счётчика для дефектов сопоставителя (#2777).
SCATTER_DEFECT_M = 5000.0
# Сколько худших домов с разбросом назвать поимённо в логе.
_SCATTER_LOG_TOP = 5
# Кандидаты: КАЖДЫЙ дом без координат + агрегат по его объявлениям. LEFT JOIN, чтобы
# «объявлений нет вовсе» и «объявления есть, но без координат» различались в счётчиках.
# ST_Collect/count(geom) пропускают NULL — дом без геокодированных объявлений получает
# cen IS NULL → lat/lon NULL → в UPDATE не попадает (n_geocoded = 0).
_BUILD_CANDIDATES_SQL = """
CREATE TEMP TABLE _2771_house_coords ON COMMIT DROP AS
WITH coordless AS (
SELECT id FROM houses WHERE lat IS NULL OR lon IS NULL
),
linked AS (
SELECT c.id AS house_id, l.id AS listing_id, l.geom
FROM coordless c
LEFT JOIN listings l ON l.house_id_fk = c.id
),
agg AS (
SELECT house_id,
count(listing_id) AS n_listings,
count(geom) AS n_geocoded,
ST_Centroid(ST_Collect(geom)) AS cen
FROM linked
GROUP BY house_id
),
spread AS (
SELECT l.house_id,
max(ST_DistanceSphere(l.geom, a.cen)) AS spread_m
FROM linked l
JOIN agg a ON a.house_id = l.house_id
WHERE l.geom IS NOT NULL
GROUP BY l.house_id
)
SELECT a.house_id,
a.n_listings,
a.n_geocoded,
ST_Y(a.cen) AS lat,
ST_X(a.cen) AS lon,
s.spread_m
FROM agg a
LEFT JOIN spread s ON s.house_id = a.house_id
"""
_COUNTERS_SQL = """
SELECT count(*) AS candidates,
count(*) FILTER (WHERE n_listings = 0) AS skipped_no_listings,
count(*) FILTER (WHERE n_listings > 0 AND n_geocoded = 0) AS skipped_no_listing_coords,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:max_spread_m AS double precision))
AS skipped_scattered,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:defect_m AS double precision))
AS scattered_over_5km,
COALESCE(max(spread_m) FILTER (WHERE n_geocoded > 0
AND spread_m > CAST(:max_spread_m AS double precision)), 0)
AS worst_spread_m,
count(*) FILTER (WHERE n_geocoded > 0
AND spread_m <= CAST(:max_spread_m AS double precision))
AS eligible
FROM _2771_house_coords
"""
# Идемпотентность: h.lat IS NULL повторяется в UPDATE (а не только в отборе кандидатов) —
# уже проставленные координаты не перезаписываются ни при каком стечении обстоятельств.
_APPLY_SQL = """
UPDATE houses h
SET lat = c.lat,
lon = c.lon,
geom = ST_SetSRID(ST_MakePoint(c.lon, c.lat), 4326)
FROM _2771_house_coords c
WHERE h.id = c.house_id
AND h.lat IS NULL
AND c.n_geocoded > 0
AND c.spread_m <= CAST(:max_spread_m AS double precision)
"""
_SCATTER_TOP_SQL = """
SELECT c.house_id, h.address, c.n_geocoded, c.spread_m
FROM _2771_house_coords c
JOIN houses h ON h.id = c.house_id
WHERE c.n_geocoded > 0
AND c.spread_m > CAST(:max_spread_m AS double precision)
ORDER BY c.spread_m DESC
LIMIT CAST(:top AS integer)
"""
@dataclass
class HouseCoordsResult:
"""Счётчики прогона. Пропуск виден отдельно по КАЖДОЙ причине — молчаливое усечение
читается как «покрыто всё»."""
candidates: int = 0 # домов без координат всего
eligible: int = 0 # объявления согласны (разброс <= порога)
updated: int = 0 # реально обновлено (UPDATE rowcount)
skipped_no_listings: int = 0 # объявлений у дома нет вовсе
skipped_no_listing_coords: int = 0 # объявления есть, но сами без координат
skipped_scattered: int = 0 # разброс больше порога — дефект сопоставителя (#2777)
scattered_over_5km: int = 0 # из них разъехавшиеся на километры
worst_spread_m: int = 0 # худший пропущенный разброс, метры
max_spread_m: int = 0 # применённый порог, метры (чтобы счётчики читались без кода)
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"candidates": self.candidates,
"eligible": self.eligible,
"updated": self.updated,
"skipped_no_listings": self.skipped_no_listings,
"skipped_no_listing_coords": self.skipped_no_listing_coords,
"skipped_scattered": self.skipped_scattered,
"scattered_over_5km": self.scattered_over_5km,
"worst_spread_m": self.worst_spread_m,
"max_spread_m": self.max_spread_m,
"duration_sec": int(self.duration_sec),
}
def backfill_house_coords(
db: Session,
*,
max_spread_m: float = DEFAULT_MAX_SPREAD_M,
) -> HouseCoordsResult:
"""Перенести координаты объявлений в дома, где объявления согласны.
Одна транзакция: TEMP-таблица кандидатов (ON COMMIT DROP) счётчики UPDATE.
Счётчики считаются по ТОЙ ЖЕ таблице, из которой идёт UPDATE, поэтому «сколько
пропущено» относится ровно к этому прогону, а не к соседнему запросу.
Args:
db: сессия tradein-БД.
max_spread_m: предел разброса объявлений, метры (см. DEFAULT_MAX_SPREAD_M).
Returns:
HouseCoordsResult со счётчиками по каждой причине пропуска.
"""
start = time.monotonic()
res = HouseCoordsResult(max_spread_m=int(max_spread_m))
params = {"max_spread_m": max_spread_m, "defect_m": SCATTER_DEFECT_M}
db.execute(text(_BUILD_CANDIDATES_SQL))
row = db.execute(text(_COUNTERS_SQL), params).mappings().one()
res.candidates = int(row["candidates"])
res.eligible = int(row["eligible"])
res.skipped_no_listings = int(row["skipped_no_listings"])
res.skipped_no_listing_coords = int(row["skipped_no_listing_coords"])
res.skipped_scattered = int(row["skipped_scattered"])
res.scattered_over_5km = int(row["scattered_over_5km"])
res.worst_spread_m = int(row["worst_spread_m"])
if res.skipped_scattered:
worst = (
db.execute(text(_SCATTER_TOP_SQL), {**params, "top": _SCATTER_LOG_TOP}).mappings().all()
)
logger.warning(
"house_coords: ПРОПУЩЕНО %d домов с разбросом объявлений > %.0f м "
"(из них %d дальше %.0f км) — это дефект сопоставителя (#2777), не переноса. "
"Худшие: %s",
res.skipped_scattered,
max_spread_m,
res.scattered_over_5km,
SCATTER_DEFECT_M / 1000,
"; ".join(
f"id={w['house_id']} {w['address']!r} n={w['n_geocoded']} "
f"разброс={w['spread_m'] / 1000:.1f} км"
for w in worst
),
)
res.updated = int(db.execute(text(_APPLY_SQL), params).rowcount or 0)
db.commit()
res.duration_sec = time.monotonic() - start
logger.info(
"house_coords: DONE — кандидатов=%d годных=%d обновлено=%d | пропущено: "
"без_объявлений=%d объявления_без_координат=%d разброс>%.0fм=%d (худший %d м) "
"время=%.1fs",
res.candidates,
res.eligible,
res.updated,
res.skipped_no_listings,
res.skipped_no_listing_coords,
max_spread_m,
res.skipped_scattered,
res.worst_spread_m,
res.duration_sec,
)
return res
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_house_coords_from_listings(
db: Session,
*,
run_id: int,
params: dict,
) -> HouseCoordsResult:
"""Run-lifecycle обёртка (kit scheduler, source='house_coords_from_listings').
Params (default_params jsonb):
max_spread_m: предел разброса в метрах (default 125).
"""
max_spread_m = float(params.get("max_spread_m", DEFAULT_MAX_SPREAD_M))
counters: dict[str, int] = HouseCoordsResult(max_spread_m=int(max_spread_m)).to_counters()
try:
runs_mod.update_heartbeat(db, run_id, counters)
res = backfill_house_coords(db, max_spread_m=max_spread_m)
counters = res.to_counters()
runs_mod.mark_done(db, run_id, counters)
return res
except Exception as exc:
logger.exception("run_house_coords_from_listings: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
logger.warning("run_house_coords_from_listings: rollback failed", exc_info=True)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(
description="Перенос координат объявлений в дома, где объявления согласны (#2771)"
)
parser.add_argument(
"--max-spread-m",
type=float,
default=DEFAULT_MAX_SPREAD_M,
help=f"Предел разброса объявлений, метры (default: {DEFAULT_MAX_SPREAD_M:.0f})",
)
args = parser.parse_args()
db = SessionLocal()
try:
backfill_house_coords(db, max_spread_m=args.max_spread_m)
finally:
db.close()
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show more