Compare commits

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

149 commits

Author SHA1 Message Date
baed8f75a3 Свип ДомКлика ходит в тёплом браузерном контексте, а не поднимает камуфокс на каждый фетч (#3595)
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 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 3m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-17 18:10:25 +00:00
dbac5d4e1c Вернуть домклик-свипы Москвы и области (#3594)
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 3m53s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 1m27s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
2026-09-17 14:03:00 +00:00
f71a438495 fix(ops): Watchdog уходит из Telegram, появляется эскалация по длительности (#3589)
All checks were successful
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 50s
Deploy / build-worker (push) Successful in 49s
Deploy / build-backend (push) Successful in 51s
Deploy Metrics / agent-apps (push) Successful in 29s
Deploy Metrics / agent-infra (push) Successful in 29s
Deploy / deploy (push) Successful in 1m14s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m41s
Watchdog пингует внешний deadman-приёмник вместо человеческой ленты. Новое правило AlertFiringTooLong при firing дольше 6 часов. Пустой секрет пинга оставляет приёмник без конфигов, а не роняет Alertmanager.
2026-09-17 13:54:02 +00:00
e5212e52ab Свип ДомКлика сохраняет лоты по корзинам, а не одним махом в конце (#3592)
All checks were successful
Deploy Trade-In / changes (push) Successful in 17s
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 4m4s
Deploy Trade-In / build-backend (push) Successful in 2m23s
Deploy Trade-In / deploy (push) Successful in 8m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
2026-09-17 13:37:47 +00:00
98f587add6 Merge pull request 'ПТИЦА: гео-радиусная цена участка считается по лотам проектов ближних ЖК, а не по чужим лотам под устаревшим complex_id (#3583)' (#3591) from fix/3583-radius-price-complex into main
All checks were successful
Deploy / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m38s
Deploy / build-worker (push) Successful in 3m50s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m43s
2026-09-17 13:32:55 +00:00
7c52622a76 fix(parcels): гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не чужие под устаревшим complex_id (#3583)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m48s
CI / backend-tests (pull_request) Successful in 7m42s
geo_radius_price отбирал лоты по objective_lots.complex_id. Эта колонка
проставлена один раз миграцией 76, а еженедельный UPSERT переписывает
project_name и не трогает complex_id, поэтому под id ближнего ЖК лежат лоты
ЖК из других районов. На проде медиана любого участка сходилась к средней по
городу: 125–138 тыс. ₽/м² при реальном разбросе 107–181 тыс.

Как в #2962: complex → проект через complex_sources (source='objective'),
лоты по project_name, имя проекта сверяется с именем ЖК. Сверка отсекает 4
неверные fuzzy-связи (VEER PARK/Clever Park, Клевер/Веер, Графит/Гранит,
Традиции/Традиция) и 2 верные («Новая Ботаника-2», «Теплые кварталы (BAZA)»).

Дедуп физлота перенесён в LATERAL по проекту с premise_kind='квартира',
чтобы работал покрывающий индекс: иначе запрос шёл 690 мс вместо 62.
Апартаменты сейчас есть только у проектов, чьи complexes без координат, так
что на медиану фильтр не влияет (570 участков, 0 отличий).

SQL вынесен в _GEO_RADIUS_PRICE_SQL, чтобы тест гонял его на временных таблицах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:21:15 +05:00
a82239b931 Merge pull request 'alert-ack: значение секрета вебхука GlitchTip больше не пишется в лог — строка доступа и ошибки разбора дают secret=***' (#3588) from fix/3576-alert-ack-redact-secret into main
All checks were successful
Deploy / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 49s
Deploy / build-backend (push) Successful in 47s
Deploy / build-worker (push) Successful in 49s
Deploy Metrics / agent-apps (push) Successful in 28s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy / deploy (push) Successful in 1m16s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m41s
2026-09-17 13:15:01 +00:00
1265e69412 chore(forgejo): ограничить размер json-лога контейнера (#3587)
Лог forgejo дорос до 3.35 ГиБ за 23 дня без ротации. 50m x 3 = потолок 150 МБ.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-09-17 13:08:15 +00:00
8b9b541736 alert-ack: значение секрета из query больше не пишется в лог (#3576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 23s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m57s
CI / backend-tests (pull_request) Successful in 7m35s
GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а
BaseHTTPRequestHandler печатает строку запроса целиком: в строке доступа
(log_request) и в тексте ошибки разбора (log_error, «Bad request syntax
('POST /glitchtip?secret=…')»). Оба пути сходятся в log_message — маскируем
там одним выражением, тем же, что у бэкенда МЕРЫ (#3154, log_scrub.py) и у
Alloy (#3354). Импортировать его нельзя: сервис намеренно без зависимостей.

Тест в backend/tests/ops/test_3078_alert_ack.py (его гоняет CI по ops/**):
настоящий сокет, три строки запроса — доступ, имя с префиксом, ошибка
разбора; значения в записях нет, `=***` стоит в ожидаемом числе записей.

Ротация секрета — за владельцем, здесь не делается.

Refs #3576

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:05:16 +05:00
a1c7f8ea92 Merge pull request 'Адреса МЕРЫ: «Сулимова, 3/2» и «Сулимова, 32» больше не один дом — дробь в номере не теряется при сопоставлении с ГАР и схлопывании дублей' (#3578) from fix/canon-addr-slash into main
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 4m12s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 6m12s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m49s
Deploy Trade-In / deploy-status (push) Successful in 3s
2026-09-17 12:57:38 +00:00
d633c0d1c3 chore(forgejo): отслеживаемый compose с настройками хранения и очистки (#3543)
Ретенция логов Actions 30д, артефактов 14д, очистка кэша архивов раз в час вместо раза в сутки.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-09-17 12:53:59 +00:00
da5fd24435 fix(alerts): пороги диска в гигабайтах плюс тревога на скорость расхода (#3542)
All checks were successful
Deploy Metrics / server (push) Successful in 47s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy Metrics / agent-apps (push) Successful in 39s
2026-09-17 12:49:42 +00:00
f0d98934bd Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
All checks were successful
CI / changes (pull_request) Successful in 15s
CI Trade-In / 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 5m26s
2026-09-17 17:46:46 +05:00
21f9c29598 test(tradein/canon): фикстура матча ГАР задаёт регион дома — после #2583 H5 дом без региона не матчится (#2189)
После слияния main красным стал test_real_gar_match_picks_the_house_with_the_same_number:
оба дома получили (None, None, None). Канон-ключи и пары верные («сулимова21893/2» ↔
g2189-3-2, «сулимова218932» ↔ g2189-32); строки отсекает новый предикат _MATCH_SQL из
#3575 (b9c89641) `CAST(h.region_code AS text) = gp.region_code` — у фикстуры region_code
NULL. Это задуманное поведение #3575 (дом без региона guid не получает), поэтому чинится
фикстура: region_code 66, как у ГАР-строк и прогона. Проверка канона со «/» не менялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:39:43 +05:00
9b818c2029 Merge pull request 'Деплой МЕРЫ снова заблокирован: три живых теста снапшотов не в allowlist' (#3584) from fix/allowlist-2993-live-tests into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Successful in 51s
Deploy Trade-In / build-frontend (push) Successful in 2m57s
Deploy Trade-In / test (push) Successful in 4m24s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
2026-09-17 11:35:34 +00:00
fd454dec4b fix(tests): красный деплой-лэйн — три живых теста #2993 не объявлены в skip_allowlist
All checks were successful
CI / changes (pull_request) Successful in 19s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m47s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
После мержа #3577 (снапшоты «строка на изменение») прогон без БД — тот, что
гоняет deploy-tradein.yml перед сборкой, — завершается rc=1:

  НЕУЧТЁННЫЙ ПРОПУСК (3): tests/test_2993_snapshot_change_only.py::...

В CI PR эти тесты шли против postgres-сервиса и проходили, поэтому
расхождение видно только на деплой-лэйне, где они законно пропускаются.
Объявлены с причиной, как соседние живые тесты (#3385, #3191).

Деплой-лэйн: 6465 passed, 68 skipped, rc=0, неучтённых пропусков 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:28:59 +05:00
8bd16e6ad0 Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
Some checks failed
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI / backend-tests (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 / backend-tests (pull_request) Failing after 5m46s
2026-09-17 16:23:58 +05:00
7da94f183f Merge pull request 'Тесты разбора площадок: дом Авито, выдачи Циана и Яндекса снова проверяются на сохранённых страницах' (#3580) from fix/kit-parser-unit-tests into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-browser (push) Successful in 2m43s
Deploy Trade-In / build-frontend (push) Successful in 3m15s
Deploy Trade-In / test (push) Failing after 4m34s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s
2026-09-17 11:23:32 +00:00
9e337855da Merge pull request 'Сайдкар МЕРЫ: не больше трёх живых браузеров, простаивающий закрывается, зомби-процессы собирает init' (#3581) from fix/browser-instances-limit into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 11:23:14 +00:00
5b6be30894 Merge pull request 'Снимки источников МЕРА пишутся только при изменении — в 26–38 раз меньше строк за ночь (#2993, PR-C)' (#3577) from fix/snapshot-change-only into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 11:23:00 +00:00
71e551d57d Merge pull request 'МЕРА: срок продажи показываем окном «от–до» по снятым объявлениям и сохраняем его вместе с оценкой' (#3573) from fix/days-on-market-window into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
2026-09-17 11:22:45 +00:00
a6a1b7cdfc Merge pull request 'Бэктест оценщика берёт тип дома из справочника домов, как боевая оценка — штраф за тип дома впервые участвует в замере' (#3571) from fix/backtest-house-type into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 11:22:33 +00:00
9c71349eed Merge pull request 'МЕРА, область: коридор сделок узнаёт города развёрток, ГАР не раздаёт домам чужой регион, история цен не врёт про «только этот дом»' (#3575) from fix/region-audit-tail into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 11:22:16 +00:00
4c11a7dab5 Merge pull request 'Яндекс: пять вечно недогружаемых карточек больше не обрывают каждый прогон добора' (#3574) from fix/yandex-perpetual-underloaded into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 11:22:03 +00:00
f9bebdf40f Merge pull request 'Схлопывание дублей домов: ФИАС-проход больше не сливает дома из разных городов, а «17Ар-н Академический» находит своего двойника' (#3558) from fix/houses-dedup into main
Some checks failed
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 31s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-09-17 11:17:35 +00:00
e942acaf12 Merge pull request 'Конкуренты ПТИЦЫ: скорость продаж и цена для несопоставленных ЖК берутся по своему проекту, а не по чужим под общим complex_id' (#3582) from fix/competitors-bridge into main
All checks were successful
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m49s
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m10s
2026-09-17 11:16:09 +00:00
3a8513cd68 Merge pull request 'ПТИЦА: убрать мёртвую фазу bulk_harvest, которая на каждый квартал запрашивала ПЗЗ у НСПД и писала их в таблицу без читателей' (#3579) from fix/ptica-bulk-harvest-phase25-removal into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Has been cancelled
2026-09-17 11:11:54 +00:00
77fdeefc50 Merge pull request 'Лучшие планировки: в «покрытие P% (Y из N комплексов)» N считается одинаково во всех ветках' (#3570) from fix/ptica-best-layouts into main
Some checks failed
Deploy / changes (push) Successful in 24s
Deploy / build-backend (push) Has been cancelled
Deploy / build-frontend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
2026-09-17 11:02:13 +00:00
6eaa31ac66 Merge remote-tracking branch 'origin/main' into fix/ptica-bulk-harvest-phase25-removal
All checks were successful
CI Trade-In / changes (pull_request) Successful in 26s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
2026-09-17 15:50:38 +05:00
5af29ee9a4 Merge remote-tracking branch 'origin/main' into fix/kit-parser-unit-tests
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 27s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 7m9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
2026-09-17 15:50:27 +05:00
92af3491ae Merge remote-tracking branch 'origin/main' into fix/browser-instances-limit
All checks were successful
CI Trade-In / changes (pull_request) Successful in 29s
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 7m23s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m11s
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
2026-09-17 15:50:18 +05:00
d76411b45f Merge remote-tracking branch 'origin/main' into fix/competitors-bridge
All checks were successful
CI / backend-tests (pull_request) Successful in 10m3s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 3m4s
CI Trade-In / changes (pull_request) Successful in 21s
CI / openapi-codegen-check (pull_request) Successful in 4m23s
CI / changes (pull_request) Successful in 25s
2026-09-17 15:50:07 +05:00
c14119a20b Merge remote-tracking branch 'origin/main' into fix/snapshot-change-only
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m31s
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 / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 31s
2026-09-17 15:49:56 +05:00
084cb79f99 Merge remote-tracking branch 'origin/main' into fix/days-on-market-window
All checks were successful
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 3m13s
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 35s
CI Trade-In / backend-tests (pull_request) Successful in 8m46s
2026-09-17 15:49:46 +05:00
86629dc9f4 Merge remote-tracking branch 'origin/main' into fix/backtest-house-type
All checks were successful
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 / changes (pull_request) Successful in 21s
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 8m41s
2026-09-17 15:49:37 +05:00
372328b726 Merge remote-tracking branch 'origin/main' into fix/region-audit-tail
All checks were successful
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 / changes (pull_request) Successful in 24s
CI Trade-In / frontend-checks (pull_request) Successful in 3m19s
CI / changes (pull_request) Successful in 28s
CI Trade-In / backend-tests (pull_request) Successful in 8m48s
CI Trade-In / browser-tests (pull_request) Has been skipped
2026-09-17 15:49:27 +05:00
145a3a7944 Merge remote-tracking branch 'origin/main' into fix/canon-addr-slash
All checks were successful
CI / changes (pull_request) Successful in 27s
CI Trade-In / backend-tests (pull_request) Successful in 8m40s
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 / changes (pull_request) Successful in 21s
2026-09-17 15:49:18 +05:00
6375b191e4 Merge remote-tracking branch 'origin/main' into fix/yandex-perpetual-underloaded
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 19s
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 / changes (pull_request) Successful in 23s
CI Trade-In / backend-tests (pull_request) Successful in 8m29s
2026-09-17 15:49:09 +05:00
2b73961af5 Merge pull request 'Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси' (#3565) from fix/proxy-pool-guards into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m5s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 4s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m52s
Deploy Trade-In / build-backend (push) Successful in 3m17s
Deploy Trade-In / changes (push) Successful in 34s
Deploy Trade-In / test (push) Successful in 6m22s
2026-09-17 10:49:03 +00:00
cd504c1eb9 Merge remote-tracking branch 'origin/main' into fix/ptica-best-layouts
All checks were successful
CI / backend-tests (pull_request) Successful in 10m16s
CI Trade-In / changes (pull_request) Successful in 20s
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / openapi-codegen-check (pull_request) Successful in 4m13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
2026-09-17 15:49:02 +05:00
f9045673e1 Merge remote-tracking branch 'origin/main' into fix/houses-dedup
All checks were successful
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 24s
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 8m37s
2026-09-17 15:48:56 +05:00
4ccf701df6 merge origin/main: конфликт skip_allowlist.txt (оба блока сохранены)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m45s
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
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
Полный сьют после слияния: 6378 passed, 61 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:48:25 +05:00
21fe24419a ПТИЦА конкуренты: у complex с двумя objective-проектами берётся сверенный по имени, тесты на сверку в обе стороны, кавычки и #968 (#2962)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 2m50s
CI / backend-tests (pull_request) Successful in 7m14s
По ревью PR #3582. Три мутации тесты не ловили (5 passed): нормализация
только пробелов, односторонний LIKE, снятый EXISTS #968. Плюс латентный
дефект: UNIQUE(source, source_id) не запрещает complex иметь два
objective-проекта, DISTINCT ON брал любой, сверка его отвергала, и верный
проект терялся (воспроизведено: complex с «Клён» и «Сосны», брался «Клён»).

- nearest_cx в обоих SQL: нормализованные ключи в LATERAL, сверка name_ok
  считается там же и стоит в ORDER BY после расстояния (name_ok DESC,
  source_id) — при двух проектах берётся сверенный, выбор детерминирован.
  Фильтр по-прежнему ПОСЛЕ DISTINCT ON.
- тесты: «Квартал "Татлин"» = «Квартал Татлин» (кавычки посреди имени),
  «Парковый» ⊂ «Парковый квартал» (обратная сторона LIKE), ближайший
  complex без лотов не съедает матч (#968), complex с двумя проектами.
  Старый тест пунктуации проверял пробел — переименован честно.

Прод 17.09 (только чтение): выбор nearest_cx у всех 185 gap-fill объектов
в обоих SQL совпал с головой PR (0 расхождений, 176 принято, 9 отвергнуто);
время на центре ЕКБ 1 км: конкуренты 70 → 69 мс, цена 103 → 103 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:33:25 +05:00
33182e7df9 CI ПТИЦА: Postgres с PostGIS, тест моста gap-fill в CI исполняется, а не пропускается (#2962)
На голове b74b02ea CI / backend-tests был красным (run 11737): 5125 passed, но
все 5 тестов test_2962_competitors_gapfill_bridge.py пропустились с причиной
«нет расширения postgis», а в skip_allowlist.txt их не было — гейт вернул rc=1.
В CI ПТИЦЫ plain postgres:16, а SQL конкурентов без PostGIS не исполнить
(ST_DWithin по geography).

- ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml); образ
  сам создаёт расширение в POSTGRES_DB.
- тест: при CI/GITHUB_ACTIONS skipif выключен — если PostGIS из CI пропадёт,
  тест упадёт с настоящей причиной, а не пропустится молча.
- skip_allowlist.txt: пропуск объявлен только для машины без базы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:28:19 +05:00
89b4a67508 Merge remote-tracking branch 'origin/main' into fix/ptica-bulk-harvest-phase25-removal
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m42s
CI / backend-tests (pull_request) Successful in 7m11s
2026-09-17 15:22:02 +05:00
8def34028c ПТИЦА: комментарии тестов bulk_harvest больше не упоминают удалённую фазу ПЗЗ (#2985)
В шести тестах test_cadastre_bulk.py комментарий к моку с xmin=None
говорил, что пропускаются «grid-walk + territorial_zones фазы». Фазы
territorial_zones в harvest_quarter после предыдущего коммита нет;
при bbox=None пропускается только grid-walk (bulk_harvest.py:176, :335).

Правка только в комментариях, поведение тестов не меняется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:21:54 +05:00
b74b02ea8b ПТИЦА конкуренты: gap-fill берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 29s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 28s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m52s
CI / backend-tests (pull_request) Failing after 8m27s
Что было. Для конкурентов вне objective_complex_mapping мост шёл
«ближайший complex → objective_lots.complex_id → все project_name».
complex_id проставлен один раз миграцией 76 на загрузке 10.05, а
еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
переписывает project_name и не трогает complex_id. Прод 17.09: из
303 677 строк с complex_id у 236 354 проект чужой (все вставлены 10.05,
переписаны 17.05–15.09). У 185 gap-fill конкурентов своих лотов 23 %,
скорость в медиане завышена в 39 раз (сумма 65 798 против 1 609 сделок/мес).

Что сделано. В _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL complex
связывается с проектом через complex_sources (source='objective', 1:1),
лоты и сделки берутся по project_name. Связь в complex_sources почти вся
fuzzy и не проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
'Гранит'), поэтому имя проекта дополнительно сверяется с именем объекта
ДОМ.РФ без регистра и пунктуации; сверка стоит после DISTINCT ON, внутри
join планировщик гонял regexp по 383k пар (3 с).

Замер на проде (все 1556 объектов, окно 3 мес): явный маппинг 308 и
остальные 1063 — изменилось 0; gap-fill 185 — изменились все, у 9 связь
отвергнута (7 чужих проектов + 2 «Традиции»/«Традиция»), доля своих лотов
23 % → 96 %. На 500 участках: 4317 прежних пар участок-конкурент — 0
изменений, 598 gap-fill пар — изменились все. Время запроса конкурентов
393 → 65 мс, ценового fallback 23 → 54 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:09:42 +05:00
be1403289a Merge pull request 'Домклик: число собственников снова читается, жилая площадь и балконы не стираются переобходом, нули в площадях — не площадь' (#3569) from fix/domclick-card-fields into main
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 1m8s
Deploy Trade-In / build-frontend (push) Successful in 4m55s
Deploy Trade-In / test (push) Successful in 6m25s
Deploy Trade-In / build-backend (push) Successful in 3m22s
Deploy Trade-In / deploy (push) Successful in 3m13s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m53s
Deploy Trade-In / changes (push) Successful in 30s
2026-09-17 10:05:08 +00:00
30d40e5747 Сайдкар МЕРЫ: не больше трёх живых браузеров и никаких зомби-процессов (#3412)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 40s
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 / browser-tests (pull_request) Successful in 2m0s
CI Trade-In / backend-tests (pull_request) Successful in 8m5s
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять
поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод
15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08;
медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5.

Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser,
через который идут все запуски, включая фоновый retry. На потолке закрывается
простаивающий инстанс другого поставщика: без context'а первым, по давности
использования внутри класса. Занятый лок не трогается, если закрыть некого,
запуск идёт сверх потолка с предупреждением. Слот резервируется на время
запуска. _close_browser снимает инстанс со словарей до первого await и больше
не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси,
как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch
закрыты тестами, каждый краснеет при снятии своей части фикса.

init: true у сервиса browser: PID 1 был python server.py и не собирал
осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:04:34 +05:00
3ee5244491 ПТИЦА: убрать мёртвую фазу 2.5 bulk_harvest — запрос ПЗЗ в НСПД на каждый квартал (#2985)
All checks were successful
CI / openapi-codegen-check (pull_request) Successful in 4m1s
CI / backend-tests (pull_request) Successful in 10m2s
CI Trade-In / changes (pull_request) Successful in 23s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
harvest_quarter после основного обхода делал отдельный grid-walk слоя 875838
(49 запросов к НСПД на квартал) и писал результат в cad_territorial_zones.
Эту таблицу никто не читает: на проде 1 строка за два полных прохода по ЕКБ,
zone_code/zone_name пустые (маппинг свойств не совпадает с ответом НСПД).
ПЗЗ до отчёта доходят другой трубой: nspd_sync -> nspd_quarter_dumps.features_json.
Владелец 17.09 выбрал удаление фазы.

Удалено: блок Phase 2.5 в harvest_quarter, писатель _save_territorial_zones,
обёртка NSPDBulkClient.get_territorial_zones_in_bbox (других вызовов нет),
тесты писателя и мок в test_cadastre_bulk. Таблица и её данные не тронуты,
миграций нет. Поправлены комментарии, ссылавшиеся на фазу.

Новый тест: квартал без overflow с валидным bbox стоит ровно один запрос
к НСПД (search_by_quarter), фазы прогресса без territorial_zones_started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:03:05 +05:00
6003ddedad Тесты kit-парсеров на сохранённых страницах: дом Авито, выдачи Циана и Яндекса (#2422, #2408)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 32s
CI / changes (pull_request) Successful in 33s
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 8m7s
После удаления golden-parity (#2419) разбор avito.houses, cian.serp и yandex.serp
не проверял ни один тест: правка извлечения поля в kit проходила бы зелёной.

- avito.houses: обе осиротевшие карточки дома (572538, 115188) — характеристики,
  рейтинг из reviews.filterEntries (#1789), отзывы, история размещений, мини-выдача
  с продавцом, рекомендации; карточка ЖК 12879380 — ветка developmentPage.
- avito.serp: цена, комнаты, площадь, этаж/этажность и дата из sortTimeStamp
  на avito_serp_sample.html.
- cian.serp: живая выдача 2026-09-17, урезанная до 4 offers и читаемых ключей
  (без телефонов и данных пользователя): вторичка, новостройка с домом, отделка
  и балкон, новостройка без улицы — номер дома не выдумывается (#1773).
- yandex.serp: живой gate-API 2026-09-17: канонизация партнёрского url (#2235),
  собственник/агентство, студия без roomsTotal, новостройка без оценки.
- guard: ни один модуль scraper_kit не импортирует app.* (AST, включая ленивые
  импорты) — замена удалённого test_kit_avito_has_no_app_imports.

Тесты лежат в tradein-mvp/backend/tests/scrapers, а не в packages/scraper-kit/tests:
CI гоняет pytest только из backend, каталог в пакете не собирался бы никем.

Мутационный прогон: 9 порч kit (рейтинг #1789, developmentPage, цена истории,
этаж Авито, год сдачи и адрес Циана, url и студия Яндекса, import app) — каждая
роняет хотя бы один тест.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:02:31 +05:00
bdc169af8b merge origin/main: починка красного main (#3572)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 26s
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 7m47s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:56:10 +05:00
7350287be7 merge origin/main: починка красного main (#3572)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 21s
CI / changes (pull_request) Successful in 26s
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 7m54s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:56:03 +05:00
85d455e387 Merge pull request 'Красный main: тесты коридора читали настройки, которые стали константами' (#3572) from fix/main-red-corridor-settings-tests into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 31s
Deploy Trade-In / build-browser (push) Successful in 1m10s
Deploy Trade-In / build-frontend (push) Successful in 4m40s
Deploy Trade-In / test (push) Successful in 5m52s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-09-17 09:55:42 +00:00
3d9eaa2704 fix(tradein/canon): канон-ключ адреса больше не теряет дробь в номере дома — «Сулимова, 3/2» ≠ «32» (#2189)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m8s
CI Trade-In / changes (pull_request) Successful in 24s
CI / changes (pull_request) Successful in 31s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
tradein_canon_addr последним шагом оставлял только [а-я0-9], и «3/2», «10/7», «4/1»
схлопывались в «32», «107», «41». Матчер ГАР при фильтре по городу брал из общего
ключа строку с max(flat_count): дом «Высоцкого, 4/2» получил 725 квартир ГАР-дома «42».

Миграция 322: тело 311 без изменений, кроме последнего шага — «/» остаётся, если с обеих
сторон цифры. Новый ключ — только уточнение старого (без «/» он равен старому), поэтому
склеить новые пары правка не может. Прод 17.09 (read-only): ключ меняется у 2549 из
49 204 домов, распавшиеся канон-кластеры — 113 пар, ни одной ближе 250 м и ни одной с
общим ФИАС. Затем REINDEX функционального индекса и сброс 47 canon_addr-сопоставлений,
державшихся только на потерянной дроби (порог остановки 100).

Живой тест B в test_real_fias_pass_cross_guard_and_identity_carryover держался на паре
«32»/«3/2»: после 322 у неё разные ключи, и страж разных ФИАС в тесте не участвовал
(со снятым стражем тест оставался зелёным). Пара заменена на «ул.»/«улица».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:49:41 +05:00
e4d44025e5 fix(tradein/snapshot): снимок источника пишется только при изменении (#2993, PR-C)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 26s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 31s
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) Failing after 7m37s
listing_source_snapshot каждую ночь копировал listing_sources целиком: прод
14-17.09 — 290-293 тыс. строк в сутки, из них отличались от предыдущего
снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23 — строка на
изменение; читатель (пол переобхода, PR-A #3056) и рельсы объёма (PR-B
#3066) уже смержены.

Строка пишется, если снимка ещё нет или (price_rub, is_active, last_seen_at,
payload_hash) отличается от последнего снимка источника, включая сегодняшний
(повторный прогон в те же сутки перезаписывает строку, только если значение
снова сдвинулось). Состояние на дату D = последняя строка с snapshot_date <= D
— для всех четырёх колонок то же, что дала бы суточная копия, поэтому пол
переобхода и события не меняются. Старые суточные снимки не трогаются.

Замер чтением на проде 17.09: отбор с условием — 1.0 с на 293 602 источника
(бюджет 900 с). Миграция 325 — только COMMENT на таблицу и view: прежние
«ежедневный снимок»/«на дату снимка» стали бы неправдой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:46:36 +05:00
635381f7b8 fix(tests): красный main — тесты #3466 читали настройки, которые #3556 перевёл в константы
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 8m23s
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
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
Семантический конфликт двух PR, смерженных 17.09 подряд. Текстового конфликта не
было, CI каждого PR был зелёным на своей голове, а объединённое дерево падало:

  test_3466_corridor_tier_a.py (2), test_estimator_radius_floor.py (2)
  AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
  TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'

#3554 (Tier A и advisory_only) писал тесты против settings.estimate_corridor_clamp_*,
а #3556 (#2380) убрал эти поля из Settings в константы CORRIDOR_CLAMP_SLACK
(estimator) и CORRIDOR_CLAMP_MIN_N (app.core.config) и снял параметр
radius_floor_factor у хелпера. Продуктовый код не затронут: в app/ старые имена
остались только в комментарии, на проде AttributeError не было. Но красный test
блокировал деплой МЕРЫ: на проде до сих пор образ до второй пачки мержей.

Тесты переведены на константы, значения прежние (0.40, 10, 0.8).
pytest tests/ — 6374 passed, 44 skipped, rc=0; ruff check/format — rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:45:54 +05:00
e3616e3611 Слить свежий origin/main в fix/houses-dedup (вместо rebase: ветка уже опубликована, push без --force)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m21s
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:45:03 +05:00
3b2f5ed642 МЕРА: срок экспозиции окном p25–p75 по снятым объявлениям и хранится с оценкой (#2898)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m32s
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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 3m18s
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.

Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
  площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
  квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
  INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
  est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.

Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:44:44 +05:00
aefbd993bb fix(tradein/ui): «только этот дом» только когда выборка не расширялась соседями (#2583 M2)
Some checks failed
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) Failing after 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».

Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
b9c89641d1 fix(tradein/gar): дом получает ГАР-guid только своего региона (#2583 H5)
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.

Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
a4e97c4b70 fix(tradein/estimate): город развёртки попадает в городской фильтр коридора сделок (#2583 H1)
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.

Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00
3b9742c60d fix(tradein): вечно недогружаемые карточки Яндекса больше не держат очередь добора (#3191)
Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 8m10s
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 34s
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
С 13.09 21:14 по 16.09 18:52 пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Счётчика попыток на объявлении не было,
они возвращались в голову каждого снапшота и пятью недогрузами подряд выбивали
брейкер: 16 прогонов закончились ровно attempted=5 incomplete=5.

Миграция 321: listings.detail_incomplete_count / detail_incomplete_at.
yandex_detail_backfill пишет недогруз на объявление; снапшот не берёт карточку
сутки и не берёт совсем после трёх недогрузов (счётчик incomplete_given_up);
повторный недогруз уже известной карточки не двигает брейкер, первый — двигает,
защита от системного недогруза сохранена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:25 +05:00
90b93b90b5 Merge origin/main в fix/proxy-pool-guards (#3299)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
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) Failing after 5m54s
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:39:38 +05:00
fc5735fd6d Merge origin/main в fix/domclick-card-fields
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
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) Failing after 5m46s
Конфликт только в tradein-mvp/backend/tests/skip_allowlist.txt: обе стороны
дописали блок в одно место (#3252 — тесты апсерта карточки ДомКлика, #3385 —
миграция 310). Оставлены оба блока. Номер миграции ветки 320 не пересекается
с main (максимум там 310).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:38:58 +05:00
a0522b9a86 fix(tradein/dedup): миграция 311 не встаёт в очередь за загрузкой ГАР — lock_timeout перед REINDEX (#1772)
CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX
gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN.

REINDEX оценён замером на проде 17.09 (только чтение):
- gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный
  (flat_count > 0), 201 513 строк, 8,8 МБ;
- эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько
  таблица закрыта для записи и для планировщика любого запроса к ней; читают её только
  ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет;
- шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не
  меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные
  до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР»)
  было неверным: новые строки и так получают ключ нового тела.

REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но
не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in
transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew,
тело функции коммитится раньше индекса.

Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит
ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело
функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный:
DID NOT RAISE — миграция дождалась загрузчика.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:28:55 +05:00
9f6701d3a5 Бэктест берёт тип дома из справочника домов, как боевая оценка (#2862)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
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) Failing after 5m14s
deals.house_type пуст у всех 434 911 сделок Росреестра (прод 17.09), поэтому
в прогоне с --resolve-house-id штраф за несовпадение типа дома в отборе
аналогов не срабатывал ни разу. Боевой estimate_quality с #3234 подставляет
тип из houses (по резолвленному house_id, иначе ближайший дом в 60 м), когда
его нет в форме; бэктест этого не повторял.

Теперь при --resolve-house-id пустой тип сделки дозаполняется тем же
_lookup_house_facts, свой тип сделки главнее. Счётчик house_type_from_houses
в блоке house_id_resolution показывает, скольким сделкам тип подставлен.
Год и этажность из houses намеренно не берутся: они сдвинули бы метрики по
другим признакам и смешали бы замер эффекта типа дома. Без флага вывод прежний.

Основная часть issue (код материала стен Росреестра -> тип дома) не сделана:
нужен справочник 126-УНСИ и решение владельца по соответствию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:28:39 +05:00
a130303cc9 Merge pull request 'Сайдкар МЕРЫ: зона браузера по IP узла, якорь Домклика без Яндекса, автологин Циана через пул, время навигации в логах' (#3563) from fix/browser-sidecar into main
Some checks failed
Deploy Trade-In / build-browser (push) Successful in 2m54s
Deploy Trade-In / build-frontend (push) Successful in 3m49s
Deploy Trade-In / test (push) Failing after 5m1s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 2s
Deploy Trade-In / changes (push) Successful in 15s
2026-09-17 09:24:11 +00:00
10c80e9e1d Merge pull request 'Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой' (#3562) from fix/kit-providers-config into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:23:57 +00:00
1e99d91afe Merge pull request 'Сбор МЕРА: упавший прогон не висит zombie 6 часов, провалившаяся фаза не попадает в чекпоинт, проверка отмены не держит транзакцию часами' (#3561) from fix/kit-orchestration into main
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 / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (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-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:23:42 +00:00
e52057e66d Merge pull request 'Авито: добор карточек больше не сжигает 1600 попыток на упавшем сайдкаре и везде ходит браузером' (#3560) from fix/avito-detail-backfill into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:23:20 +00:00
a672178bcb Merge pull request 'Оценка: дедуп склеивает улицы вида «40-летия Комсомола», пороги Tier H — в настройки для бэктеста' (#3552) from fix/estimator-analog-quality into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:23:05 +00:00
a9e5322b7a Merge pull request 'Оценка: при якоре того же дома видно, что коридор сделок цену не ограничивал' (#3554) from fix/corridor-advisory-tier-a into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:22:46 +00:00
0be4d91d30 Merge pull request 'МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970' (#3556) from fix/estimator-flag-collapse into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:22:26 +00:00
6a6fc7031d Merge pull request 'Алерты GlitchTip: медленный отказ Telegram отвечает 502 и уходит в фон, а не обрывается на 10-й секунде' (#3555) from fix/glitchtip-delivery into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:22:08 +00:00
6fe231c101 fix(ptica): «покрытие P% (Y из N комплексов)» — N одно и то же во всех ветках (#2464)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m55s
CI / backend-tests (pull_request) Successful in 7m16s
CI Trade-In / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 30s
best_layouts.py:1113 (эпик #2464). Поле objects_total_in_radius заполнялось
тремя ветками по-разному: ранний пустой ответ («фильтр никого не оставил»)
отдавал число комплексов ДО exclude/filter, ветка «нет velocity» и штатная —
ПОСЛЕ.

Смысл выбран по потребителям, а не по имени поля: BestLayoutsBlock.tsx и
layout_tz_pdf.py печатают его только как N в «покрытие P% (Y из N
комплексов)», а P в штатной ветке считается по отфильтрованным. Число до
фильтра сделало бы строку внутренне противоречивой («100% (1 из 2)»).
Поэтому ранний пустой ответ приведён к len(complex_groups); число obj_id до
фильтра по-прежнему отдаётся в raw_objects_total. Штатная ветка не менялась.

Тест проверяет поле во всех трёх ветках при filter_competitor_obj_ids;
два старых теста, закреплявших число до фильтра в пустом ответе, обновлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:21:51 +05:00
99f8525b1c Merge pull request 'Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки' (#3551) from fix/lead-funnel into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 1m12s
Deploy Metrics / agent-apps (push) Successful in 36s
Deploy Metrics / agent-infra (push) Successful in 33s
2026-09-17 09:21:44 +00:00
57b03092ea Merge pull request 'Витрина лендинга: пустой пересчёт больше не считается успешным, а итог игры «Проверьте себя» говорит, что квартиры отобраны' (#3550) from fix/landing-showcase-and-game into main
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 / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-09-17 09:21:02 +00:00
2b3602364a Merge pull request 'МЕРА: в истории цен Яндекса больше нет выдуманных «прошлых цен» в 10 и 1000 раз ниже' (#3564) from fix/yandex-price-decimal-slip into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 27s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-09-17 09:17:27 +00:00
404b583682 Merge pull request 'МЕРА v2: клик по «Предыдущим оценкам» после отправки открывает выбранную оценку' (#3549) from fix/v2-history-click into main
Some checks failed
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 23s
2026-09-17 09:17:00 +00:00
af5eed19f9 Merge pull request 'Бэкапы: образцы настроек ведут алерты в тему «Metrics», неработавший сторож доступности удалён' (#3559) from fix/backup-notify-topic into main
All checks were successful
Deploy / build-backend (push) Successful in 3m27s
Deploy / build-worker (push) Successful in 5m9s
Deploy / build-frontend (push) Successful in 6m7s
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / deploy (push) Successful in 1m42s
Deploy / deploy-status (push) Successful in 5s
Deploy / changes (push) Successful in 17s
Deploy / perimeter-smoke (push) Successful in 1m51s
Deploy / deploy-caddy (push) Has been skipped
2026-09-17 09:16:45 +00:00
d9a5bfe71f Merge pull request 'ПТИЦА: у изъятий на участке появляется номер постановления, а таблица не удваивается' (#3568) from fix/ptica-reservation-act-number into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-09-17 09:16:07 +00:00
b2394b0a0f Merge pull request 'Каталог DOM.РФ в beat: вместо обещания «вернуть после cooldown» — факт о блокировке StormWall и тест, который не даст включить молча' (#3566) from fix/ptica-domrf-waf-probe into main
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-09-17 09:15:47 +00:00
1812a1ed18 Merge pull request 'Метрики: правки prometheus.yml, конфига Loki, датасорсов Grafana и запросов postgres-экспортёров вступают в силу после деплоя' (#3557) from fix/metrics-deploy-reload into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy Metrics / agent-apps (push) Successful in 31s
2026-09-17 09:15:18 +00:00
d087e3076d Merge pull request 'Мера: тревога, когда бэкенд лёг, а лэндинг ещё открывается из кэша' (#3553) from fix/mera-observability into main
Some checks failed
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
Deploy Metrics / server (push) Has been cancelled
2026-09-17 09:15:04 +00:00
d551cfbab9 test(tradein): объявить пропуски новых live-тестов защиты пула (#3299)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
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 5m32s
Четыре live-теста из правок по ревью без БД пропускаются, а гейт conftest
роняет прогон с DSN-заглушкой на необъявленных пропусках (rc=1 при
«6226 passed» в сводке). Внесены в skip_allowlist.txt к остальным тестам #3299.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:10:03 +05:00
452a0368ce Merge origin/main в fix/proxy-pool-guards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:02:38 +05:00
29ac3043b4 fix(tradein/proxy_pool): защита бана не считает узел с истёкшей арендой, условия резерва проверены по значению (#3299)
Ревью PR #3565: у нового предиката резерва две части не проверялись ни одним
тестом. Снятие `other.expires_at` из подзапроса acquire и снятие
`other.consecutive_fails` из подзапроса mark_banned оставляли прогон
test_3299 + services/test_proxy_pool.py зелёным (72 passed). Проверено тем же
способом, и нашлась третья такая часть: `other.expires_at` в mark_banned.

Добавлено по live-тесту на каждое условие: просроченный резерв в acquire,
резерв в карантине и просроченный резерв в mark_banned.

Попутно найден дефект рядом. Внешний отбор mark_banned («есть ли у источника
другой узел») срок аренды не проверял, хотя acquire такой узел не выдаёт, а
докстринг обещает «тем же правилом, что acquire()». Если у источника остались
только просроченные узлы, бан уходил последнему живому, и источник оставался
без прокси, пока healthcheck не наберёт просроченным узлам отказов. Добавлена
проверка `sp.expires_at` и тест на этот случай.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:02:32 +05:00
2639d2a7e7 Merge remote-tracking branch 'origin/main' into fix/kit-orchestration
All checks were successful
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
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 6m11s
2026-09-17 13:59:43 +05:00
0327af6fab Merge remote-tracking branch 'origin/main' into fix/ptica-domrf-waf-probe
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Successful in 2m1s
CI / openapi-codegen-check (pull_request) Successful in 4m0s
CI / backend-tests (pull_request) Successful in 8m21s
2026-09-17 13:59:43 +05:00
69e7047398 Тесты гейта чекпоинта: частичный отказ фазы, порог в одну попытку, упавший запрос домов (#3415)
Ревью PR #3561: три мутации гейта оставались зелёными — порог `attempted >= 2`,
жадный гейт `failed >= 1` и снятый `houses_attempted += len(nb_id_list)` в ветке
«houses DB query failed». Добавлены проверки по чекпоинту:
- 1 из 2 домов отказал в каждом якоре — оба якоря в чекпоинте (контроль жадности);
- единственный дом якоря отказал — якорь не в чекпоинте (порог);
- упал запрос домов — якоря не в чекпоинте, houses_attempted == houses_failed == 2.

Код не менялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:58:43 +05:00
7cd15aab71 Отказ ручного сбора каталога DOM.РФ больше не советует ждать cooldown (#2443)
Ревью PR #3566: обещание таймера, убранное из beat, осталось в тексте
HTTP 400 эндпоинтов /admin/scrape/kn-catalog-objects и /kn-catalog-flats.
Отказ предлагал передать i_understand_waf_risk=true, «если WAF cooldown
прошёл», то есть подсказывал оператору обойти блокировку, которую ожидание
не снимает (зонды 20.08-01.09: StormWall, «Доступ заблокирован [403]»).

Теперь отказ называет StormWall и реальное условие: прокси и kn-прогон,
принятый по числу строк (#3307). Константа переименована в
_DOMRF_BLOCK_GUARD_MSG. Докстринги эндпоинтов и задач
scrape_kn_catalog_flats/objects больше не говорят про WAF cooldown и про
«вторник 04:00 UTC» у выключенной записи с расписанием в МСК.
api-types.ts перегенерирован как в CI-гейте openapi-codegen-check,
изменились только два докстринга.

Тесты отказа проверяют значение detail: #3307 есть, cooldown нет.
Поведение guard'а (400 без флага, задача не ставится) не менялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:58:31 +05:00
2e4a82dd46 Упавший прогон финализирует общий runs.mark_crashed: и ручные запуски из админки, и без дубля алерта (#1940)
Ревью PR #3561, две находки.

1. Ручные запуски из админки (avito/cian city sweep, cian_full_load,
   yandex_full_load, yandex_city_sweep) идут мимо scheduler._dispatch: в except
   был только logger.exception, и отказ «пул пуст» до try-финализатора пайплайна
   оставлял строку running до zombie. Логика финализации переехала из
   планировщика в runs.mark_crashed; её зовут планировщик и все пять ручек.

2. Обычный путь пайплайна — mark_failed и raise; планировщик звал mark_failed
   второй раз. UPDATE — no-op, но _alert_on_run_id срабатывал снова с тем же
   стриком, и на вехе лестницы в Sentry уходил дубль (+ WARNING «no-op»).
   mark_crashed сначала читает статус и финализирует только running.

Тесты по значению над двойником строки scrape_runs и боевыми mark_*:
статус banned/infra или failed после планировщика и после каждой из пяти ручек,
одна отправка в Sentry на неудаче пайплайна.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:34 +05:00
f48a510e94 Merge remote-tracking branch 'origin/main' into fix/estimator-flag-collapse
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
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 6m36s
2026-09-17 13:57:23 +05:00
91a075bc81 МЕРА: снимок фикстуры бэктеста снова знает пороги движка (#1970)
Ревью #3556: settings_at_capture в _write_fixture писал только поля estimate_*
из Settings. После переноса 31 порога в константы estimator.py будущая фикстура
не знала бы, с какими порогами её захватили. Теперь в снимок попадают и
числовые константы модуля (63 штуки, CORRIDOR_CLAMP_MIN_N в том числе).
replay_fixture снимок по-прежнему не читает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:07 +05:00
228c5f58f9 МЕРА: тест якоря «тот же дом» проверяет веса на месте вызова (#2381)
Ревью #3556: подмена rooms_boost=1.0 на месте вызова _compute_same_building_anchor
проходила весь сьют — тест значений держит только число в константе. Новый тест
оборачивает расчёт якоря и требует боевые sigma/rooms_boost/floor_sigma/min_comps/
mad_k (прод 17.09.2026). SB_GUARDRAIL_TOL, FSD_K, SB_GATE_MAX_FSD и сдвиг
ANCHOR_TIER_C_CORRIDOR_MULT вниз по-прежнему без поведенческих тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:05 +05:00
bc7fe9b0b9 МЕРА: тесты квартального индекса ловят пороги на месте использования (#2379)
Ревью #3556: тест значений держит число в константе, но не то, что код её читает.
Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и
bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index —
оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0.
Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда.

- оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается;
- границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет;
- заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:57:04 +05:00
2cfc2eda08 МЕРА: переименования порогов во фронте откачены — деплой без пересборки фронта (#1970)
Три правки в комментариях tradein-mvp/frontend (имена estimate_corridor_clamp_min_n
и estimate_imv_blend_weight → константы) кода не меняли, но фильтр deploy-tradein
по tradein-mvp/frontend/** пересобирал и пересоздавал tradein-frontend ради
комментариев. Комментарии переименуются вместе с ближайшей правкой фронта
(в #3554 фронт и так пересобирается — переименование приложено к нему патчем).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:56:48 +05:00
c7a03495e2 Свип ДомКлика: корзина без сохранённых строк не попадает в чекпоинт (#2406)
Ревью PR #3561: тест таймаута фазы закреплял как контракт done_buckets == ['st'].
Лоты ДомКлика копятся в памяти и пишутся одним save_listings после всех корзин,
поэтому снятая watchdog'ом (или упавшая на save) фаза не сохраняет ничего, а
чекпоинт всё равно получал completed_buckets живого скрейпера — следующий прогон
пропускал корзину навсегда (механизм миграции 308).

Чекпоинт пополняется только если фаза дошла до конца (флаг _saved после save).
Тест развёрнут: при таймауте fetch_city и при падении save_listings
done_buckets == [], статус failed. Контроль «полный проход несёт
унаследованное ∪ пройденное» — test_3369.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:54:55 +05:00
3992d7546f Merge origin/main в fix/estimator-analog-quality
All checks were successful
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 5m24s
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:51:04 +05:00
cbf0f6dec6 docs(mera/estimator): ручки Tier H временные — вернуть в константы после бэктеста (#3234)
Ревью #3552: направление расходится с #3556/#1970, где неменявшиеся
estimate_*-настройки переводят в константы движка. Две новые ручки нужны
только для бэктеста вариантов; условие возврата записано рядом с полями,
чтобы его увидел тот, кто будет делать шаг 4 #3234.

Refs #3234

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:50:49 +05:00
45017d2535 fix(mera/estimator): дедуп различает порядковые улицы «2-я Синичкина» во всех трёх форматах (#2291, класс 4)
Что было. После «40-летия Комсомола» остались однобуквенные порядковые:
регексы требовали после «N-» минимум две буквы. Одна улица в трёх форматах
разбиралась по-разному: cian «улица 2-я Синичкина» давал ('', ''),
avito/yandex «2-я ул. Синичкина» и domklik «2-я Синичкина улица» теряли номер
('синичкина'). Потеря номера — это ложная склейка: «9-я Парковая, 5» и
«12-я Парковая, 5» получали один ключ ('парковая', '5').

Что сделано. В обоих регексах названия улицы добавлен необязательный
порядковый «N-x » перед (нумерованным) именем; формат «номер тип имя»
переставляется в «тип номер имя» до разбора. Номер дома с дефисом не тронут.

Замер на проде (tradein-postgres, SELECT, 17.09): 12 848 разных адресов с
шаблоном «цифра-дефис/пробел-буква», 29 959 объявлений. Разбор изменился у
8 713 объявлений; ни один адрес не потерял токен. Межисточниковых групп
(улица, дом) 1305 -> 1473. Из 81 прежней группы, которая распалась, 60 —
разные порядковые улицы, 3 — ложный mkr-ключ, 18 — голая и порядковая
улица в разных городах (одна — источник сам потерял номер). Гейт бэктеста
без изменений baseline.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:50:31 +05:00
8bcb025c0d Merge origin/main в fix/corridor-advisory-tier-a
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m36s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 1m38s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:46:11 +05:00
1a9a612a82 test(mera/estimator): тесты дедупа #2291 не трогают флаг, который #3556 удаляет
Два новых теста класса 4 ставили monkeypatch.setattr(settings,
"estimate_dedup_analogs_enabled", True). PR #3556 (#2378) удаляет это поле из
Settings, и setattr по несуществующему атрибуту падает AttributeError. CI
гоняет голову ветки, а не merge-ref, поэтому второй смерженный PR пришёл бы
зелёным и уронил main. На main флаг и так ON по умолчанию
(test_dedup_default_is_on), так что строки лишние при любом порядке мержа.

Проверено: дифф PR, наложенный на голову #3556 (31c31f8f), до правки
2 failed / 144 passed (AttributeError), после 146 passed. Тесты не стали
пустыми: откат префиксного регекса без флага даёт assert 4 == 1 и 1 == 2.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:44:45 +05:00
803ea2217b test(tradein-front): подпись коридора в v1 HeroSummary стережётся рендером (#3466)
Ревью #3554: вызов corridorAdvisoryNote в HeroSummary (корневая /trade-in)
тестом не покрывался — мутация F2 убирала аргумент estimate.analog_tier,
vitest оставался зелёным (305 passed). Покрыт был только v2 mappers.

Рендер HeroSummary по образцу thinMarketBadge.test.tsx: n=20,
advisory_only=false; same_building → подпись про дом в тексте hero,
micro_radius → подписи нет. Мутант F2 краснеет: «expected 'Секция 1 ·
Сводка…' to contain 'справочно: цена посчитана по аналогам…'».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:44:36 +05:00
49bcfdacd6 test(tradein): граница radius-floor при n == порогу стережётся (#3466)
Ревью #3554: замена `>=` на `>` в гейте radius-floor оставляла
test_estimator_radius_floor.py зелёным — в тестах были только count=15 и
count=min_n−1. При n == min_n витрина не пишет «справочно»
(advisory_only=False), значит floor обязан поднять цену.

Новый тест: n = estimate_corridor_clamp_min_n, медиана 80k → ровно 120k,
плюс проверка, что DkpCorridor на той же границе не справочный. Мутант
`>` краснеет: «коридор из 10 сделок (ровно порог) должен поднять цену
ровно до floor=120000».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:42:57 +05:00
6e8c261ff8 fix(tradein/domclick): собственники, нулевые площади и жилая площадь карточки перестали теряться (#3252)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
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 4m55s
Сверка парсера с живым __SSR_STATE__ трёх карточек (17.09) нашла три тихие дыры:

1. owners_count. egrnData отдаёт поля ЕГРН обёрткой {status, value}; int() от
   словаря молча давал None. Прод: 0 из ~2970 карточек, обогащённых с 29.08,
   против 5075 из 6296 у ручного прогона 18.07, который обёртку разворачивал.
   Ключ egrnData.area подтверждён — гадания rosreestrArea/object_area удалены,
   в raw_payload обёртка остаётся как есть (status = сверка с объявлением).
2. livingArea/kitchenArea = 0 у Домклика значит «не указано», а в колонку ложился
   0.00 и затирал известное значение. Прод: 303 кухни и 84 жилые площади = 0,
   только domklik. Разбор: _pos_float; строки: миграция 320 ставит NULL.
3. Жилую площадь и балконы стирал переобход выдачи: апсерт scraper_kit.base писал
   их сырым EXCLUDED, а SERP Домклика и Авито этих полей не отдаёт. Прод: domklik,
   переобойдённые после обогащения, living_area_m2 0 из 5217 (без переобхода
   2990 из 4058); avito 1 из 8639 (4122 из 6505). COALESCE в SET, в гейте #2992
   и на reconcile-пути.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:25:38 +05:00
7501fef5c3 fix(ptica): номер акта в land_reservation извлекается у постановлений Администрации ЕКБ (#2982)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 6m44s
Регекс номера требовал суффикс областных актов (-ПП/-ПА/-РП/-ПГ/-ГП/-МО),
а у постановлений Администрации Екатеринбурга его нет: на проде act_number
пуст у всех 27 строк (17.09.2026).

- izyatie_ocr: номер берётся у того же акта, чью дату выбирает
  _extract_act_date (вплотную после «от DD.MM.YYYY», иначе перед ней).
  Первое «№» в теле — «Решение Думы № 60/1» или «Приказ № 746-П».
- page_reservation_parser: суффикс необязателен; номер с суффиксом не из
  списка («218-ФЗ», «746-П», «60/1») отбрасывается целиком, а не обрезается.
- izyatie_ocr_ingest: перед записью удаляется прежний разбор того же
  участка из того же документа с другим номером. act_number в ключе
  конфликта, без этого прогон положил бы 27 строк с номером рядом с 27
  строками без номера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:24:06 +05:00
9cc23c6698 Выключить домклик-свипы 77/50 до инкрементального сохранения (#3567)
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 4m5s
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 7m52s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m43s
2026-09-17 08:23:05 +00:00
47a91acf61 test(tradein): объявить пропуски live-тестов пула прокси (#3299, #3310, #3404)
All checks were successful
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 / changes (pull_request) Successful in 15s
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 5m47s
Без БД 11 новых live-тестов пропускаются, и гейт conftest роняет прогон
(«НЕУЧТЁННЫЙ ПРОПУСК», rc=1 при «6226 passed» в сводке). В ci-tradein.yml они
идут по-настоящему: локальный прогон на Postgres со схемой из миграций —
6278 passed, 1 skipped, rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:14:01 +05:00
b2ff92afbb beat: каталог DOM.РФ выключен из-за блокировки StormWall, а не «до cooldown» (#2443)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m45s
CI / backend-tests (pull_request) Successful in 6m59s
Комментарии у закомментированных записей scrape-kn-catalog-objects-weekly и
scrape-kn-catalog-flats-weekly обещали «возврат после cooldown 24-48h
(проверить через targeted test)». Тест проведён 20.08, 27.08 и 01.09:
кулдауна нет (kn-прогоны 29-33 шли в июне, уже после «бана» 24.05), с 01.09
наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Комментарии теперь называют факт и условие включения (прокси и принятый
kn-прогон, #3307).

Решение больше не живёт только в комментарии: тест проверяет по значению
(по task, не по ключу), что ни одна из двух задач не попала в собранное
beat-расписание, и в тексте падения ведёт в #3307.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:09:44 +05:00
4961128f87 fix(scraper-kit/cian): детальная Циана на своей сессии — пул прокси не на event loop (#3408 п.3)
fetch_detail без session/browser_fetcher входил в синхронный curl_proxy_url:
acquire/mark_health/release RealProxyProvider'а ходят в БД прямо на loop'е.
Вызывающий — админ-ручка истории цен Циана в публичном tradein-backend
(один воркер uvicorn): пара блокирующих вызовов на каждый листинг батча.
#3398 перевёл так три сайта /estimate, этот остался.

Теперь async with acurl_proxy_url: вход и выход в потоке, contextvars
(current_run_id) копируются, ProxyBanError/CianBlockedError изнутри блока
доходят до пула как раньше (test_2700/test_3402 зелёные). Попутно потолок в
докстринге acurl_proxy_url: pool_timeout теперь 5 с (#3444), не 30.

Тест по образцу test_3398: acquire спит 0.3 с, соседняя корутина тикает.
На синхронном входе — «тиков всего 0».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:04:54 +05:00
e83742ac41 МЕРА: yandex-история больше не сеет цену на порядок ниже/выше текущей (#3385)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 21s
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 5m40s
Все 7 скачков ×10 в yandex-истории цен на проде (17.09) — не «сдвиг на следующем
наблюдении», как предполагал issue, а синтетический посев из gate-поля
price.previous: нижняя точка стоит ровно на 24 ч раньше второй и записана тем же
батчем. Ошибочна нижняя цена (13–37 тыс/м² против 133–375 у верхней). Ещё 4 посева
того же рода — ×175…×1000 (цена в тысячах). Ветка писателя «история есть» на проде
не записала ни одной строки: смену цены раньше неё пишет триггер
record_listing_price_change, поэтому отлов «на следующем наблюдении» там поймал бы 0.

- yandex_price_history: price.previous, отличающаяся от текущей цены в 9.5 раза и
  больше в любую сторону (или не > 0), не сеется; счётчик seed_rejected в логе.
- миграция 310: удаляет yandex-посевы по подписи батча (recorded_at совпадает,
  change_time второй точки ровно +24 ч) с тем же порогом; lock_timeout 5s,
  остановка при > 30 строк; на проде ровно 11 кандидатов, повторный прогон — 0.
- тесты по значению: посев отвергнут/сохранён на прод-ценах; настоящий файл
  миграции на живом Postgres через временную таблицу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:04:02 +05:00
ab003877dc fix(tradein/proxy_egress): прогон на egress-пути тоже знает свой узел (#3404)
PR #3405 писал scrape_runs.proxy_id только из proxy_pool.acquire(). Прогоны,
которые берут прокси через proxy_egress.resolve_proxy_url без аренды
(yandex_detail_backfill, yandex_address_backfill, curl-ветка
avito_detail_backfill), атрибуцию не получали: прод 17.09 —
yandex_detail_backfill 0 из 56, yandex_address_backfill 0 из 1.

resolve_proxy_url при выбранном узле и выставленном current_run_id зовёт
attribute_run_proxy. Своей короткой сессией: db вызывающего — долгоживущая
сессия прогона посреди работы, а атрибуция коммитит и на сбое откатывает.

Тесты на живом Postgres: атрибуция внутри прогона (proxy_id и
counters.proxy_ids), вне прогона scrape_runs не тронут, незакоммиченная
работа вызывающего не коммитится. Красные прогоны: на старом коде
«assert None == 70»; с атрибуцией через db вызывающего — «атрибуция
закоммитила чужую транзакцию».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:03:22 +05:00
611878df4c fix(scraper-kit/proxy): бан на curl-пути не копит счётчик отказов узла (#3310)
Защита последнего узла в mark_banned бережёт узел от бан-строки и пишет
«узел продолжит выдаваться», но curl_proxy_url на том же ProxyBanError
вслед за баном звал mark_health(ok=False). Три ответа 403/капчи подряд —
consecutive_fails = 3, и acquire отсекал узел для ВСЕХ источников: пул
пуст при живом узле. Браузерный путь это исправил в #3288
(report_platform_ban), curl-путь — нет; через него ходят cian detail,
ЖК-резолв, история цен Циана и оценщик.

Теперь на ProxyBanError зовётся mark_banned вместо mark_health(False).
Транспортные сбои по-прежнему засчитываются узлу.

Тест на живом Postgres настоящим трактом (curl_proxy_url →
RealProxyProvider → proxy_pool): единственный узел, три CianBlockedError →
consecutive_fails == 0, acquire('cian') выдаёт узел; на старом коде
«assert 3 == 0». Пять старых проверок ждали mark_health(False) на бане —
они фиксировали дефект, поправлены.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:01:24 +05:00
1a773554db Сайдкар: в логах /fetch появилось время целевой навигации — замер хвоста Циана (#3419)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 18s
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 / browser-tests (pull_request) Successful in 1m47s
CI Trade-In / backend-tests (pull_request) Successful in 5m54s
Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался
на DEBUG и без длительности, «fetch error» — тоже без неё, а время из
access-лога включает пейсинг (18 с у cian) и ожидание лока. За сутки до 17.09
у cian 54 TimeoutError на ~1350 страниц (90 recycle × 15), ~4 %.

Теперь _fetch_once меряет только целевую page.goto (monotonic, в finally —
и на таймауте), обе строки несут nav_ms, «fetch OK» переведён на INFO.
nav_ms=None — до цели не дошли (упал прогрев origin), прошлое число не
наследуется. Решение по порогу — после 48 ч данных, это отдельный шаг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:00:23 +05:00
92295971fe fix(tradein/proxy_pool): резерв выделенного узла считает узлы 'any' (#3299)
Запасной заход acquire() и защита последнего узла в mark_banned() спрашивали,
есть ли у выделенной привязки ВТОРОЙ узел той же привязки. Выделенный узел
штучный, поэтому ответ почти всегда «нет», и такой узел не выдавался никому,
хотя свой источник обслуживали 'any'-узлы. Прод 17.09: узел 15 (avito)
свободен и здоров, а cian/domclick его не получали; 30.08 так лёг добор
Домклика (прогоны 5449-5459).

Теперь резерв — любой узел той же привязки или 'any', здоровый
(consecutive_fails), с живой арендой порта и не забаненный источником
выделенной привязки. Предикат одинаковый в обоих местах. leased_by
намеренно не проверяется: аренда временная.

Тесты на живом Postgres (tests/test_3299_*): 6 случаев из приёмки, два
красные на старом SQL. Мок test_proxy_pool.py: маршрутизация primary/fallback
по полному фрагменту IN (:provider, 'any') и гейт нового предиката.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:59:44 +05:00
4549311720 Автологин в Циан идёт через узел пула, а не через мёртвый env-узел сайдкара (#3410)
/login сайдкара не принимал proxy из тела (_no_live_proxy(provider, None),
_ensure_browser без override), BrowserFetcher.login его не клал, а ручка
cian auto-login сознательно не брала провайдер. Итог: логин всегда шёл с
SCRAPER_PROXY_URL сайдкара — на проде это выключенный узел 9 (InvalidIP),
то есть ручка восстановления cian-сессии не работала вовсе.

- сайдкар: /login берёт proxy из тела, как /fetch (guard и _ensure_browser
  с override; crash-relaunch в _do_login сохраняет _launched_proxy);
- scraper-kit: login() кладёт в тело узел текущей аренды, как _post_fetch;
- backend: ручка передаёт _kit_proxy_provider(), как debug-карточка DomClick;
  пустой пул на проде — 503 «нет свободного узла для cian», а не 502.

Тесты #3197 про «логин без пула» переписаны на обратные утверждения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:58:03 +05:00
808a4fb4cb refactor(tradein): IMV Авито строит curl-сессию общей фабрикой, а не своей копией (#2386)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m55s
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
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
avito/imv.py сам собирал curl_cffi AsyncSession с третьей копией
document-заголовков (_DOC_HEADERS) — из-за того, что parity-тесты патчили
curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля и
такой патч до него не долетает. Взят вариант B из issue: в кодовой базе уже
принят патч scraper_kit.providers._base.AsyncSession (test_scraper_proxy.py,
test_pipeline_browser_routing.py, test_kit_serp_proxy_pool.py), а вариант A
(живой lookup в _base) сломал бы эти тесты — атрибута _base.AsyncSession не стало бы.

Сессия теперь build_document_session(proxy_url, timeout=25); _DOC_HEADERS
удалён (идентичен DOCUMENT_HEADERS, проверено до правки), мёртвый
try/except ImportError убран — curl_cffi и так импортируется через _base.
Тесты патчат _base.AsyncSession; ассерт с config дополнительно фиксирует
impersonate, timeout=25 и заголовки — параметры сессии не изменились.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:57:29 +05:00
d2a31dff08 Тесты kit-свипов: таймаут якоря, дрейн и отмена посреди обхода у всех свипов, а не только у Авито (#2406)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
CI Trade-In / 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 / changes (pull_request) Successful in 15s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
После удаления legacy-пайплайна эти сценарии были заассерчены только у avito
(test_3319) и дрейн до первого якоря у yandex/cian (test_3333). Добавлено:
- таймаут якоря у cian и yandex: якорь не в чекпоинте, следующий обработан,
  errors_count растёт; таймаут фазы у domclick: завершённые корзины в чекпоинте,
  статус failed, а не done;
- SIGTERM-дрейн после первого якоря у cian и yandex: interrupted=1, в
  чекпоинте ровно первый якорь, второй не посещён;
- пользовательская отмена (is_cancelled=True) после первого якоря у avito,
  cian и yandex: второй якорь не посещён, чекпоинт на месте, без mark_done.

Проверка по строке прогона: фейковая БД мержит counters всех записей, как
jsonb-мерж в runs.py. Мутации (снятый continue/return/interrupted/errors_count
в pipeline.py) роняют ровно свой тест, 6 из 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:57:13 +05:00
eade88bc66 fix(tradein): config обязателен в newbuilding-провайдерах Циана и Яндекса (#2385)
fetch_newbuilding, resolve_cian_zhk_url_via_search, YandexNewbuildingScraper и
resolve_yandex_jk_slug принимали config=None и в этом случае строили
BrowserFetcher(endpoint=None) мимо фабрики, а резолвер Циана — сессию без прокси.
Все боевые вызовы config уже передают, но новый вызывающий без config молча ушёл бы
на env-узел сайдкара мимо пула — именно так потеряли 8 суток в #2767 и сломались
в #3197. Теперь config обязателен по сигнатуре, запасные ветки удалены, фетчер
строит только build_browser_fetcher.

Тесты: кейсы *_without_config (ждали endpoint=None) заменены одним — без config
TypeError до сети, фетчер и сессия не строятся; остальные тестовые вызовы
получили config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:55:32 +05:00
6be2bf9aaa fix(tradein/avito): серия отказов нашего тракта обрывает detail-добор статусом failed (#3439)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
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 5m12s
Прод: 7193, 7218, 7264, 7302 (15-16.09) и 6608/6631/6643/6662 (10.09) — по
1600 попыток, 1-10 карточек, ~1599 блоков, status='done', по 70+ минут
каждый. В те же окна в логах tradein-browser шторм «browser launch failed …
InvalidIP» и «/fetch 503». 6885 (13.09) — те же 1600 попыток при
ban_kinds {"infra": 1591}, но 'banned'.

Корень — регресс #3288/#3367: record_block(kind != platform) уходит в
record_failure(), то есть только в знаменатель окна. Числитель доли растёт
лишь на platform, safety-net смотрит на platform-серию, счётчика серии
infra нет вовсе. Серия отказов сайдкара любой длины не рвала прогон ничем,
а финализатор #3367 при одной карточке ставил 'done'.

- BlockRatioBreaker: infra_streak_limit и счётчик серии infra. Серию гасит
  любой ответ площадки — успех, её блок, 404. abort_reason() -> "infra_streak"
  (проверяется первым), abort_explanation() называет эту величину.
- avito_detail_backfill: порог = max_consecutive_failures (дефолт 25) — тот же
  разряд «отказ нашей стороны», что и отказы-не-блоки. На обрыве — mark_failed
  «тракт недоступен: N отказов … подряд без единого ответа площадки», в
  counters abort_reason=infra_streak и ban_kinds. Не 'banned' — площадка
  молчала; не через mark_backfill_finished — там одна карточка даёт 'done'.

Тесты по значению: 1 успех + 1599 отказов сайдкара -> attempted=26,
mark_failed, abort_reason=infra_streak, ban_kinds {"infra": 25}; обрыв ровно
на 25-м (24 — нет); 24 infra + ответ площадки (успех/блок/404) + 24 — не
обрыв; 24 infra + ответ, трижды — снапшот пройден; 25% настоящих блоков —
не обрыв; прежний контроль #3367 (20 infra + 10 успехов -> done) зелёный.

Фальсификация: без проводки порога — «assert (1600, 1600) == (26, 26)»; без
сброса серии на блоке площадки — «abort_reason='infra_streak': 24 + ответ
площадки (platform) + 24»; без ветки финала — «прогон ушёл в
backfill-honest-status: там 1 карточка превращает шторм в 'done'».

Пункт 3 задачи (browser_unfit узлу, на котором сайдкар не поднял браузер)
не сделан: это вердикт пула прокси и протокол сайдкара, отдельная задача.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:18 +05:00
fb02e41d77 Проверка отмены закрывает свою транзакцию: свип ДомКлика больше не держит горизонт vacuum на весь обход (#3480)
Виновник по живому снимку pg_stat_activity 17.09 07:48 UTC: pid 1933154 из
tradein-scraper, 'idle in transaction' 1 ч 35 мин, последний запрос
`SELECT status FROM scrape_runs WHERE id = $1` (runs.is_cancelled),
xact_start через 30 мс после старта domclick_city_sweep_moskva 7344.
По Prometheus за 03-17.09 10 из 12 окон «транзакция > 1 ч» совпадают по
началу и концу со свипами ДомКлика.

is_cancelled делал SELECT без commit, а вызывающие сразу уходят в сетевую
фазу (у ДомКлика — весь fetch_city, до 3 ч). Теперь commit после чтения —
в общей точке для всех 12 вызовов; незакоммиченных записей, рассчитанных на
откат, перед вызовами нет (перед каждым — update_heartbeat/save_listings с
commit или чтения).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:04 +05:00
6c5f327acd Сайдкар: якорь Домклика открывает свою выдачу напрямую, без захода через поиск Яндекса (#3263)
Выдача Яндекса капчит конкретные выходные узлы пула (замер владельца 29.08:
узлы 1/10/11 — капча, 9 и домашний IP — выдача), поэтому путь через поиск
срабатывал ~2 раза из 9 и стоил ~15 с на подъём якоря. В логах tradein-browser
15-16.09 все 7 попыток — капча, и все 7 через 0,2 с заканчивались «якорная
вкладка не поднялась (Error)». Referer карточке и так даёт переход с выдачи
площадки (detail.py: referer=origin).

Дефолт BROWSER_ANCHOR_VIA_SEARCH теперь пустой: путь через поиск остаётся,
но только по явному env. Тесты яндекс-пути включают его явно; новый тест
грузит модуль без env и проверяет, что якорь идёт прямым goto(origin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:53:04 +05:00
a6c86e88ea fix(tradein/avito): диагноз блоков ложится в counters любого финала добора, не только 'banned' (#3288)
Пункт 5 из комментария 05.09: ban_kind пишется только у banned-прогонов, у
done/failed диагноза в БД нет. Прод: 7193, 7218, 7264, 7302 — по 1600
попыток, ~1599 блоков, status='done', counters без ban_kinds; что это были
отказы сайдкара, а не площадки, видно лишь по косвенному признаку (мелкая
block_streak_histogram считает только platform-серии). mark_backfill_finished
кладёт перепись в counters только на ветке бана.

Задача теперь кладёт counters.ban_kinds сама перед любым финализатором
(mark_backfill_finished / mark_failed пустого пула). Статусы и колонка
ban_kind не тронуты — контракты #3196/#2764 как были.

Тест по значению: 20 отказов сайдкара + 10 успехов — counters, уходящие в
финализатор, несут {"infra": 20}. Фальсификация: без строки —
«assert None == {'infra': 20}».

Стоп-кран для infra-серии (вторая половина п.5) — отдельным коммитом #3439.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:51:44 +05:00
19b6081242 fix(tradein): режим detail-добора Авито одинаков во всех контейнерах образа backend; убрана мёртвая YANDEX_COOKIES_FILE (#3182)
Прод 17.09, printenv: AVITO_DETAIL_BACKFILL_USE_CURL=false у tradein-scraper
и true у tradein-backend/tgbot/browser. Источник true — хостовый
backend/.env.runtime:37; false задан только environment'ом сервиса scraper.
tradein-backend исполняет добор сам (ручка админки
scrape_avito_detail_backfill), то есть ручной прогон шёл curl'ом, который
QRATOR PoW не проходит.

- docker-compose.prod.yml: AVITO_DETAIL_BACKFILL_USE_CURL "false" у backend
  и tgbot — у всех сервисов образа backend одно значение (environment
  перекрывает env_file). tgbot добор не исполняет; строка там, чтобы правило
  «одно значение на образ» проверялось без списка исключений.
- Остальные флаги режима проверены на проде: SCRAPER_FETCH_MODE=browser и
  AVITO_IMV_USE_BROWSER_FETCHER=true одинаковы во всех четырёх контейнерах.
- yandex_cookies_file удалён из Settings: его не читал ни один модуль
  (только мок в test_scraper_proxy.py), файла /app/yandex_cookies.json нет
  ни в одном контейнере, куки Яндекса давно живут в БД (#3192).
  extra="ignore" держит старт при оставшейся переменной в .env.runtime.

Тест по значению: разобранный compose — у каждого сервиса образа backend
значение "false"; Settings с YANDEX_COOKIES_FILE в env стартует без поля.
Фальсификация: без строки у backend — «{'backend': None} != {'backend':
'false'}»; с возвращённым полем — «assert not True».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:50:20 +05:00
c871f804cf Сайдкар: таймзона браузера берётся из IP узла, а не жёстко МСК (#3187)
camoufox 0.5.5 при geoip=True кладёт координаты из GeoIP-записи exit-IP
безусловно, а timezone — через setdefault, поэтому ручной
config["timezone"]="Europe/Moscow" перебивал зону из IP. Узлы пула 13 и 15
(T2 NSK, UTC+7) и 14 (МегаФон Самара, UTC+4, RIPE 17.09) заявляли МСК при
сибирских/самарских координатах, а библиотека писала LeakWarning на каждом
запуске (4715 штук за 13 ч при 665 запусках). Ротация узла идёт внутри
подсети оператора, смена узла релончит браузер — зона с запуска не протухает.

Тест: в kwargs запуска нет ключей timezone/geolocation:* в config, geoip=True.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:37 +05:00
7218c2094c Бэкапы: образцы env ведут алерты в тему «Metrics», мёртвый uptime-сторож удалён (#3164)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 24s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 29s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m15s
CI / backend-tests (pull_request) Successful in 7m37s
Тема форума для уведомлений бэкапов задаётся только env-файлом на хосте, а в
образцах её не было вовсе. На проде она задана, но не та: 158 («алерты») в
/opt/gendesign/secrets/backup-notify.env и forgejo-backup.env на Beget и в
/etc/default/gendesign-backup на Poincare. По решению #3163 инфраструктура идёт
в 245 «Metrics». Значение на хостах этот коммит не меняет.

- ops/gendesign-backup*.default.example: строка #TELEGRAM_TOPIC_ID=245 с
  причиной и ловушкой: тема обязана лежать в одном файле с токеном и чатом,
  иначе notify() её не прочитает.
- ops/crontab-beget.cron сверен с живым crontab Beget: сторожа и бэкап волта
  получают BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env. Без него
  переустановка crontab из репозитория глушила бы алерты бэкапов на Beget.
- ops/uptime-healthcheck.sh и его образец удалены: скрипт не запущен ни на
  одном хосте (crontab, cron.d, таймеры), доступность сторожат uptime-мониторы
  GlitchTip на Beget (gendsgn.ru, /health, meraocenka.ru — раз в 60 с).

Тест исполняет настоящий check-backup-staleness.sh с образцом, заполненным
по инструкции, и проверяет адрес в вызове curl: message_thread_id=245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:33 +05:00
facdbb2566 Свипы Циана и Авито: якорь с полностью отказавшей фазой не попадает в чекпоинт (#3415)
cian_city_sweep 6179 (06.09) — failed по «фаза houses отказала полностью, 30 из
30», но в done_buckets все пять якорей. 6276 резюмировался от него, пропустил
все якоря и отчитался done с houses_attempted=0, lots_fetched=0.

Отметка «якорь пройден» опиралась только на поток управления, а фаза отказывает
и без исключения (fetch_newbuilding -> None, растёт счётчик). Гейт
_bucket_phase_totally_failed сверяет прирост пар X_attempted/X_failed за якорь;
порог одна попытка. Тот же гейт в run_avito_city_sweep (detail). Ветка «houses
DB query failed» теперь растит houses_attempted вместе с houses_failed.
Форма чекпоинта прежняя (плоский список имён).

Перенос ветки fix/3415-bucket-done-only-if-phases-ok (19a335f4) на свежий main,
применилась без конфликтов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:48:20 +05:00
e42e370ac9 fix(tradein/avito): detail-добор по умолчанию идёт браузером, а не curl (#3045)
curl_cffi не проходит QRATOR proof-of-work Авито в принципе (замер 21.08:
~6% обогащений против ~74% на браузере). Шаг 3 задачи был сделан только
environment'ом сервиса scraper, дефолт в config.py остался True. Добор при
этом исполняет и tradein-backend — ручка админки scrape_avito_detail_backfill
вызывает run_avito_detail_backfill в своём процессе, и там printenv даёт true.
Ручной прогон через админку шёл curl'ом.

Дефолт avito_detail_backfill_use_curl -> False. Комментарий в задаче «true
только у tradein-backend, который добор не запускает» был неверен — исправлен,
как и два упоминания use_curl=True «прод-дефолтом».

Тест по значению: Settings без env и без .env даёт False; контроль — env
по-прежнему переключает на curl. Фальсификация: default=True ->
«AssertionError: assert True is False».

На проде хостовый backend/.env.runtime:37 задаёт true явно, поэтому для
tradein-backend нужен ещё environment-оверрайд — он в коммите #3182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:48:12 +05:00
8e5f097c5e Планировщик: упавший до финализатора прогон получает статус сразу, а не zombie через 6 часов (#1940)
Исключение, вылетевшее из хендлера до его try с mark_failed (прод: пустой пул
прокси в BrowserFetcher.__aenter__ через 10 мс после claim), планировщик только
логировал. Строка оставалась 'running' с heartbeat_at == started_at, reaper через
6 ч ставил 'zombie' — за 21 сутки так 18 avito-свипов (7349 и 7350 висят сейчас).

_dispatch._run теперь финализирует прогон сам: пустой пул прокси в цепочке
причин -> banned/ban_kind=infra (как в пайплайнах), остальное -> failed.
Уже финализированную хендлером строку не трогает (WHERE status='running'),
пустые counters мержатся и чекпоинт не стирают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:47:05 +05:00
ec85ea1861 fix(metrics): правка queries.yml доезжает до postgres-экспортёров (#3486)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 3m19s
CI / backend-tests (pull_request) Successful in 8m6s
queries.yml смонтирован трём экспортёрам одним файлом и читается только при
старте, а агентские джобы деплоя пересоздавали лишь alloy: правка ложилась на
диск новым инодом, экспортёры продолжали отдавать старые запросы при зелёном
деплое. Разрыв латентный — 17.09 иноды хоста и контейнеров совпадают
(Poincare 5112170, Beget 569352).

agent-apps и agent-infra после подъёма сверяют инод queries.yml у своих
экспортёров через ops/metrics/recreate-stale-mount.sh и пересоздают только при
расхождении, под гейтом профиля. Гейт пофайловых маунтов теперь читает и
docker-compose.metrics-agent.yml (сервис → джоба по профилю), а ci.yml
запускает backend-тесты на правку этого файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:43:48 +05:00
e5db8dd80f fix(metrics): правки prometheus.yml, loki-config.yml и датасорсов Grafana доезжают до работающих сервисов (#3467)
Хвосты #3467, не попавшие в main вместе с #3476:

- prometheus.yml смонтирован одним файлом: после git reset --hard reload
  перечитывал СТАРЫЙ инод с rc=0 и новым lastConfigTime (стенд
  prom/prometheus:v3.1.0, 17.09). Комментарий в деплое утверждал обратное.
  Теперь promtool проверяет файлы С ДИСКА одноразовым контейнером, а при
  расхождении инода контейнер пересоздаётся до reload.
- loki-config.yml — тот же пофайловый маунт, перезагрузки у Loki нет:
  пересоздание при расхождении инода.
- Датасорсы Grafana применяются только при старте: POST
  /api/admin/provisioning/datasources/reload, отказ роняет деплой.

Общий шаг — ops/metrics/recreate-stale-mount.sh: пересоздаёт только при
расхождении инода и перепроверяет после; тесты исполняют его с подставным
docker. Гейт берёт пофайловые маунты из docker-compose.metrics.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:42:35 +05:00
e2b1a8adee fix(tradein/dedup): дома с районом, приклеенным к номеру («17Ар-н Академический»), сливаются со своими двойниками (#1772)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
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 6m47s
Канон-ключ tradein_canon_addr не срезал район, приклеенный к номеру дома без
запятой (наследие слипа «29р-н» из #1773): «ул. Евгения Савкова, 17Ар-н
Академический» давал «евгениясавкова17арнакадемический», а «44Бр-н» терял
литеру (токен-срез принимал «бр» за бульвар). Двойник «ул. Евгения Савкова,17А»
с таким домом в один кластер не попадал, и перепись остатка его не видела.

Миграция 311: тело 147 плюс шаг «<цифра>[литера]р-н <название>» → «<цифра>[литера]»
сразу после lower. Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2»
превращается в «мк2». Прод 17.09 (read-only): канон меняется у 44 из 49 159
домов, у 20 появляется двойник в пределах 250 м, ГАР-сторона не затронута
(0 из 3,27 млн строк с шаблоном).

Тест по значению на функции, установленной миграциями, и сквозное слияние:
склеенный адрес и чистый двойник сливаются, «17» без литеры остаётся отдельным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:42:08 +05:00
bc0ef9536e fix(glitchtip): медленный отказ Telegram отвечает 502 до таймаута GlitchTip (#3157)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 28s
CI / changes (pull_request) Successful in 33s
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 / 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 6m25s
Синхронная пересылка алерта была ограничена таймаутом одного HTTP-запроса
(8 с), а попыток после ретранслятора (#3471) больше одной: relay, прямой путь,
пауза 2 с, повтор. 16.09.2026 16:42 UTC отказ шёл медленно, GlitchTip
(aiohttp ClientTimeout total=10) оборвал запрос на 10-й секунде, Caddy записал
status=0, а хендлер досчитал 200 уже разорванному клиенту: uvicorn такой ответ
выбрасывает вместе со строкой access-log. Исход у отправителя и приёмника
расходился, 502 и фоновая доставка не срабатывали.

Вся синхронная попытка теперь под asyncio.timeout(7 с). TimeoutError идёт тем
же путём, что TelegramError: logger.exception, 502, фоновая доставка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:40:56 +05:00
ceb2e5972d fix(tradein/dedup): ФИАС-проход схлопывания домов больше не сливает дома за сотни километров (#2690)
ФИАС-проход house_dedup_merge шёл без гео-стража (#2187: «общий ФИАС-UUID и
есть здание»). Посылка неверна: house_fias_id — ответ DaData на НАШУ строку
адреса, и «улица Кирова, 4» без города получает тот же UUID, что и дом в
другом городе. Прогон 05.09 (house_merge_log, merge_pass='fias'): 408 слияний,
129 дальше 250 м, 53 дальше 3 км, максимум 362 км, у 67 нет координат на одной
из сторон.

Теперь у обоих проходов один и тот же страж (250 м, координаты у обеих сторон),
параметр apply_geo_guard удалён — асимметрию нельзя вернуть флагом.

Тест по значению на живой Postgres: тот же ФИАС в 5 км и с пустыми координатами
не сливается, тот же ФИАС в 10 м сливается, журнал пишет geo_guard=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:39:34 +05:00
31c31f8f82 МЕРА: якорь «тот же дом» и IMV-blend — константы движка, не настройки (#2381)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
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 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s
Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей
Settings перенесены в estimator.py константами с теми же значениями:
IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA
0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05,
SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10,
ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD
0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая
проверка `tier_c_mult > 0` (константа против нуля) убрана.

Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста
по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf
гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma,
guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один
поведенческий тест и ни гейт, их держит только тест боевых значений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:44 +05:00
1fe383f935 fix(tradein): коридор ДКП при якоре того же дома помечен как не вошедший в цену (#3466)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
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 2m25s
CI Trade-In / backend-tests (pull_request) Successful in 7m2s
Кламп headline к коридору ДКП выключается не только малым числом сделок
(advisory_only, #3452), но и якорем Tier A: _apply_corridor_clamp его exempt,
radius-floor требует anchor_tier is None. Ревьюер #3462 воспроизвёл n=20,
advisory_only=False, headline 202 100 против потолка 140 000 — подписи нет.

- Признак — analog_tier == "same_building" (уже структурный в POST).
  corridorAdvisoryNote принимает тир и при same_building говорит
  «справочно: цена посчитана по аналогам в этом же доме — коридор её не
  ограничивает»; оба вызова (v1 HeroSummary, v2 mappers) передают тир.
- GET-rehydrate терял analog_tier (колонки нет) — якорный тир теперь
  восстанавливается из подписи якорного блока в confidence_explanation
  через общую константу (analog_tier_from_explanation), как радиус в #2632.
  Разбор по всей фразе: радиусный тир S пишет «(аналоги из того же дома)»,
  на проде таких строк 4.
- Тесты floor не доходили до floor: три лота уводили в #oblast-E, headline
  брался из медианы коридора. На main с полностью выключенным floor файл
  зелёный. Лотов шесть, ожидания точные, добавлен кейс n = min_n − 1.
- Полоса маркера corridor_advisory_zone — n = 1..9, не 3..9: уличный коридор
  отдаётся с одной сделки. Формулировки поправлены, тест на n=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:35 +05:00
26a7f1c163 fix(metrics): тревога, когда бэкенд Меры лёг, а лэндинг ещё открывается (#2214)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 21s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 24s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m23s
CI / backend-tests (pull_request) Successful in 8m17s
Мёртвый или зависший tradein-backend не давал ни одного сигнала вне Poincare.
GlitchTip-монитор 6 смотрит на лэндинг meraocenka.ru, а его отдаёт фронт из
пререндер-кэша (x-nextjs-cache: HIT) — при мёртвом бэкенде там 200. Монитор 2
(gendsgn.ru/health) проверяет бэкенд Птицы. AppHighErrorRate и
AppHighLatencyP95 считают метрики самого бэкенда и при его смерти молчат.

Новое правило MeraBackendDown: up{job="app",app="mera"} == 0 или серия
пропала, 5 минут, severity critical + host apps — маршрут telegram-clients,
тема 158. Считается и доставляется на Beget. Порог 5 минут откалиброван по
истории up за 26.08–17.09: 140 провалов, самый длинный — 4 нулевые точки
(~2 минуты, окна деплоя).

Юнит-тесты promtool: мёртвый бэкенд — тревога; окно деплоя и упавшая Птица —
тишина; цель пропала из скрейпа — тревога.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:14 +05:00
883c741a63 МЕРА: кламп expected_sold ≤ asking без выключателя, пороги #1795 — константы (#2380)
Флаг estimate_expected_sold_le_asking снят: оба клампа (ratio > 1.0 и
повторный после хедоники) безусловные. На проде 17.09 флаг = True во всех
трёх контейнерах, ENV-оверрайда нет, фикстура бэктеста захвачена с True.
Флаги corridor_clamp/radius_floor enabled сняты ещё в #2475.

Числовые пороги перенесены с прежними значениями: CORRIDOR_CLAMP_SLACK 0.40,
RADIUS_FLOOR_FACTOR 0.8, OUTLIER_SMALL_N_THRESHOLD 15, OUTLIER_TUKEY_K_SMALL 1.0
— в estimator.py; CORRIDOR_CLAMP_MIN_N 10 — в app.core.config рядом с
LISTINGS_FRESH_DAYS, потому что его же читает DkpCorridor.advisory_only (#3452),
а схема не должна тянуть estimator. Мёртвая проверка `tukey_k_small < 1.5`
(константа против константы) убрана. Сегментный множитель (#2255) не тронут,
порядок операций прежний.

Тесты: OFF-тесты клампа удалены; тест потолка хедоники берёт ratio 0.70, при
котором кламп не срабатывает (0.70 × 1.30 = 0.91), вместо выключения клампа.
Реплей бэктеста по сделкам побитово тот же (бизнес 169, элит 5, премиум 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:33:56 +05:00
0ff2703032 МЕРА: пороги квартального индекса — константы движка, не настройки (#2379)
Единственный переключатель estimate_quarter_index_enabled снят ещё в #2475.
Оставшиеся шесть числовых полей Settings (min_n_deals 10, match_skip_ratio 0.6,
max_for_small_n 2.0, small_n_threshold 50, factor_min 0.6, factor_max 1.8)
перенесены в estimator.py константами QUARTER_* с теми же значениями и
комментариями #764/#859; бэктест берёт QUARTER_INDEX_MIN_N_DEALS из модуля.
На проде 17.09 все шесть равны дефолтам, ENV-оверрайдов нет.

Замороженная фикстура квартальный индекс не применяет ни разу (0 из 1600
сделок), а поведенческие тесты не краснеют при подмене трёх порогов из шести,
поэтому добавлен tests/test_1970_estimator_constants.py: боевые значения,
снятые с прода, и проверка, что поле не вернулось в Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:31:12 +05:00
45bd47fdd7 МЕРА: дедуп аналогов и пороги ручной оценки без выключателей (#2378)
Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда.
На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot),
ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в
реплее и monkeypatch в гейте больше не нужны.

Числовые пороги estimate_wide_corridor_threshold и три
estimate_manual_review_* перенесены в estimator.py константами модуля с
прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше
не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475
флагам (#1871 P1.2, P2 radius-dedup) удалены.

Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции,
получили разные площади у аналогов (разные физлоты). Регрессионный гейт и
реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:29:26 +05:00
08216e4c43 feat(mera/estimator): порог и окно этажности Tier H — в настройки, для бэктеста (#3234)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 23s
CI / changes (pull_request) Successful in 24s
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 6m30s
Tier H («тот же класс дома») отдаётся только при ≥5 комплах, иначе молча уходит в
Tier W без фильтра по классу. Прод, Loki 30 сут (комментарий 12.09): 61 из 86
попыток не доживает, в 31 из них в своей полосе 0 комплов. Какой вариант лучше —
порог 5→4, окно этажей ±30%→±50% или оба — решает только MAPE бэктеста на сделках,
а оба числа были литералами: сравнить варианты без правки кода было нельзя.

estimate_tier_h_min_comps (5) и estimate_tier_h_floors_tol (0.30) в Settings,
_fetch_analogs берёт их оттуда. Дефолты дают те же tf_min/tf_max, что литералы
(сверено для total_floors 1..199), поведение прода не меняется. Бэктест варианта:
docker exec -e ESTIMATE_TIER_H_MIN_COMPS=4 -e ESTIMATE_TIER_H_FLOORS_TOL=0.5 …

Дефолт не меняю: бэктест вариантов не прогнан (нужен деплой этих ручек).

Refs #3234

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:28:55 +05:00
56f0cdbdf7 fix(mera/lead): о новой заявке узнаёт ответственный, конверсия «оценка → заявка» на дашборде
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 21s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m53s
CI Trade-In / backend-tests (pull_request) Successful in 6m56s
CI / backend-tests (pull_request) Successful in 8m25s
#1971, два невыполненных пункта DoD.

1. Уведомления не было. Заявка с результата оценки только ложилась в
trade_in_leads (docstring прямо называл уведомление «вне scope»). На проде
17.09: 4 заявки, notified_at пуст у всех, последняя 12.07. Теперь после ответа
клиенту фоновая задача шлёт сообщение в support-топик тем же ботом, что и
веб-чат поддержки, и при успехе ставит notified_at. Отказ Telegram не меняет ни
ответ (200), ни сохранённый лид — только лог. Телефона в сообщении нет: копию в
Telegram не стирает механизм удаления ПДн, поэтому туда идут id заявки,
пользователь и id оценки.

2. Доли заявок от оценок не было нигде. Панель на продуктовом дашборде: лиды за
7 суток / успешные оценки за 7 суток (знаменатель — только outcome=ok: форма
заявки показывается только при посчитанной оценке). Выражение проверено на
боевом Prometheus 17.09: 0 / 91.008 = 0.

Closes #1971

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:27:44 +05:00
4dffb2d1fb fix(mera/лендинг): итог игры «Проверьте себя» говорит, что квартиры взяты из отобранной полосы (#2903)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Successful in 2m32s
CI Trade-In / backend-tests (pull_request) Successful in 7m1s
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 17s
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
Раунды игры — строки витрины, а витрина с 12.09 отобрана по ошибке МЕРЫ
(полоса -5..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» выходила без
оговорки и читалась как точность расчёта: на проде 17.09 медиана |ошибки|
двадцати строк 6,5 %. Лента и таблица «Точность» полосу называли, игра — нет.

Теперь итог печатает медиану по всей сверке (landing-facts) и, если все
строки витрины лежат в полосе (allWithinBand, как у ленты и таблицы),
говорит, что квартиры из отобранной полосы, а не из всей сверки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:27:33 +05:00
9f11b8ed69 fix(mera/estimator): дедуп склеивает нумерованные улицы через дефис (#2291, класс 4)
Регексы названия улицы в _parse_street_house пускали ведущую цифру только через
пробел («8 Марта»), а через дефис нет. «40-летия Комсомола», «22-го Партсъезда»,
«3-го Интернационала» разбирались так:
- avito «ул. 40-летия Комсомола,32Г» и yandex — ('', ''), дедупа нет вовсе;
- cian «мкр. ЖБИ, улица 40-летия Комсомола, 8А» — ('mkr жби', '40'): все дома
  улицы получали один ключ и дом «40», разные квартиры с одинаковыми
  этажом/площадью/ценой сливались в одну;
- domklik «60-летия Октября проспект» — ('октября', …).

Фикс: (?:\d+\s+)? → (?:\d+(?:\s+|-))? в _DEDUP_STREET_NAME_RE и
_DEDUP_STREET_NAME_SUFFIX_RE. На 5310 живых адресах прода с «цифра-буква»
меняется разбор у 730 объявлений, все новые токены — реальные нумерованные
улицы (14 штук, от «40-летия комсомола» до «2-ая новосибирская»).

Бэктест-гейт: baseline перегенерирован. В реплее дедуп включён (как на захвате),
поэтому правка разбора сдвигает пул: изменились 99 из 1600 сделок, у 48 ошибка
меньше, у 51 больше. expected_sold MAPE 15.88→15.92, покрытие вилки 1056→1057,
headline не сдвинулся.

Refs #2291

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:25:50 +05:00
71d60ff13e fix(mera/витрина): пустой прогон витрины сделок больше не считается успешным (#3511)
Обработчик landing_showcase_deals безусловно ставил прогону done. У счётчиков
витрины (considered/eligible/written) нет результатного ключа кита, поэтому
сводка просроченных судит её только по статусу: прогон с written=0 обнулял
часы свежести так же, как удачный, а страница тем временем теряла таблицу.

Теперь written=0 — mark_failed с причиной и logger.error. Тест идёт путём
сводки: обработчик -> freshness_rows -> stale_sources; пустой последний
прогон при старом непустом даёт витрину в тревоге, непустой — нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:24:20 +05:00
f1f0d297cf МЕРА v2: клик по «Предыдущие оценки» после отправки показывает выбранную оценку (#2425)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
После отправки оценки freshResult оставался выставленным, и клик по строке
истории ничего не менял на экране: useEstimate получал null (восстановление
по id вообще не запускалось), а estimate и currentEstimateId брались из
только что посчитанного результата. onSelectEstimate теперь сбрасывает
freshResult так же, как это делает handleNew.

Тест рендерит страницу с заглушками хуков: submit, затем клик по другой
строке истории — в useEstimate уходит id строки, HeroBar и форма получают
её id и адрес. Без правки тест красный (useEstimate вызван с null).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:23:47 +05:00
34642e1dd5 Merge pull request 'Метрики: экспортёр Celery наконец поднимается, тревоги про воркеров и память перестают врать' (#3547) from fix/3493-celery-exporter-memory-alert into main
All checks were successful
Deploy Metrics / server (push) Successful in 45s
Deploy Metrics / agent-infra (push) Successful in 24s
Deploy Metrics / agent-apps (push) Successful in 39s
2026-09-17 07:05:41 +00:00
200 changed files with 12616 additions and 2241 deletions

View file

@ -233,6 +233,10 @@ jobs:
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml'
# #3486: гейт test_metrics_single_file_mounts.py читает и
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
# экспортёра) без этой строки прошла бы мимо гейта.
- 'docker-compose.metrics-agent.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
@ -256,10 +260,12 @@ jobs:
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
# нужен, в отличие от tradein-лэйна.
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
# ST_DWithin по geography, без расширения его не исполнить. На plain
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
#
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
@ -290,15 +296,14 @@ jobs:
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
# здесь не нужен вовсе, в отличие от tradein-лэйна.
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -fv "$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
postgis/postgis:16-3.4
ready=""
for _ in $(seq 1 45); do

View file

@ -91,12 +91,16 @@ jobs:
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
# #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и
# аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок
# METRICS_WATCHDOG_PING_BLOCK ниже.
METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }}
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET,METRICS_WATCHDOG_PING_URL
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
@ -193,6 +197,24 @@ jobs:
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi
# Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и
# аналоги) — заменяет регулярные сообщения в Telegram, см.
# комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок
# целиком, а не значение — та же причина, что у
# METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в
# receiver'е не деградирует, а валит amtool check-config
# целиком, то есть роняет ВЕСЬ алертинг из-за одного
# необязательного получателя. Секрет пока не заведён —
# деградация корректна: receiver остаётся без `*_configs` и
# молча ничего никуда не шлёт, amtool это пропускает.
if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then
METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}")
echo "Watchdog: внешний deadman-пинг настроен."
else
METRICS_WATCHDOG_PING_BLOCK=""
echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)."
fi
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
@ -221,7 +243,8 @@ jobs:
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
> ops/metrics/alertmanager/alertmanager.yml
chmod 600 ops/metrics/alertmanager/alertmanager.yml
@ -388,6 +411,15 @@ jobs:
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
fi
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
# на диск и не вступала в силу до случайного пересоздания.
# Пересоздаём только при расхождении инода.
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
@ -406,15 +438,6 @@ jobs:
fi
fi
# ── Приёмка ────────────────────────────────────────────────────
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
@ -424,27 +447,41 @@ jobs:
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# после `git reset --hard` подхватывается без пересоздания
# контейнера. --web.enable-lifecycle уже включён в compose ради
# этого шага (см. docker-compose.metrics.yml) — просто раньше
# никто не звал сам reload.
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
# файла: после `git reset --hard` за путём в контейнере остаётся
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
# --web.enable-lifecycle включён в compose ради этого шага.
#
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# положить работающий Prometheus молчаливым откатом на дефолты.
#
# Плюс юнит-тесты правил (#3493): синтаксически верное правило может
# врать по смыслу — `count(x == 1) == 0` от пустого вектора не
# срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
# экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
# одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \
&& docker run --rm --entrypoint promtool \
-v /opt/gendesign/ops/metrics/prometheus:/work:ro \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
test rules /work/tests/infra_test.yml; then
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
# контейнером того же образа. `docker exec` в работающий проверял
# бы тот самый старый инод, а не то, что сейчас применится.
# Синтаксически верное правило может врать по смыслу —
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
# отсюда test rules.
if docker run --rm --entrypoint sh \
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
gendesign-metrics docker-compose.metrics.yml \
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
# ── Приёмка ────────────────────────────────────────────────
# После возможного пересоздания выше — ждём оба процесса,
# которые дальше перечитывают конфиг по HTTP.
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
@ -470,6 +507,30 @@ jobs:
exit 1
fi
# ── Grafana: датасорсы применяются только при старте (#3467) ────
# Каталог provisioning смонтирован целиком, новый datasources.yml
# контейнер видит сразу. Дашборды провайдер пересканирует сам
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
#
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
case "$GRAFANA_RELOAD" in
*"Datasources config reloaded"*)
echo "Grafana: датасорсы перечитаны."
;;
*)
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
exit 1
;;
esac
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest
@ -561,6 +622,21 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
# `git reset --hard` пишет правку новым инодом: без пересоздания
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
# Пересоздаём только при расхождении инода. Под гейтом профиля:
# `up` с явным именем сервиса включает его профиль сам.
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi
agent-infra:
runs-on: ubuntu-latest
needs: server
@ -630,3 +706,10 @@ jobs:
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
if [ -n "$EXPORTER_PROFILE" ]; then
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
fi

View file

@ -77,7 +77,7 @@ on:
# исполнял бы старую версию — молча и без единого сигнала.
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
# backup.sh, restore-drill.sh, restore.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.

View file

@ -1910,17 +1910,19 @@ def trigger_ekburg_permits(
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
_WAF_COOLDOWN_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
"передай i_understand_waf_risk=true в теле запроса."
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
_DOMRF_BLOCK_GUARD_MSG = (
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
"не снимает, запуск отсюда даст только отказы. Передавай "
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
"принят по числу строк (#3307)."
)
@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects(
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
объектов до next beat fire).
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для
свежесозданных объектов).
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
re-trigger без явного подтверждения оператора запрещён.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
запуск без явного подтверждения оператора запрещён (условие #3307).
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats(
- max_flats=3 smoke-тест.
- force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
того же WAF-бана.
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
блокировкой StormWall (условие #3307).
"""
if not payload.i_understand_waf_risk:
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats

View file

@ -166,6 +166,74 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
_GEO_PRICE_MIN_LOTS: int = 10
_GEO_PRICE_MIN_COMPLEXES: int = 2
_GEO_RADIUS_PRICE_SQL = text("""
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
-- (physflat-ключ, последний снапшот), scope протолкнут В
-- CTE через проекты ближних ЖК. НЕ через
-- v_objective_lots_latest: view материализует ВСЮ таблицу
-- (qual/join не проходят ниже DISTINCT ON) seq-scan+sort
-- 1.76M (~6.4 s на request-path analyze_parcel). Дедуп до price-фильтра:
-- цена объективна по физлоту (последний снапшот).
-- #3583: дедуп по проекту в LATERAL + premise_kind='квартира' → Index Only Scan
-- objective_lots_physflat_covering_v2_idx без сортировки. Общий DISTINCT ON по
-- всем снапшотам проектов шёл external merge на диск: прод-EXPLAIN 17.09, 16
-- проектов, 690 мс против 62 мс. Апартаменты на проде 17.09 есть у 6 проектов из
-- complex_sources, у всех complex без координат в радиус не попадают, фильтр
-- медиану не меняет (замер на 570 участках: 0 отличий).
WITH nearby_cx AS (
-- #3583: complex → проект Объектива из complex_sources (source='objective',
-- 1:1), а НЕ из objective_lots.complex_id. Тот проставлен один раз миграцией 76,
-- а еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
-- переписывает project_name и не трогает complex_id под id ближнего ЖК
-- лежат лоты чужих (прод 17.09: 236 354 из 303 677 строк). Тот же дефект,
-- что #2962 в competitors.py, и та же сверка имени: связь fuzzy, у «ЖК VEER
-- PARK» стоит 'Clever Park' в 11.7 км, у «ЖК Графит» 'Гранит'.
SELECT c.id, cs.source_id AS project_name
FROM complexes c
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(c.canonical_name), '[^0-9a-zа-яё]', '', 'g') AS cx_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
ST_SetSRID(
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326
)::geography,
CAST(:radius_m AS float)
)
AND ( k.cx_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.cx_key || '%')
),
latest AS (
SELECT l.price_per_m2_rub, nc.id AS complex_id
FROM nearby_cx nc
CROSS JOIN LATERAL (
SELECT DISTINCT ON (ol.corpus_name, ol.section, ol.floor, ol.lot_number)
ol.price_per_m2_rub
FROM objective_lots ol
WHERE ol.project_name = nc.project_name
AND ol.premise_kind = 'квартира'
ORDER BY ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
) l
)
SELECT
percentile_cont(0.5) WITHIN GROUP (
ORDER BY price_per_m2_rub
) AS median,
count(*) AS n,
count(DISTINCT complex_id) AS n_complexes
FROM latest
WHERE price_per_m2_rub IS NOT NULL
""")
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
@ -3562,7 +3630,7 @@ def analyze_parcel(
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
# complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py).
# complexes (latitude/longitude), проект — через complex_sources (#3583, как #2962).
geo_radius_price: dict[str, Any]
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
@ -3573,60 +3641,7 @@ def analyze_parcel(
with db.begin_nested():
grp_row = (
db.execute(
text("""
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
-- (physflat-ключ, последний снапшот), scope протолкнут В
-- CTE через complex_id ближних ЖК. НЕ через
-- v_objective_lots_latest: view материализует ВСЮ таблицу
-- (qual/join не проходят ниже DISTINCT ON) seq-scan+sort
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
-- geo-index по complexes Nested Loop bitmap
-- objective_lots_complex_idx по ~186 ЖК ~120 ms
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
-- цена объективна по физлоту (последний снапшот).
WITH nearby_cx AS (
SELECT c.id
FROM complexes c
WHERE c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(
ST_MakePoint(c.longitude, c.latitude),
4326
)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS float),
CAST(:lat AS float)
), 4326
)::geography,
CAST(:radius_m AS float)
)
),
latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number
)
ol.price_per_m2_rub,
ol.complex_id
FROM objective_lots ol
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
ORDER BY ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
percentile_cont(0.5) WITHIN GROUP (
ORDER BY price_per_m2_rub
) AS median,
count(*) AS n,
count(DISTINCT complex_id) AS n_complexes
FROM latest
WHERE price_per_m2_rub IS NOT NULL
"""),
_GEO_RADIUS_PRICE_SQL,
{
"lon": centroid_lon,
"lat": centroid_lat,

View file

@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
"""
objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage)
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —

View file

@ -587,19 +587,6 @@ class NSPDBulkClient:
)
return results
async def get_territorial_zones_in_bbox(
self,
bbox: tuple[float, float, float, float],
*,
grid_n: int = 7,
) -> list[dict]:
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
Returns: list of feature dicts с полями id, geometry, properties.
Дедуплицирует по feature id.
"""
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
# ── 4. list_objects_in_building ───────────────────────────────────────────
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
# Готов для per-building помещения/парковка фазы.

View file

@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
from __future__ import annotations
import hashlib
import json
import logging
from collections.abc import Callable
@ -260,10 +259,8 @@ async def harvest_quarter(
update_progress(done_progress)
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0])
@ -278,25 +275,6 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit()
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@ -679,7 +657,7 @@ def upsert_features(
cat = feature.category_id
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
# квартала. Зеркалит _grid_walk_category:
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
try:
@ -1476,114 +1454,6 @@ def upsert_quarter_stats(
)
# ── cad_territorial_zones upsert ────────────────────────────────────────────
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
Args:
db: SQLAlchemy session.
quarter_cad: кадастровый номер квартала (3 сегмента).
features: list of raw feature dicts от get_features_in_bbox_grid.
Returns:
Количество успешно upserted строк.
"""
inserted = 0
for f in features:
props: dict = f.get("properties") or {}
geom = f.get("geometry")
geom_geojson: str | None = json.dumps(geom) if geom else None
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
if _raw_id:
zone_id = str(_raw_id)
else:
_props_hash = hashlib.md5(
json.dumps(props, sort_keys=True).encode("utf-8")
).hexdigest()[:12]
zone_id = f"{quarter_cad}_{_props_hash}"
zone_code = (
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
)
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
# db.execute() в этом loop — outer tx гарантирована.
with db.begin_nested():
db.execute(
text("""
INSERT INTO cad_territorial_zones
(quarter_cad, zone_id, zone_code, zone_name,
permitted_use, raw_props, geom)
VALUES (
CAST(:quarter_cad AS text),
CAST(:zone_id AS text),
CAST(:zone_code AS text),
CAST(:zone_name AS text),
CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography
ELSE NULL
END
)
ON CONFLICT (zone_id) DO UPDATE SET
zone_code = EXCLUDED.zone_code,
zone_name = EXCLUDED.zone_name,
permitted_use = EXCLUDED.permitted_use,
raw_props = EXCLUDED.raw_props,
geom = EXCLUDED.geom,
fetched_at = NOW()
"""),
{
"quarter_cad": quarter_cad,
"zone_id": zone_id,
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
"raw_props": json.dumps(props, ensure_ascii=False),
"geom": geom_geojson,
},
)
inserted += 1
except Exception as e:
logger.warning(
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
zone_id,
quarter_cad,
e,
)
db.commit()
return inserted
# ── Утилиты ──────────────────────────────────────────────────────────────────

View file

@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
_RE_ACT_NUMBER = re.compile(
r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE | re.IGNORECASE,
)
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
_ACT_NUM = r"(\d+(?:\s*[-]\s*[А-Я]{1,3})?)(?![\w/\-])"
_RE_ACT_NUMBER = re.compile(rf"\s*{_ACT_NUM}")
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"\s*{_ACT_NUM}\s*$")
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
@ -233,7 +237,9 @@ def extract_izyatie_records(
normalized = _normalize_ocr_text(ocr_text)
# Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
act_number = _extract_act_number(doc_title) or _extract_act_number(
normalized, require_act_context=True
)
act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True
)
@ -289,9 +295,26 @@ def extract_izyatie_records(
# ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str) -> str | None:
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
m = _RE_ACT_NUMBER.search(text)
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает номер акта.
Без require_act_context (заголовок) первое « N» в тексте.
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
выбирает `_extract_act_date`: «» вплотную после даты, иначе вплотную перед
ней. Первое «» в теле это «Решение Думы 60/1» (Генплан) или «Приказ
Министерства 746-П», а не основание изъятия. Номера рядом с датой нет
None, а не номер соседнего документа.
"""
if not require_act_context:
m = _RE_ACT_NUMBER.search(text)
else:
date_m = _act_date_match(text, require_act_context=True)
if date_m is None:
return None
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
)
if not m:
return None
return re.sub(r"\s+", "", m.group(1))
@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool:
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return m
return None
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок.
"""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
return None
m = _act_date_match(text, require_act_context=require_act_context)
if m is None:
return None
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
def _extract_purpose(text: str) -> str | None:

View file

@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
_RE_ACT_NUMBER = re.compile(
r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
r"\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
re.UNICODE,
)

View file

@ -1109,9 +1109,6 @@ def get_best_layouts(
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
raw_objects_total_in_radius = len(all_obj_ids)
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
exclude_set = set(request.exclude_competitor_obj_ids)
@ -1132,13 +1129,16 @@ def get_best_layouts(
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
# objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра):
# UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается
# по отфильтрованным (#2464). Число до фильтра — в raw_objects_total.
complex_groups = group_radius_objects(id_row_dicts)
if not all_obj_ids:
return _empty_response(
radius_km=request.radius_km,
time_window=request.time_window,
objects_total_in_radius=groups_total_pre_filter,
objects_total_in_radius=len(complex_groups),
raw_objects_total=raw_objects_total_in_radius,
)

View file

@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
-- важна velocity питает forecast). Спатиальный join дёшев, т.к.
-- complexes мал (~1.5k строк с geom) НЕ потому что latest_obj
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
--
-- #2962: complex → проект Объектива берём из complex_sources (source=
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
-- project_name и не трогает complex_id под одним complex_id лежат чужие ЖК
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM latest_obj o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -369,6 +374,20 @@ _COMPETITORS_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
-- #968: только complexes, у которых ЕСТЬ objective-данные →
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
-- без objective_lots съедал бы матч (prod: +37 наивно +181 так).
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё spatial-only
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
),
mapped AS (
-- PRIMARY (unchanged): 148 явных маппингов velocity для них byte-identical.
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
cm.objective_complex_name
FROM objective_complex_mapping cm
UNION
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
-- (у комплекса может быть несколько корпус-project_name velocity
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
-- (корпуса у Объектива corpus_name внутри проекта, не отдельные имена).
SELECT nc.obj_id,
ol.project_name AS objective_complex_name
nc.project_name AS objective_complex_name
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
AND ol.project_name IS NOT NULL
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит»
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
-- гео-фильтра 3 с против 0.4 с.
WHERE nc.name_ok
),
velocity AS (
SELECT
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
nearest_cx AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
cs.source_id AS project_name,
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
-- «Парковый» «Парковый квартал»)
( k.comm_key LIKE '%' || k.project_key || '%'
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
FROM domrf_kn_objects o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
JOIN complex_sources cs
ON cs.complex_id = c.id
AND cs.source = 'objective'
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.project_name = cs.source_id
)
CROSS JOIN LATERAL (
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
) k
WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
) ASC,
-- #2962: 1:1 complex → objective-проект держится только на данных
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
-- брал любой, сверка его отвергала, и верный терялся.
name_ok DESC,
cs.source_id
),
gapfill_price AS (
SELECT
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
ON ol.project_name = nc.project_name
AND ol.price_per_m2_rub IS NOT NULL
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
WHERE nc.name_ok
)
SELECT
p.obj_id,

View file

@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
# territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
#
@ -346,11 +346,13 @@ def build_beat_schedule() -> dict:
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
# Включать только после решения по прокси и принятого по числу строк
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
@ -362,11 +364,11 @@ def build_beat_schedule() -> dict:
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
#
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {

View file

@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text(
)
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
#
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
_DELETE_STALE_PARSE_SQL = text(
"""
DELETE FROM land_reservation
WHERE cad_num = CAST(:cad_num AS text)
AND doc_url = CAST(:doc_url AS text)
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
"""
)
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
count = 0
@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
try:
with db.begin_nested(): # SAVEPOINT per-row
db.execute(_DELETE_STALE_PARSE_SQL, row)
db.execute(upsert_sql, row)
count += 1
except Exception as exc:

View file

@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
VPS IP; включается вручную после проверки targeted-тестом.
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
включать после прокси и принятого kn-прогона (#3307).
"""
from __future__ import annotations
@ -116,7 +117,7 @@ def scrape_kn_catalog_flats(
Concurrency:
No Redis lock consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне
маловероятно. Если случится:
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.

View file

@ -9,7 +9,7 @@ Selector logic:
- DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем
- force=True игнорирует фильтр, загружает все объекты последнего snapshot
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
"""
from __future__ import annotations

View file

@ -0,0 +1,123 @@
"""Гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не всё под complex_id (#3583).
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
не трогает complex_id: под id ближнего ЖК лежат лоты чужих. Тот же дефект, что #2962
в competitors.py.
Тест герметичный и прогоняет НАСТОЯЩИЙ `_GEO_RADIUS_PRICE_SQL`: временные таблицы
затеняют боевые в пределах сессии. Нужен Postgres с PostGIS (ST_DWithin по geography).
В CI он есть, и там тест не пропускается: без PostGIS падает с настоящей причиной.
Пропуск разрешён только вне CI и объявлен в skip_allowlist.txt.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _postgis_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
return True, ""
return False, "нет расширения postgis"
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _postgis_reachable()
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
pytestmark = pytest.mark.skipif(
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
)
_SCHEMA = [
"""CREATE TEMP TABLE complexes (
id bigint, canonical_name text, latitude double precision,
longitude double precision) ON COMMIT DROP""",
"""CREATE TEMP TABLE complex_sources (
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_lots (
id bigint, project_name text, corpus_name text, section text, floor int,
lot_number text, snapshot_date date, premise_kind text, complex_id bigint,
price_per_m2_rub numeric) ON COMMIT DROP""",
]
# Участок в (56.840, 60.600), радиус 3 км. Малахит — в ~10 км, вне радиуса.
_DATA = [
"""INSERT INTO complexes VALUES
(10, 'ЖК Мичуринский', 56.841, 60.601),
(20, 'ЖК VEER PARK', 56.845, 60.605),
(30, 'СтудияПарк', 56.835, 60.595),
(40, 'ЖК Малахит', 56.930, 60.600)""",
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
"""INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'),
(30, 'objective', 'Студия Парк'),
(40, 'objective', 'Малахит')""",
# Под complex_id=10 лежат свой лот и три лота чужого «Малахита» с устаревшим
# complex_id; у новых лотов complex_id NULL. Лот «Мичуринский/1/1/3/3» в двух
# снапшотах: в медиану идёт последний (120 тыс.), а не старый (50 тыс.).
"""INSERT INTO objective_lots VALUES
(1, 'Мичуринский', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 100000),
(2, 'Мичуринский', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 110000),
(3, 'Мичуринский', '1', '1', 3, '3', '2026-08-01', 'квартира', NULL, 50000),
(4, 'Мичуринский', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 120000),
(5, 'Малахит', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 300000),
(6, 'Малахит', '1', '1', 2, '2', '2026-05-10', 'квартира', 10, 300000),
(7, 'Малахит', '1', '1', 3, '3', '2026-05-10', 'квартира', 10, 300000),
(8, 'Clever Park', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 500000),
(9, 'Clever Park', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 500000),
(10, 'Clever Park', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 500000),
(11, 'Студия Парк', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 90000),
(12, 'Студия Парк', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 95000)""",
]
@pytest.fixture(scope="module")
def row() -> dict[str, float]:
from app.api.v1.parcels import _GEO_PRICE_RADIUS_M, _GEO_RADIUS_PRICE_SQL
session = sessionmaker(bind=create_engine(_dsn()))()
try:
for stmt in _SCHEMA + _DATA:
session.execute(text(stmt))
r = (
session.execute(
_GEO_RADIUS_PRICE_SQL,
{"lon": 60.600, "lat": 56.840, "radius_m": _GEO_PRICE_RADIUS_M},
)
.mappings()
.one()
)
return {k: float(v) for k, v in r.items()}
finally:
session.rollback()
session.close()
def test_median_counts_own_projects_of_nearby_complexes(row) -> None:
"""Лоты 90/95 тыс. («Студия Парк») и 100/110/120 тыс. («Мичуринский») → медиана 100 тыс.
По устаревшему complex_id было бы 100 + три «Малахита» по 300 тыс. 300 тыс.;
без сверки имени добавились бы три лота «Clever Park» по 500 тыс. 115 тыс.
"""
assert row == {"median": 100000.0, "n": 5.0, "n_complexes": 2.0}

View file

@ -1,10 +1,11 @@
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
smoke-тест/re-trigger не углубил бан.
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
для обоих тасков отключён по этой причине эти эндпоинты должны отказывать в
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа то, по чему оператор
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
"""
from __future__ import annotations
@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
def _assert_refusal_names_real_condition(detail: str) -> None:
assert "2443" in detail
assert "i_understand_waf_risk" in detail
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
assert "3307" in detail
assert "cooldown" not in detail.lower()
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
result = MagicMock()
result.id = task_id
@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
_assert_refusal_names_real_condition(resp.json()["detail"])
mock_apply_async.assert_not_called()
@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
assert resp.status_code == 400
detail = resp.json()["detail"]
assert "2443" in detail
assert "i_understand_waf_risk" in detail
_assert_refusal_names_real_condition(resp.json()["detail"])
mock_apply_async.assert_not_called()

View file

@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None:
# После исключения obj_id=20 список пуст → пустой ответ
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
# objects_total_in_radius = 1 (до исключения)
assert body["data_quality"]["objects_total_in_radius"] == 1
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
# число obj_id в радиусе до исключения — raw_objects_total
assert body["data_quality"]["objects_total_in_radius"] == 0
assert body["data_quality"]["raw_objects_total"] == 1
finally:
app.dependency_overrides.clear()

View file

@ -1,4 +1,4 @@
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
@ -16,9 +16,7 @@
реально ушло бы в сеть.
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя у
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
настоящие сетевые проверки.
бы с оригиналом на первой же правке.
"""
from __future__ import annotations
@ -34,7 +32,6 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parents[3]
SENDERS = {
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
}
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="123:FAKE"',
'TELEGRAM_CHAT_ID="-1004443088679"',
f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"',
]
if topic is not None:
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
args = _run_notify(tmp_path, SENDERS[name], topic="158")
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
# откуда следующий, кто заводит хост, узнает правильный адрес.
INFRA_TOPIC = "245"
BACKUP_ENV_EXAMPLES = [
"gendesign-backup.default.example",
"gendesign-backup-couchdb.default.example",
"gendesign-backup-forgejo.default.example",
]
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
маркером, а env-файл образец, заполненный так, как он велит человеку:
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
окружении, тема из другого файла молча не подхватится.
"""
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
env_file = tmp_path / "backup.env"
env_file.write_text(filled, encoding="utf-8", newline="\n")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake = bin_dir / "curl"
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
fake.chmod(0o755)
dump = tmp_path / "args.txt"
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
# перебили бы файл, и тест проверял бы не образец.
prefix = [
f'PATH="{_posix(bin_dir)}:$PATH"',
f'ARGS_DUMP="{_posix(dump)}"',
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
]
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
proc = subprocess.run(
[
"bash",
"-c",
" ".join(prefix)
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
env=dict(os.environ),
)
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
threads = [a for a in args if a.startswith("message_thread_id=")]
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
)

View file

@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
Как это ломало алерты:
* `ops/uptime-healthcheck.sh` ОДИН `curl`, дальше `|| log WARN`. Каждый
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) ОДИН
`curl`, дальше `|| log WARN`. Каждый
отказ терял уведомление целиком. Watchdog, который не может дозваться,
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
@ -51,7 +53,6 @@ import pytest
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
UPTIME = "ops/uptime-healthcheck.sh"
LIB_BACKUP = "ops/lib-backup.sh"
# См. подробное обоснование shutil.which в
@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за
def _extract_function(script_rel: str, name: str = "notify") -> str:
"""Достаёт тело одной функции из скрипта — не весь файл.
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
Сопоставление точное (`name() {`), иначе `notify` поймал бы
`notify_fallback_mail` соседнюю функцию в том же файле.
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
return calls, out, err
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
"""Один отказ — алерт всё равно доставляется со второй попытки.
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
def test_gives_up_after_three_attempts(script_rel: str) -> None:
"""Повторы ограничены: три попытки, а не бесконечный цикл.
@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
def test_uptime_reports_undelivered_alert_loudly() -> None:
"""Когда все три попытки провалились — это видно в логе, а не молча."""
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
"""Транзиентный таймаут не должен трогать запасной канал.

View file

@ -21,7 +21,11 @@
from __future__ import annotations
import importlib.util
import logging
import socket
import sys
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import ModuleType
@ -179,3 +183,48 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
code, _ = app.do_ack(token)
assert code == 404
assert app.sent == []
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
_LEAK = "leak-probe-3576-VALUE"
@pytest.mark.parametrize(
("request_line", "masked", "lines_with_mask"),
[
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
# Имя с префиксом и соседний параметр — остальная строка цела.
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
],
)
def test_sekret_iz_query_ne_popadaet_v_log(
app: ModuleType,
caplog: pytest.LogCaptureFixture,
request_line: str,
masked: str,
lines_with_mask: int,
) -> None:
caplog.set_level(logging.INFO, logger="alert-ack")
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
with socket.create_connection(srv.server_address, timeout=5) as sock:
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
sock.shutdown(socket.SHUT_WR)
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
pass
finally:
srv.shutdown()
srv.server_close()
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
assert sum(masked in m for m in messages) == lines_with_mask, messages

View file

@ -21,8 +21,9 @@
переменной и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
развести: инфраструктура в «метрики», клиентские инциденты в «алерты».
Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают
ИНФРАСТРУКТУРНУЮ тему, а не общую.
Тест ниже закрепляет именно это для единственного оставшегося прямого
получателя `telegram` (Watchdog с 17.09 больше не шлёт в Telegram вовсе,
у него теперь внешний webhook-приёмник `watchdog-ping`, см. шаблон).
Тесты рендерят шаблон обоими способами и разбирают результат как YAML
проверяется фактический конфиг, а не наличие нужных слов в тексте.
@ -43,9 +44,56 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
INFRA_TOPIC_LINE = " message_thread_id: 245"
# Заглушки для переменных, которые деплой может подставить. Значение
# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт
# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение.
_DUMMY_VALUES = {
"METRICS_TELEGRAM_BOT_TOKEN": "123:ABC",
"METRICS_TELEGRAM_CHAT_ID": "-100123",
"METRICS_TELEGRAM_ONCALL": "",
"METRICS_WATCHDOG_PING_BLOCK": "",
}
def _render(infra_topic_line: str) -> dict:
"""Повторяет подстановку деплоя и разбирает результат как YAML.
def _envsubst_allowlist() -> set[str]:
"""Реальный список переменных, которые деплой передаёт в envsubst.
Не хардкодим копию списка #3589 случился именно так: шаблон завёл
`${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили,
и тест этого не заметил, потому что сам подставлял значение мимо деплоя.
"""
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
text = WORKFLOW.read_text(encoding="utf-8")
m = re.search(r"envsubst '([^']+)'", text)
assert m, "не нашёл вызов envsubst в деплое"
return set(re.findall(r"\$\{(\w+)\}", m.group(1)))
def _template_placeholders() -> set[str]:
text = TMPL.read_text(encoding="utf-8")
return set(re.findall(r"\$\{(\w+)\}", text))
def test_every_template_placeholder_is_in_envsubst_allowlist() -> None:
"""Регресс #3589: переменная шаблона обязана быть в allow-list envsubst.
Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список
envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО
перечисленные переменные забытая долетает до `amtool check-config`
литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то
есть роняет ВЕСЬ Alertmanager, а не только Watchdog.
"""
missing = _template_placeholders() - _envsubst_allowlist()
assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}"
def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict:
"""Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML.
Подставляются только переменные из реального allow-list envsubst деплоя
(`_envsubst_allowlist`) не весь известный тесту набор. Так регресс
#3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь:
`assert "${" not in rendered` ниже упадёт, если что-то не подставилось.
Строка темы в шаблоне ровно одна инфраструктурная (#3163). Тема
клиентских инцидентов сюда не подставляется вовсе: маршрут
@ -55,11 +103,18 @@ def _render(infra_topic_line: str) -> dict:
"""
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
text = TMPL.read_text(encoding="utf-8")
rendered = (
text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
.replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
.replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
)
values = dict(_DUMMY_VALUES)
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line
if watchdog_ping_block is not None:
values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block
allowlist = _envsubst_allowlist()
rendered = text
for name, value in values.items():
if name in allowlist:
rendered = rendered.replace("${" + name + "}", value)
assert "${" not in rendered, (
"в отрендеренном конфиге остался литерал плейсхолдера — "
"значит в шаблоне появилась подстановка, о которой тест не знает"
@ -67,6 +122,37 @@ def _render(infra_topic_line: str) -> dict:
return yaml.safe_load(rendered)
def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None:
"""Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив.
`watchdog-ping` остаётся без единого `*_configs` валидный receiver,
Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда
не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел.
"""
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="")
receivers = {r["name"]: r for r in cfg["receivers"]}
assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига"
watchdog = receivers["watchdog-ping"]
assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs"
assert "telegram" in receivers and "telegram-clients" in receivers, (
"остальной алертинг не должен пострадать из-за пустого watchdog-секрета"
)
def test_watchdog_receiver_with_secret_gets_webhook() -> None:
"""Секрет задан — Watchdog реально пингует внешний deadman-приёмник."""
block = (
" webhook_configs:\n"
' - url: "https://hc-ping.com/dummy"\n'
" send_resolved: false"
)
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block)
receivers = {r["name"]: r for r in cfg["receivers"]}
hooks = receivers["watchdog-ping"].get("webhook_configs") or []
assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy"
assert hooks[0].get("send_resolved") is False
def _telegram_configs(cfg: dict) -> list[dict]:
out = []
for r in cfg.get("receivers", []):
@ -76,16 +162,16 @@ def _telegram_configs(cfg: dict) -> list[dict]:
def test_topic_lands_in_every_telegram_receiver() -> None:
"""Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
"""Единственный прямой получатель адресует ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
Получателей два `telegram` и `telegram-heartbeat`. До разделения тем оба
брали топик из одной переменной с клиентскими инцидентами, и тема «метрики»
(245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив»
будет капать мимо, и это заметят не сразу сюда же попадёт и весь
инфраструктурный шум.
До разделения тем `telegram` и `telegram-heartbeat` брали топик из одной
переменной с клиентскими инцидентами, и тема «метрики» (245) оставалась
пустой. С 17.09 (устранение шума Watchdog) прямой Telegram-получатель
остался один `telegram`; Watchdog теперь пингует внешний
deadman-приёмник вебхуком (`watchdog-ping`, без topic вовсе не Telegram).
"""
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}"
assert len(cfgs) >= 1, f"ожидался хотя бы один получатель telegram, найдено {len(cfgs)}"
for c in cfgs:
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"

View file

@ -114,7 +114,7 @@ def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None:
"""
text = TEMPLATE.read_text(encoding="utf-8")
block = text[text.index("- name: telegram-clients") :]
block = block[: block.index("- name: telegram-heartbeat")]
block = block[: block.index("- name: watchdog-ping")]
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
# У прочих приёмников прямой путь сохранён.

View file

@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
в этом же workflow только для Prometheus починка не пересоздание
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
пути заново, так что новый инод после `git reset --hard` подхватывается
без даунтайма.
в этом же workflow. `POST /-/reload` лечит правила они подключены
КАТАЛОГОМ. prometheus.yml же бинд-маунт ОДНОГО файла: reload перечитывает
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
поведение test_metrics_single_file_mounts.py), а promtool проверяет файлы С
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
подтверждён сменой lastConfigTime.
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
(2) reload и пересоздание ТОЛЬКО после успешной валидации, (3) шаг обязан
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
Grafana перечитываются, и отказ этого шага роняет деплой.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def _guard_start(text: str) -> int:
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
return guard
def test_promtool_checks_config_and_rules() -> None:
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
text = _text()
@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None:
def test_reload_is_guarded_by_the_promtool_check() -> None:
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
guard_start = _guard_start(text)
else_pos = text.index("else", guard_start)
reload_pos = text.index("localhost:9090/-/reload")
assert guard_start < reload_pos < else_pos, (
@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None:
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
guard_start = _guard_start(text)
else_pos = text.index("else", guard_start)
fi_pos = text.index("fi", else_pos)
else_branch = text[else_pos:fi_pos]
@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None:
assert "exit 1" in verify_block, (
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
)
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
prometheus.yml смонтирован одним файлом: `docker exec promtool check config`
в работающем контейнере читает СТАРЫЙ инод битая правка прошла бы проверку
и уехала бы в пересоздание.
"""
text = _text()
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
)
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
)
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
"""Пересоздание по иноду — после проверки promtool и до reload."""
text = _text()
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
recreate = re.search(
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
text,
)
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
else_pos = text.index("else", _guard_start(text))
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
"пересоздание Prometheus стоит не между успешной проверкой и reload"
)
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
v11.5.1 «Datasources config reloaded» (pkg/api/admin_provisioning.go).
"""
text = _text()
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
assert call != -1, "деплой не перечитывает датасорсы Grafana"
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
assert block, "результат перезагрузки датасорсов не разбирается"
ok, _, failed = block.group(1).partition(";;")
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"

View file

@ -0,0 +1,310 @@
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
POST /-/reload rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
job. Деплой зелёный, конфиг старый у отказа нет отрицательного признака (#3467).
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
postgres-экспортёров (#3486).
ЧТО ПРОВЕРЯЕТСЯ.
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
добавят новый тест покраснеет, пока путь не появится.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
SRC = "ops/metrics/loki/loki-config.yml"
DST = "/etc/loki/loki-config.yml"
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
# код из $FAKE_PS_RC;
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
exec)
view="$FAKE_VIEW/$1"
[ -f "$view" ] || exit 1
cat "$view"
;;
compose)
case " $* " in
*" ps "*)
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
cat "$FAKE_CID_FILE"
;;
*" up "*)
printf 'cid-new\n' > "$FAKE_CID_FILE"
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
;;
esac
;;
esac
exit 0
"""
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
STAT_SHIM = (
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
'exec /usr/bin/stat "$@"\n'
)
NO_SLEEP = "#!/bin/sh\nexit 0\n"
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
def _gnu_stat() -> bool:
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
@pytest.fixture
def host(tmp_path: Path) -> Path:
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
_write_exec(bin_dir / "sleep", NO_SLEEP)
if not _gnu_stat():
_write_exec(bin_dir / "stat", STAT_SHIM)
view = tmp_path / "view"
view.mkdir()
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _ino(tree: Path) -> int:
return os.stat(tree / SRC).st_ino
def _stale(tree: Path) -> None:
"""Контейнер держит прежний инод — как после `git reset --hard`."""
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
[
"sh",
str(tree / "ops" / "metrics" / SCRIPT.name),
"gendesign-metrics",
"docker-compose.metrics.yml",
"loki",
SRC,
DST,
],
cwd="/", # скрипт обязан сам встать в корень дерева
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _recreates(calls: list[str]) -> list[str]:
return [c for c in calls if "--force-recreate" in c]
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_fresh_inode_touches_nothing(host: Path) -> None:
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
_stale(host)
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
recreates = _recreates(calls)
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
assert any(c.startswith("exec cid-new ") for c in calls), (
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
)
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
_stale(host)
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
(host.parent.parent / "view" / "cid-old").unlink()
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
assert rc == 0, out
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
def test_not_running_container_is_left_alone(host: Path) -> None:
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
(host.parent.parent / "cid").write_text("", encoding="utf-8")
rc, out, calls = _run(host)
assert rc == 0, out
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
_stale(host)
rc, out, calls = _run(host, FAKE_PS_RC="1")
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
assert not _recreates(calls), calls
def test_missing_host_file_is_not_success(host: Path) -> None:
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
(host / SRC).unlink()
rc, out, _calls = _run(host)
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
# Производные файлы: их рендерит сам деплой, в git их нет.
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
RENDERED = {
"ops/metrics/alertmanager/alertmanager.yml",
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
}
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
COMPOSE_FILES = {
"docker-compose.metrics.yml": "gendesign-metrics",
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
}
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
if compose_name == "docker-compose.metrics.yml":
return ["server"]
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
def _commands(job: str) -> str:
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта содержит его формулировку дословно.
"""
spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп"
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
found = []
for service, body in spec["services"].items():
for vol in body.get("volumes") or []:
m = MOUNT.match(vol) if isinstance(vol, str) else None
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
continue
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
return found
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
script_call = re.search(
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
commands,
)
forced = re.search(
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
)
return script_call or forced
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
project = COMPOSE_FILES[compose_name]
mounts = _file_mounts(compose_name)
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
missing = []
for service, src, dst, profiles in mounts:
for job in _jobs_for(compose_name, profiles):
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
if not hit:
missing.append(f"{job}: {service} {src}{dst}")
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
assert not missing, (
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
)
def test_gate_sees_the_known_mounts() -> None:
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
for exporter in ("gendesign", "tradein", "infra"):
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
def test_gate_would_notice_a_mount_without_arrival() -> None:
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
commands = _commands("server")
assert not _arrival(
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"

View file

@ -1,198 +0,0 @@
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
Проверяет:
- Успешный UPSERT 3 features 3 строки вставлены
- Повторный вызов ON CONFLICT обновляет, не дублирует
- Feature без geometry строка вставлена с geom=NULL, без краша
- Feature без zone_id синтетический fallback zone_id используется
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.services.cadastre.bulk_harvest import _save_territorial_zones
def _make_feature(
feature_id: Any = "zone_1",
zone_code: str = "Ж-1",
zone_name: str = "Жилая смешанная",
permitted_use: str = "ИЖС",
has_geometry: bool = True,
) -> dict:
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
geom = (
{
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090100.0, 7590000.0],
[6090100.0, 7590100.0],
[6090000.0, 7590100.0],
[6090000.0, 7590000.0],
]
],
}
if has_geometry
else None
)
return {
"id": feature_id,
"geometry": geom,
"properties": {
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
},
}
def _make_db_mock() -> MagicMock:
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
db = MagicMock()
# begin_nested() используется как context manager
savepoint_ctx = MagicMock()
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
savepoint_ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = savepoint_ctx
return db
class TestSaveTerritorialZones:
"""Тесты для _save_territorial_zones."""
def test_three_features_inserted(self) -> None:
"""3 features → returned count == 3, execute вызван 3 раза."""
db = _make_db_mock()
features = [
_make_feature("z1", "Ж-1"),
_make_feature("z2", "ОД-1"),
_make_feature("z3", "П-1"),
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 3
assert db.execute.call_count == 3
db.commit.assert_called_once()
def test_empty_features_list(self) -> None:
"""Пустой список → 0 inserted, commit всё равно вызван."""
db = _make_db_mock()
result = _save_territorial_zones(db, "66:41:0204016", [])
assert result == 0
db.execute.assert_not_called()
db.commit.assert_called_once()
def test_feature_without_geometry_no_crash(self) -> None:
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
db = _make_db_mock()
features = [_make_feature("zone_no_geom", has_geometry=False)]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
# Проверяем что geom параметр передан как None
call_kwargs: dict = db.execute.call_args[0][1]
assert call_kwargs["geom"] is None
def test_feature_without_zone_id_uses_fallback(self) -> None:
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"zone_code": "Ж-2"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
zone_id: str = call_kwargs["zone_id"]
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
assert zone_id.startswith("66:41:0204016_")
suffix = zone_id.split("_", 3)[-1]
assert len(suffix) == 12
assert all(c in "0123456789abcdef" for c in suffix)
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
db2 = _make_db_mock()
_save_territorial_zones(db2, "66:41:0204016", features)
call_kwargs2 = db2.execute.call_args[0][1]
assert call_kwargs2["zone_id"] == zone_id
def test_zone_id_from_props_id(self) -> None:
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
db = _make_db_mock()
features = [
{
"id": None,
"geometry": None,
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
}
]
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 1
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["zone_id"] == "props_id_42"
def test_execute_error_logged_not_raised(self) -> None:
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
db = _make_db_mock()
db.execute.side_effect = RuntimeError("DB error")
features = [_make_feature("z_err")]
# Не должен бросить исключение
result = _save_territorial_zones(db, "66:41:0204016", features)
assert result == 0
db.commit.assert_called_once()
def test_savepoint_used_per_row(self) -> None:
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
db = _make_db_mock()
features = [_make_feature(f"z{i}") for i in range(3)]
_save_territorial_zones(db, "66:41:0204016", features)
assert db.begin_nested.call_count == 3
def test_quarter_cad_param_passed(self) -> None:
"""quarter_cad правильно передаётся в SQL параметры."""
db = _make_db_mock()
features = [_make_feature("zone_check")]
_save_territorial_zones(db, "66:41:9999999", features)
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["quarter_cad"] == "66:41:9999999"
def test_raw_props_serialized(self) -> None:
"""raw_props — JSON строка из properties dict."""
import json
db = _make_db_mock()
features = [
{
"id": "z_props",
"geometry": None,
"properties": {"zone_code": "ОД-2", "extra": "value"},
}
]
_save_territorial_zones(db, "66:41:0204016", features)
call_kwargs = db.execute.call_args[0][1]
raw = json.loads(call_kwargs["raw_props"])
assert raw["zone_code"] == "ОД-2"
assert raw["extra"] == "value"

View file

@ -0,0 +1,103 @@
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
Фрагменты дословный OCR пяти прод-документов из test_2464_act_date_citation
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера из самих PDF:
1413, 259, 2687, 863, 1504. Помехи в тех же текстах «Решение Думы 60/1»
(Генплан) и «Приказ Министерства 746-П»: их номер браться не должен.
Тест зовёт `extract_izyatie_records` путь загрузчика и краснеет на origin/main
неверным значением (None вместо номера), а не ошибкой сигнатуры.
"""
from __future__ import annotations
import pytest
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
from app.services.scrapers.page_reservation_parser import extract_reservations
from tests.services.scrapers.test_2464_act_date_citation import (
ДОКЕРЕМЕШАННЫЙ,
ДОКЕРЕМЕШАННЫЙ_2,
ДОКРЯМОЙ,
ДОК_РАЗВЯЗКА,
ДОКНЕРГОСТРОИТЕЛЕЙ,
ЗАГОЛОВОК,
КАД,
)
ПЯТЬОКУМЕНТОВ = [
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
pytest.param(ДОКНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
pytest.param(ДОКЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
pytest.param(ДОКЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
pytest.param(ДОКРЯМОЙ, "1504", "2019-06-24", id="татищева"),
]
def апись(текст: str) -> dict:
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
return записи[0]
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬОКУМЕНТОВ)
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
чья дата стоит в act_date, а не Решения Думы или приказа Министерства."""
запись = апись(текст)
assert запись["act_number"] == номер
assert запись["act_date"] == дата
@pytest.mark.parametrize(("текст", "номер", "ата"), ПЯТЬОКУМЕНТОВ)
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, ата: str) -> None:
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
Там номер первое «» в тексте. В двух документах первыми идут « 60/1» и
« 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
"""
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
def test_citations_alone_give_no_number() -> None:
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
текст = (
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
"области от 30.12.2021 № 746-П"
)
assert апись(текст)["act_number"] is None
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
Первое подходящее «» в тексте «746», дата при этом у постановления 1413:
в строке оказалась бы пара реквизитов от двух разных документов.
"""
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
запись = апись(текст)
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
следующего « -ПП» номера цитируемого постановления Правительства."""
текст = (
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
"В соответствии с постановлением Правительства Свердловской области "
f"от 26.02.2020 № 100-ПП\n{КАД}"
)
assert extract_reservations(текст)[0].act_number == "1234"
def test_regional_suffix_still_extracted() -> None:
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
assert апись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
assert апись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"

View file

@ -0,0 +1,246 @@
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
медиане в 39 раз.
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
на машине без базы, и объявлен в skip_allowlist.txt.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _postgis_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
return True, ""
return False, "нет расширения postgis"
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _postgis_reachable()
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
pytestmark = pytest.mark.skipif(
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
)
_SCHEMA = [
"""CREATE TEMP TABLE domrf_kn_objects (
obj_id bigint, comm_name text, dev_name text, obj_class text,
obj_class_fallback text, latitude double precision, longitude double precision,
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_complex_mapping (
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
"""CREATE TEMP TABLE complexes (
id bigint, canonical_name text, latitude double precision,
longitude double precision) ON COMMIT DROP""",
"""CREATE TEMP TABLE complex_sources (
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_lots (
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
"""CREATE TEMP TABLE objective_corpus_room_month (
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
]
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
_DATA = [
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
"""INSERT INTO domrf_kn_objects VALUES
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
'Строящиеся', CURRENT_DATE),
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
CURRENT_DATE),
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
"""INSERT INTO complexes VALUES
(10, 'ЖК Мичуринский', 56.840, 60.600),
(20, 'ЖК VEER PARK', 56.850, 60.620),
(30, 'СтудияПарк', 56.830, 60.580),
(50, 'Квартал "Татлин"', 56.835, 60.610),
(60, 'Парковый', 56.855, 60.590),
(70, 'Роща', 56.825, 60.630),
(71, 'Роща Парк', 56.8259, 60.630),
(80, 'ЖК Сосны', 56.860, 60.650)""",
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
"""INSERT INTO complex_sources VALUES
(10, 'objective', 'Мичуринский'),
(20, 'objective', 'Clever Park'),
(30, 'objective', 'Студия Парк'),
(50, 'objective', 'Квартал Татлин'),
(60, 'objective', 'Парковый квартал'),
(70, 'objective', 'Роща'),
(71, 'objective', 'Роща Парк'),
(80, 'objective', 'Клён'),
(80, 'objective', 'Сосны')""",
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
# complex_id; два своих лота новые — complex_id у них NULL.
"""INSERT INTO objective_lots VALUES
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
('Мичуринский', NULL, 120000),
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
('Clever Park', NULL, 150000),
('Студия Парк', NULL, 90000),
('Явный', NULL, 200000),
('Квартал Татлин', NULL, 95000),
('Парковый квартал', NULL, 130000),
('Роща Парк', NULL, 105000),
('Клён', NULL, 250000),
('Сосны', NULL, 115000)""",
"""INSERT INTO objective_corpus_room_month VALUES
('Мичуринский', date_trunc('month', now())::date, 30),
('Малахит', date_trunc('month', now())::date, 300),
('Clever Park', date_trunc('month', now())::date, 90),
('Студия Парк', date_trunc('month', now())::date, 15),
('Явный', date_trunc('month', now())::date, 60),
('Квартал Татлин', date_trunc('month', now())::date, 18),
('Парковый квартал', date_trunc('month', now())::date, 12),
('Роща Парк', date_trunc('month', now())::date, 21),
('Клён', date_trunc('month', now())::date, 240),
('Сосны', date_trunc('month', now())::date, 27)""",
]
@pytest.fixture(scope="module")
def result() -> tuple[dict[int, float], dict[int, float]]:
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
from app.services.site_finder.competitors import (
_COMPETITORS_SQL,
_OBJECTIVE_PRICE_FALLBACK_SQL,
_VELOCITY_MATCH_RADIUS_M,
)
session = sessionmaker(bind=create_engine(_dsn()))()
try:
for stmt in _SCHEMA + _DATA:
session.execute(text(stmt))
vel_rows = session.execute(
_COMPETITORS_SQL,
{
"center_lon": 60.6,
"center_lat": 56.84,
"radius_m": 5000.0,
"time_window_months": 3.0,
"window_interval": "3 months",
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
price_rows = session.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
).mappings()
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
return velocity, price
finally:
session.rollback()
session.close()
def test_gapfill_velocity_counts_only_own_project(result) -> None:
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
velocity, _ = result
assert velocity[MICH] == pytest.approx(10.0), velocity
def test_gapfill_price_is_median_of_own_lots(result) -> None:
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
_, price = result
assert price[MICH] == pytest.approx(110000.0), price
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
velocity, price = result
assert velocity[VEER] == 0.0, velocity
assert VEER not in price, price
def test_space_difference_still_matches(result) -> None:
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
velocity, price = result
assert velocity[STUDIO] == pytest.approx(5.0), velocity
assert price[STUDIO] == pytest.approx(90000.0), price
def test_explicit_mapping_unchanged(result) -> None:
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
velocity, price = result
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
assert price[EXPLICIT] == pytest.approx(200000.0), price
def test_quotes_inside_name_still_match(result) -> None:
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
друга. 18 сделок / 3 = 6, цена 95 тыс.
"""
velocity, price = result
assert velocity[TATLIN] == pytest.approx(6.0), velocity
assert price[TATLIN] == pytest.approx(95000.0), price
def test_project_name_longer_than_object_name_matches(result) -> None:
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
velocity, price = result
assert velocity[PARK] == pytest.approx(4.0), velocity
assert price[PARK] == pytest.approx(130000.0), price
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
"""
velocity, price = result
assert velocity[GROVE] == pytest.approx(7.0), velocity
assert price[GROVE] == pytest.approx(105000.0), price
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
Без этого DISTINCT ON брал любой из двух (здесь «Клён»), сверка его отвергала,
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
"""
velocity, price = result
assert velocity[PINES] == pytest.approx(9.0), velocity
assert price[PINES] == pytest.approx(115000.0), price

View file

@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None:
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.objects_total_in_radius == 1
# после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id
assert resp.data_quality.objects_total_in_radius == 0
assert resp.data_quality.raw_objects_total == 1
@pytest.mark.parametrize(
("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"),
[
([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ
([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ
([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка
],
ids=["all-filtered", "no-velocity", "normal"],
)
def test_objects_total_is_coverage_denominator_in_every_branch(
filter_ids: list[int],
vel_obj_ids: list[int] | None,
expected_total: int,
expected_with_data: int,
) -> None:
"""#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках.
UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть
знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой
ответ отдавал число ДО фильтра (2), две другие ветки после.
"""
id_rows = [_obj_id_row(20), _obj_id_row(30)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else []
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids))
dq = resp.data_quality
assert dq.objects_total_in_radius == expected_total
assert dq.objects_with_velocity_data == expected_with_data
assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем
expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0
assert dq.velocity_coverage_pct == pytest.approx(expected_pct)
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────

View file

@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
db = MagicMock()
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
# grid-walk фаза корректно пропускается (тест про
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
# db.execute().mappings().first() в этом тесте.
db.execute = MagicMock(
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
db = MagicMock()
# phase_state = None → начинаем с нуля
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
db = MagicMock()
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
# grid-walk фаза пропускается (тесты про snapshot /
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
# db.execute().mappings().first() вызовы.
db.execute = MagicMock(
@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
db = _mock_db_grid_bbox()
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
progress_states: list[dict[str, Any]] = []
@ -1557,6 +1556,42 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
assert progress_states[-1]["phase"] == "done"
@pytest.mark.asyncio
async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None:
"""#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один
запрос к НСПД (search_by_quarter) отдельного grid-walk за ПЗЗ больше нет."""
from app.services.cadastre.bulk_harvest import harvest_quarter
snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[_make_parcel_feature()],
meta_counts={},
)
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=snapshot)
progress_states: list[dict[str, Any]] = []
with (
patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert,
patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox,
):
mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0)
mock_upsert.return_value = dict.fromkeys(
("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0
)
await harvest_quarter(
db=_mock_db_grid_bbox(),
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=progress_states.append,
)
assert [c[0] for c in client.mock_calls] == ["search_by_quarter"]
assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"]
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────

View file

@ -144,6 +144,29 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
# только для машины без базы.
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
# ── #3583: гео-радиусная цена участка (complex_sources → project_name) ────────
# Тот же случай, что #2962: гоняет НАСТОЯЩИЙ parcels._GEO_RADIUS_PRICE_SQL на
# временных таблицах, нужен PostGIS. В CI идёт и пропуститься не может (CI=true
# выключает skipif). Запись только для машины без базы.
tests/api/v1/test_3583_geo_radius_price_project_bridge.py::test_median_counts_own_projects_of_nearby_complexes
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции

View file

@ -0,0 +1,134 @@
"""Починка разбора номера не удваивает land_reservation (#2982).
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
строка (участок, «1413») с (участок, NULL) не конфликтует без очистки прогон
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
формы: боевые данные не читаются и не меняются.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _db_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
c.execute(text("SELECT 1"))
return True, ""
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
_TABLE = """
CREATE TEMP TABLE land_reservation (
id bigserial PRIMARY KEY,
cad_num text NOT NULL,
reservation_kind text NOT NULL,
basis_act text NOT NULL,
act_number text,
act_date date,
purpose text,
doc_url text,
source text NOT NULL DEFAULT 'page_pdf',
is_active boolean NOT NULL DEFAULT true,
raw_excerpt text,
fetched_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
) ON COMMIT DROP;
"""
КАД = "66:41:0303004:22"
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
return {
"cad_num": cad,
"reservation_kind": "изъятие",
"basis_act": "Сообщение о планируемом изъятии",
"act_number": act,
"act_date": "2022-05-27",
"purpose": None,
"doc_url": url,
"source": "izyatie_ekb_ocr",
"raw_excerpt": None,
}
@pytest.fixture
def db():
engine = create_engine(_dsn())
session = sessionmaker(bind=engine)()
try:
session.execute(text(_TABLE))
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
yield session
finally:
session.rollback()
session.close()
engine.dispose()
def _rows(db) -> list[tuple[str, str | None, str]]:
return [
(r[0], r[1], r[2])
for r in db.execute(
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
).all()
]
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
_upsert_records(db, [_row(act=None)])
assert _rows(db) == [(КАД, None, ДОК)]
assert _upsert_records(db, [_row(act="1413")]) == 1
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
def test_same_parcel_in_another_document_is_kept(db) -> None:
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
_upsert_records(db, [_row(act="259", url=другой)])
_upsert_records(db, [_row(act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
def test_other_parcels_of_same_document_are_kept(db) -> None:
"""Контроль: чистка идёт по участку, а не по всему документу."""
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
_upsert_records(db, [_row(act="1413")])
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]

View file

@ -0,0 +1,38 @@
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
значению: включить запись = осознанно поправить этот тест после #3307.
"""
from __future__ import annotations
from unittest.mock import patch
_BLOCKED_TASKS = (
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
)
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
from app.workers import beat_schedule
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
schedule = beat_schedule.build_beat_schedule()
# Контроль: статическая часть расписания реально построилась, иначе
# отсутствие задач ниже было бы зелёным по построению.
assert schedule["refresh-quarter-price-index"]["task"] == (
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
)
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
scheduled = {
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
}
assert scheduled == {}, (
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
)

View file

@ -1919,17 +1919,17 @@ export interface paths {
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
*
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
* объектов до next beat fire).
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
* Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для
* свежесозданных объектов).
*
* - max_objects=None дефолтный лимит таска (300).
* - max_objects=3 smoke-тест.
* - force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
*
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
* re-trigger без явного подтверждения оператора запрещён.
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
* запуск без явного подтверждения оператора запрещён (условие #3307).
*/
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
delete?: never;
@ -1960,9 +1960,9 @@ export interface paths {
* - max_flats=3 smoke-тест.
* - force=True 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
*
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
* того же WAF-бана.
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
* блокировкой StormWall (условие #3307).
*/
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
delete?: never;

View file

@ -10,9 +10,8 @@
# script says so loudly instead of the silence that let backups break for
# weeks undetected before (#71).
#
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
# Telegram once a backup is already known to be stale.
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
#
# Usage (cron — one line per sentinel, run more often than the backup itself
# so a stale state is caught promptly; hourly is a reasonable default for a
@ -27,9 +26,10 @@
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
# >> /var/log/gendesign-backup-staleness.log 2>&1
#
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
# Alert channel: notify() in ops/lib-backup.sh — creds in
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first
# two, logs only.
#
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
# used as a plain healthcheck by anything that just wants the exit code).

View file

@ -22,7 +22,11 @@
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
# этом хосте нет, — алерты молча не уходят никуда.
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
@ -33,11 +37,11 @@
# file-level копия работающего узла может оказаться рассогласованной. Время —
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
# чтобы три задачи не дрались за диск и сеть одновременно.
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
# сутки плюс запас на сдвиг прогона.
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
# ── Уборка докера ───────────────────────────────────────────────────────────
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их

View file

@ -0,0 +1,93 @@
# Source-of-truth copy of the Forgejo compose file.
#
# Forgejo is NOT part of the automated deploy pipeline (deploy.yml only
# manages /opt/gendesign via `git reset --hard origin/main` on the main and
# obsidian stacks). Forgejo lives separately at /home/gendesign/forgejo on
# the VM and is a plain directory there — NOT a git checkout — so changes
# here do not auto-apply. Sync manually:
#
# scp ops/forgejo/docker-compose.yml gendesign:/home/gendesign/forgejo/docker-compose.yml
# ssh gendesign "cd /home/gendesign/forgejo && docker compose up -d --force-recreate forgejo"
#
# `up -d --force-recreate` (not `restart`) is required: Forgejo generates
# app.ini from the FORGEJO__* env vars via /usr/local/bin/environment-to-ini
# at container start, and `restart` does not re-read `environment:` from a
# changed compose file (see docker-compose pitfall #1 in devops CLAUDE.md).
#
# Config keys verified 2026-09-16 against the running image
# (codeberg.org/forgejo/forgejo:10, Forgejo 10.0.3+gitea-1.22.0) by
# extracting Go struct tags from the binary (`strings` on
# /app/gitea/gitea) and by a dry-run of environment-to-ini in a scratch
# dir inside the container (no prod files touched). Do not re-derive these
# from memory — a wrong key is silently ignored (empty section) and
# creates a false sense of safety.
#
# Dotted section names ([cron.archive_cleanup]) must be encoded as
# `_0x2E_` in the env var per the container's own
# `environment-to-ini --help` (confirmed empirically, see PR description).
services:
forgejo:
image: codeberg.org/forgejo/forgejo:10
container_name: forgejo
restart: unless-stopped
environment:
USER_UID: 1000
USER_GID: 1000
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: infra-postgres:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASS}
FORGEJO__server__DOMAIN: git.gendsgn.ru
FORGEJO__server__ROOT_URL: https://git.gendsgn.ru/
FORGEJO__server__SSH_PORT: 2222
FORGEJO__server__SSH_LISTEN_PORT: 22
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__actions__DEFAULT_ACTIONS_URL: "github"
# Actions Log/artifact retention — was unset (Forgejo defaults), which
# let CI run logs/artifacts accumulate indefinitely. Artifact
# retention checked against .forgejo/workflows + .github/workflows on
# 2026-09-16: no workflow uploads/downloads artifacts today, so 14d
# cannot break a cross-job dependency. Revisit this comment if a
# workflow starts using actions/upload-artifact.
FORGEJO__actions__LOG_RETENTION_DAYS: "30"
FORGEJO__actions__ARTIFACT_RETENTION_DAYS: "14"
# Repo-archive cache cleanup — was entirely absent (no
# [cron.archive_cleanup] section), so it ran on Forgejo's own default
# schedule (once every 24h, deleting archives older than 24h). That
# let an external crawler hitting /<owner>/<repo>/archive/<ref>
# balloon the cache to ~48GB/145GB disk before the daily sweep caught
# up (see PR #3534, which closed the path in Caddy as the primary
# fix). This is the second line of defense if that Caddy rule is ever
# removed: run hourly, evict anything older than 1h.
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__ENABLED: "true"
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__RUN_AT_START: "true"
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__SCHEDULE: "@every 1h"
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__OLDER_THAN: "1h"
FORGEJO__security__INSTALL_LOCK: "true"
volumes:
- ./data/forgejo:/data
# Container log cap. Measured 2026-09-17: this container's json log had
# grown to 3.35 GiB in 23 days (~150 MB/day) with no rotation at all,
# the single largest log on the host by two orders of magnitude. Docker's
# json-file driver never rotates unless told to, and there is no
# /etc/docker/daemon.json on this VM to set a global default. 50m x 3
# caps this container at 150 MB; recreating it also drops the old
# unbounded file.
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
ports:
- "2222:22"
networks:
- gendesign_default
networks:
gendesign_default:
external: true
name: gendesign_default

View file

@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY=
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
#TELEGRAM_BOT_TOKEN=
#TELEGRAM_CHAT_ID=
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
#BACKUP_ENV_FILE=/etc/default/gendesign-backup

View file

@ -27,6 +27,17 @@
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
# --- оповещения (Telegram) ---
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
# the policy's Resource path exactly

View file

@ -24,13 +24,8 @@
#
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
# /etc/default/gendesign-uptime — deliberately a separate config so backup
# alerting doesn't depend on the uptime watchdog's env file existing).
# SAME variable names as ops/gendesign-uptime.default.example — point both
# files at the same bot/chat if you want one Telegram destination for
# everything, or use different bots/chats if you'd rather split the noise.
# Without these two set, ops/check-backup-staleness.sh still logs, just
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
# Without the first two set, ops/check-backup-staleness.sh still logs, just
# doesn't send a Telegram alert.
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
@ -41,9 +36,14 @@
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
# --- missed-run alerting (Telegram) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
# чат: notify() читает env-файл, только если их нет в окружении.
#TELEGRAM_TOPIC_ID=245
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and

View file

@ -1,28 +0,0 @@
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
#
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
# VPS outage):
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
# sudo chmod 600 /etc/default/gendesign-uptime
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
#
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
# it still logs up/down but sends no alert (useful for a dry run first).
#
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
# --- Telegram alerting (both required to enable alerts) ---
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
#TELEGRAM_CHAT_ID=123456789
# --- optional overrides (defaults are sensible; uncomment only to change) ---
#BASE_URL=https://gendsgn.ru
#STATE_FILE=/var/tmp/gendesign-uptime-state
#CURL_TIMEOUT=15
#RETRIES=2
#RETRY_SLEEP=5
# Custom check list (newline-separated "label|path|expected_status"):
#CHECKS="health|/health|200
#market-pulse|/api/v1/analytics/market-pulse|200"

View file

@ -5,9 +5,7 @@
# SOURCED, not executed directly — no shebang execution of its own. Inherits
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
# notify() when Telegram vars are actually set — curl is already a hard
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
# new dependency).
# notify() when Telegram vars are actually set).
#
# Load with (script computes its own dir so this works regardless of cron's
# CWD or which repo subdir the caller lives in):
@ -18,14 +16,8 @@
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- notify ------------------------------------------------------------
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
# backup alerting doesn't depend on the uptime watchdog's config being
# present, and vice versa. Point both files at the same bot/chat if you want
# one Telegram destination for everything — that's an ops choice, not this
# script's concern.
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
#
# No-op (logs only) when unset — this is the extension point: to wire a
# different channel later, edit ONLY this function; every caller in this repo
@ -153,12 +145,9 @@ sentinel_age_hours() {
}
# --- transition-tracked alert state --------------------------------------
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# A flat "<label> <status>" file, one line per label, so repeated runs alert
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
# single run — avoids Telegram spam from an hourly staleness-check cron.
# Intentionally a separate, independent implementation (not shared code with
# uptime-healthcheck.sh) — that script is out of scope for this change.
backup_prev_status() {
local state_file="$1" label="$2" v
[[ -f "$state_file" ]] || { echo "unknown"; return; }

View file

@ -57,6 +57,7 @@ import html
import json
import logging
import os
import re
import secrets
import threading
import time
@ -76,6 +77,16 @@ TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}"
# Значения секретных query-параметров в логе — `***` (#3576). GlitchTip шлёт
# секрет только в `?secret=`, а строка запроса целиком уходит в access-log. То
# же выражение, что у бэкенда МЕРЫ (tradein-mvp/backend/app/core/log_scrub.py,
# #3154) и у Alloy (#3354); импортировать нельзя — сервис без зависимостей.
_SENSITIVE_QUERY = re.compile(
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
r"[^&\s\"'<>]+",
re.IGNORECASE,
)
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
_PENDING: dict[str, dict] = {}
_LOCK = threading.Lock()
@ -319,7 +330,9 @@ class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
log.info("%s %s", self.address_string(), fmt % args)
# Сюда сходятся и строка доступа (log_request), и ошибки разбора запроса
# (log_error: «Bad request syntax ('POST /glitchtip?secret=…')») — маскируем здесь.
log.info("%s %s", self.address_string(), _SENSITIVE_QUERY.sub(r"\1***", fmt % args))
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
self.send_response(code)

View file

@ -15,11 +15,11 @@
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
# инцидент не равны по срочности, а смешанные в одной теме они обучают
# пролистывать обе. Поэтому `telegram` и `telegram-heartbeat` ниже адресуют
# ИНФРАСТРУКТУРНУЮ тему (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя
# `telegram-clients` в этом списке нет: он не шлёт в Telegram напрямую, а
# вебхуком уходит в alert-ack, и тему адресует сам, своей переменной
# METRICS_TELEGRAM_TOPIC_ID.
# пролистывать обе. Поэтому `telegram` ниже адресует ИНФРАСТРУКТУРНУЮ тему
# (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя `telegram-clients` в этом
# списке нет: он не шлёт в Telegram напрямую, а вебхуком уходит в alert-ack, и
# тему адресует сам, своей переменной METRICS_TELEGRAM_TOPIC_ID. `watchdog-ping`
# тоже не шлёт в Telegram вовсе — см. комментарий у его маршрута ниже.
global:
resolve_timeout: 5m
@ -35,14 +35,35 @@ route:
repeat_interval: 6h
routes:
# Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
# это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
- receiver: telegram-heartbeat
# Watchdog — «сторож сторожа», горит ВСЕГДА по построению (`vector(1)`,
# см. infra.yml). Раньше уходил в Telegram раз в 12ч — 14 сообщений в
# неделю ни о чём, и именно они приучили пролистывать инфра-тему: 17.09
# настоящий DiskWillFillIn24h утонул между Watchdog и вечно горящим
# NoActiveCeleryWorkers, диск дошёл до 84% незамеченным.
#
# ПОЧЕМУ ВНЕШНИЙ DEADMAN-ПРИЁМНИК, А НЕ «РЕЖЕ» И НЕ ОТДЕЛЬНАЯ ТЕМА.
# Увеличенный интервал по-прежнему кладёт человеку регулярное сообщение —
# просто реже, и его тоже рано или поздно начнут пролистывать. Отдельная
# техническая тема — это ещё один chat_id/topic_id и ещё один канал,
# за которым НАДО СПЕЦИАЛЬНО следить, то есть тот же человеческий цикл,
# сдвинутый в другое место. Внешний deadman-приёмник (healthchecks.io и
# аналоги) устроен наоборот: Alertmanager молча шлёт HTTP-пинг на каждый
# Watchdog, и пока пинги идут — сервис МОЛЧИТ. Он заговорит (email/свой
# alert) только когда пинг ПЕРЕСТАНЕТ приходить, то есть ровно когда
# канал доставки умер, — это и есть смысл «сторожа сторожа», без единого
# штатного сообщения человеку. Полностью выключать эту проверку нельзя —
# remove бы всей ветки Watchdog это и сделал.
#
# METRICS_WATCHDOG_PING_URL пока НЕ заведён на хосте (нужен аккаунт
# healthchecks.io/аналога) — до тех пор webhook будет молча падать по
# DNS/сети, Alertmanager это тихо ретраит; человека это не касается ни
# раньше, ни теперь.
- receiver: watchdog-ping
matchers:
- alertname = "Watchdog"
group_wait: 0s
group_interval: 12h
repeat_interval: 12h
group_interval: 5m
repeat_interval: 5m
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
@ -63,6 +84,20 @@ route:
group_wait: 10s
repeat_interval: 30m
# Эскалация по длительности (AlertFiringTooLong, prometheus/rules/infra.yml)
# — сигнал о том, что какую-то другую тревогу не заметили или на неё
# забили дольше 6 часов. Она НЕ про клиентский инцидент, но обязана быть
# заметнее обычной инфраструктуры, поэтому уходит в ту же тему, где
# владелец бывает чаще, а не смешивается с общим потоком severity=critical
# ниже. Матчим по имени, а не по host="apps": исходная тревога может
# быть про любой хост, и врать в лейбле не стоит (см. инвариант host
# у alert:app в infra.yml).
- receiver: telegram-clients
matchers:
- alertname = "AlertFiringTooLong"
group_wait: 10s
repeat_interval: 30m
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
- receiver: telegram
matchers:
@ -117,14 +152,17 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
- url: "http://alert-ack:8080/alertmanager"
send_resolved: true
- name: telegram-heartbeat
telegram_configs:
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
api_url: "https://api.telegram.org"
parse_mode: HTML
send_resolved: false
message: |
⚪ <b>Мониторинг жив</b> — сторож отчитался, канал доставки работает.
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
# Watchdog выше. Блок целиком (не значение) подставляется деплоем в
# METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у
# METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет
# условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит
# ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный
# Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает
# уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url
# (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится:
# непустой ключ с пустым значением или литералом плейсхолдера амtool валит
# целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не
# только Watchdog.
- name: watchdog-ping
${METRICS_WATCHDOG_PING_BLOCK}

View file

@ -133,6 +133,29 @@
}
},
{
"type": "timeseries",
"title": "Конверсия оценка → заявка, скользящие 7 суток",
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
"legendFormat": "заявки / оценки"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{

View file

@ -63,40 +63,85 @@ groups:
- name: host
interval: 60s
rules:
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ
# (host=infra). Проценты на дисках разного размера значат разное —
# host=apps держит /-раздел ~910 ГБ, те же 85 % там это ещё ~135 ГБ
# запаса, а на infra (145 ГБ) — почти ничего. Порог переведён в
# АБСОЛЮТНЫЕ ГБ свободного места: он одинаково осмыслен на любом диске,
# потому что напрямую отвечает на вопрос «сколько времени есть до нуля
# при текущей скорости утечки», а не «какая доля занята».
#
# `mountpoint="/"` — намеренное сужение с прежнего «все fstype кроме
# tmpfs/overlay»: /boot и /boot/efi по обоим хостам меньше 1 ГБ
# целиком, с порогом 30/15 ГБ они бы горели ПОСТОЯННО (проверено живыми
# метриками 16.09 — infra:/boot/efi 98 МБ, apps:/boot 553 МБ). Данные
# приложения и Postgres лежат на "/", туда и целится алерт.
- alert: DiskSpaceLow
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
/ 1073741824 < 30
for: 15m
labels:
severity: warning
annotations:
summary: "Диск занят больше 85 %"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
summary: "Свободно на диске меньше 30 ГБ"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ."
- alert: DiskSpaceCritical
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
/ 1073741824 < 15
for: 5m
labels:
severity: critical
annotations:
summary: "Диск почти кончился"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
summary: "Свободно на диске меньше 15 ГБ"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается."
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
# Скорость, а не уровень. Ловит именно ту утечку, что была 16.09: пока
# DiskSpaceLow/Critical ещё не сработали (места вагон), но оно тает
# быстрее ~1 ГБ/ч устойчиво — это уже течь, а не органический рост.
# `deriv()` — линейная регрессия по 15-минутному окну (не мгновенная
# разница двух точек, ту дёргает шум). Знак минус спереди и деление
# переносят результат из "Б/с, отрицательное при убыли" в "ГБ/ч,
# положительное когда тает" — чтобы $value в тексте читался нормально
# (не "-2.1 ГБ/ч"). `for: 30m` поверх 15-минутного окна регрессии
# требует НЕПРЕРЫВНОЙ убыли около часа, чтобы не будить на разовый
# скачок (бэкап, ротация логов).
- alert: DiskSpaceDepletingFast
expr: |
-deriv(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[15m])
* 3600 / 1073741824 > 1
for: 30m
labels:
severity: warning
annotations:
summary: "Диск пустеет быстрее ГБ в час"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободное место убывает на {{ printf \"%.1f\" $value }} ГБ/час устойчиво последние ~30 минут — независимо от того, сколько места осталось сейчас."
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в
# стену — но роль другая, чем у DiskSpaceDepletingFast выше. Тот ловит
# БЫСТРУЮ (>1 ГБ/ч) утечку рано, за счёт короткого 15-минутного окна.
# Этот ловит УМЕРЕННУЮ утечку (может быть медленнее 1 ГБ/ч), которую
# короткое окно не поймает, но которая всё равно приведёт к нулю в
# течение суток при текущем уровне занятости — окно 6h усредняет шум
# ценой более позднего срабатывания. На одной и той же быстрой утечке
# оба правила могут сработать (сначала это, следом то) — это
# ЗАДУМАННАЯ эскалация двумя разными сигналами (скорость сейчас →
# подтверждённый тренд на сутки), а не дублирующее письмо: тексты и
# время срабатывания разные. DiskSpaceLow/Critical выше добавляют
# третий, независимый от скорости сигнал — «места мало» само по себе,
# даже если утечки нет и ничего не тает быстро.
- alert: DiskWillFillIn24h
expr: |
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600))
/ 1073741824 > 0
for: 30m
labels:
severity: warning
annotations:
summary: "По текущему темпу диск кончится за сутки"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ."
- alert: MemoryPressure
expr: |
@ -215,6 +260,40 @@ groups:
- name: app
interval: 60s
rules:
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
# молчат по построению.
#
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
# тихо пропала из alloy-apps.alloy.
#
# for: 5m — по истории up{app="mera"} 26.0817.09 (61 857 точек): 140
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
#
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
# заводится в UI GlitchTip, а не кодом.
- alert: MeraBackendDown
expr: |
up{job="app", app="mera"} == 0
or absent(up{job="app", app="mera"})
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
@ -408,3 +487,44 @@ groups:
annotations:
summary: "WAL пишется быстрее 100 МБ/час"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
# ── Эскалация ──────────────────────────────────────────────────────────────
# Симптом 17.09: DiskWillFillIn24h пришёл вовремя и утонул между Watchdog
# (10080 интервалов firing за 7 суток — горит всегда по построению) и
# NoActiveCeleryWorkers (6229 интервалов) в общей ленте; диск дошёл до 84%
# незамеченным. Alertmanager сам по длительности не эскалирует — это
# правило Prometheus поверх служебной метрики ALERTS_FOR_STATE (unix-время
# входа тревоги в pending/firing, см. Robust Perception "The
# ALERTS_FOR_STATE metric").
- name: escalation
interval: 60s
rules:
# ИСКЛЮЧЕНИЯ В `alertname!~` ОБЯЗАТЕЛЬНЫ, а не для порядка:
# - Watchdog горит всегда по построению (`vector(1)` выше) — без
# исключения это правило унаследовало бы его вечный firing и стало
# ВТОРЫМ таким сигналом, то есть тем самым шумом, который лечим.
# - Сама AlertFiringTooLong — иначе, однажды сработав, она бы никогда
# не погасла: собственная ALERTS_FOR_STATE тоже старше порога, и
# правило продлевало бы себя бесконечно.
# Что это НЕ значит: если NoActiveCeleryWorkers (её чинит параллельная
# правка, здесь не трогаем) продолжит гореть дольше 6 часов, эта
# тревога сработает сразу после мержа — это ожидаемо и верно: она
# огонь реального незакрытого инцидента, а не вечная по построению.
#
# `label_replace(..., "stuck_alertname", "$1", "alertname", "(.+)")`
# ОБЯЗАТЕЛЕН, а не косметика: `alertname` — зарезервированный лейбл,
# Prometheus молча перезаписывает его именем ЭТОГО правила
# (AlertFiringTooLong) на выходе, каким бы ни было значение в expr.
# Без копии в `stuck_alertname` текст сообщения называл бы саму себя
# виновником, а не исходную тревогу.
- alert: AlertFiringTooLong
expr: |
label_replace(
(time() - ALERTS_FOR_STATE{alertname!~"Watchdog|AlertFiringTooLong"}) > 6*3600,
"stuck_alertname", "$1", "alertname", "(.+)"
)
labels:
severity: critical
annotations:
summary: "Тревога держится дольше 6 часов"
description: "{{ $labels.stuck_alertname }}{{ if $labels.host }} ({{ $labels.host }}){{ end }} непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."

View file

@ -115,3 +115,140 @@ tests:
exp_annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x9 0x40'
alert_rule_test:
- eval_time: 15m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
instance: tradein-backend:8000
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Окно деплоя: самый длинный провал за 26.0817.09 — 4 нулевые точки подряд
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
# поднимать тревогу про «Меру».
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x19 0 0 0 0 1x40'
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '0x64'
alert_rule_test:
- eval_time: 11m
alertname: MeraBackendDown
exp_alerts: []
- eval_time: 30m
alertname: MeraBackendDown
exp_alerts: []
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
- interval: 30s
input_series:
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 10m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Эскалация по длительности. `promtool test rules` держит одну общую шкалу
# времени и TSDB на весь файл — к 6.5 часам к этому моменту «зависшими»
# (input series предыдущих сценариев кончились, но absent()-условия по ним
# продолжают гореть) оказываются и другие тестовые тревоги файла, не только
# NoActiveCeleryWorkers из этого блока. Список ниже — ровно то, что
# реально вернул promtool (проверено запуском, не придумано): 8 тревог,
# держащихся дольше 6 часов. ГЛАВНАЯ ПРОВЕРКА в этом списке — то, чего в
# нём НЕТ: ни Watchdog (горит вечно с t=0 точно так же, но исключён
# матчером), ни сама AlertFiringTooLong (иначе была бы там на восьмое
# место и продлевала бы себя бесконечно). В 3 часа — рано, эскалации
# ещё быть не должно вовсе.
- interval: 1m
input_series:
- series: 'up{job="celery",host="apps"}'
values: '1x420'
alert_rule_test:
- eval_time: 3h
alertname: AlertFiringTooLong
exp_alerts: []
- eval_time: 6h30m
alertname: AlertFiringTooLong
exp_alerts:
- exp_labels:
severity: critical
stuck_alertname: MeraBackendDown
host: apps
job: app
app: mera
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "MeraBackendDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: HostAgentDown
host: apps
job: node
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "HostAgentDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: RemoteWriteStalled
host: apps
job: node
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "RemoteWriteStalled (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: CadvisorDown
job: cadvisor
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "CadvisorDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: QueueExporterDown
job: redis
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "QueueExporterDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: TradeInBackgroundContainerMissing
name: tradein-scraper
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: TradeInBackgroundContainerMissing
name: tradein-tgbot
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
- exp_labels:
severity: critical
stuck_alertname: NoActiveCeleryWorkers
exp_annotations:
summary: "Тревога держится дольше 6 часов"
description: "NoActiveCeleryWorkers непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."

View file

@ -0,0 +1,65 @@
#!/bin/sh
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
# (#3467, #3486).
#
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
#
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
# расхождении, чтобы не рвать работу на каждом деплое.
#
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
set -eu
if [ "$#" -ne 5 ]; then
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
exit 2
fi
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
cd "$(dirname "$0")/../.."
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
# «контейнер не запущен» — и зелёный выход со старым конфигом.
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
id=$(cid)
if [ -z "$id" ]; then
echo "$service: контейнер не запущен — старый инод $src держать некому."
exit 0
fi
want=$(stat -c %i "$src")
if [ "$(seen "$id")" = "$want" ]; then
echo "$service: $src доехал до контейнера (инод $want)."
exit 0
fi
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
i=0
while [ "$i" -lt 15 ]; do
id=$(cid)
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
echo "$service: пересоздан, $src доехал (инод $want)."
exit 0
fi
i=$((i + 1))
sleep 2
done
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
exit 1

View file

@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
#
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
# ops/lib-backup.sh (уведомления о бэкапах).
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
# мы узнали бы о любой другой поломке.
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"

View file

@ -1,171 +0,0 @@
#!/usr/bin/env bash
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
#
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
# this covers the case Kuma structurally can't.
#
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
# (up→down, down→up) — not every run — to avoid Telegram spam.
#
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
#
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
# TELEGRAM_BOT_TOKEN=123456:ABC...
# TELEGRAM_CHAT_ID=123456789
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
# A redacted template lives at ops/gendesign-uptime.default.example.
# Without a token set, the script still logs up/down but sends no alert.
set -euo pipefail
# --- config (env-overridable) ---
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
# shellcheck source=/dev/null
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
# Override the whole list via CHECKS env (same newline-separated format).
#
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
CHECKS="${CHECKS:-health|/health|200}"
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- telegram (no-op if token/chat unset) ---
notify() {
local text="$1"
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "NOTIFY (telegram disabled — no token/chat): $text"
return 0
fi
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
#
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
# тем вероятнее, что о них не сообщат.
#
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
# применяется к КАЖДОЙ попытке.
#
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
# запроса, так что повтор почти никогда не дублирует уже доставленное
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
local attempt
for attempt in 1 2 3; do
if curl -fsS --max-time "$CURL_TIMEOUT" \
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1; then
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
return 0
fi
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
done
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
}
# --- state helpers (last status per check) ---
prev_status() {
local label="$1"
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
# Line format: "<label> <status>". grep the latest for this label.
local v
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
echo "${v:-unknown}"
}
set_status() {
local label="$1" status="$2" tmp
tmp="$(mktemp)"
# Drop any prior line for this label, then append the fresh one.
if [[ -f "$STATE_FILE" ]]; then
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
fi
echo "${label} ${status}" >> "$tmp"
mv "$tmp" "$STATE_FILE"
}
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
probe() {
local url="$1" expect="$2" attempt code
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
# never need a `|| echo` fallback (which previously concatenated → "401000").
for attempt in $(seq 1 "$((RETRIES + 1))"); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
code="${code:-000}"
if [[ "$code" == "$expect" ]]; then
echo "up"
return 0
fi
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
done
# Report the last code seen for the alert body.
echo "down code=${code}"
return 0
}
# --- run ---
overall_rc=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
label="${line%%|*}"
rest="${line#*|}"
path="${rest%%|*}"
expect="${rest##*|}"
url="${BASE_URL}${path}"
result="$(probe "$url" "$expect")"
now="up"
[[ "$result" == up ]] || now="down"
prev="$(prev_status "$label")"
if [[ "$now" == "up" ]]; then
log "OK $label ($url)"
if [[ "$prev" == "down" ]]; then
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
fi
else
overall_rc=1
log "DOWN $label ($url) — $result"
# Alert on transition into down (or first-ever observation that is down).
if [[ "$prev" != "down" ]]; then
notify "🔴 DOWN: gendesign $label${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
fi
fi
set_status "$label" "$now"
done <<< "$CHECKS"
exit "$overall_rc"

View file

@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -516,16 +517,13 @@ async def cian_auto_login(
)
try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
async with build_browser_fetcher(
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
) as fetcher:
raw_cookies = await fetcher.login(
url=settings.cian_login_url,
email=email,
@ -537,6 +535,13 @@ async def cian_auto_login(
pre_click_selectors=settings.cian_login_pre_click_selectors,
wait_ms=settings.cian_login_wait_ms,
)
except NoProxyAvailableError as exc:
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
raise HTTPException(
status_code=503,
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
) from exc
except Exception as exc:
logger.error("cian auto-login failed: %s", type(exc).__name__)
raise HTTPException(
@ -1252,8 +1257,11 @@ async def start_avito_city_sweep(
request_delay_sec=payload.request_delay_sec,
enrich_imv=payload.enrich_imv,
)
except Exception:
except Exception as exc:
logger.exception("city-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()
@ -1344,8 +1352,11 @@ async def start_cian_city_sweep(
detail_top_n=payload.detail_top_n,
enrich_houses=payload.enrich_houses,
)
except Exception:
except Exception as exc:
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()
@ -1487,8 +1498,11 @@ async def start_cian_full_load(
resume_run_id=payload.resume_run_id,
secondary_only=payload.secondary_only,
)
except Exception:
except Exception as exc:
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally:
task_db.close()
@ -1590,8 +1604,11 @@ async def start_yandex_full_load(
concurrency=payload.concurrency,
resume_run_id=payload.resume_run_id,
)
except Exception:
except Exception as exc:
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally:
task_db.close()
@ -1658,8 +1675,11 @@ async def start_yandex_city_sweep(
request_delay_sec=payload.request_delay_sec,
enrich_address=payload.enrich_address,
)
except Exception:
except Exception as exc:
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally:
sweep_db.close()

View file

@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
from __future__ import annotations
import asyncio
import json
import logging
import secrets
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
_INTERACTIVE_SEND_DEADLINE_S = 7.0
class GlitchTipAttachment(BaseModel):
@ -234,19 +243,21 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# может легально висеть воркерные минуты ретраев.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramError:
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# может легально висеть воркерные минуты ретраев.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).

View file

@ -2,9 +2,16 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
trade_in_leads.
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
уходит сообщением в support-топик Telegram тот же бот и топик, что у веб-чата
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
Telegram не трогает ни ответ, ни лид остаётся лог и пустой `notified_at` как
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id
телефон оператор берёт из trade_in_leads.
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.support import _bot_configured
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
from app.core.db import SessionLocal, get_db
from app.observability.metrics import LEADS
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__)
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
return value
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
Идёт после ответа клиенту, поэтому ретраи клиента штатные воркерные, без
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
сессия запроса к этому моменту уже закрыта. Любой отказ только в лог.
"""
message = (
"Новая заявка на трейд-ин\n"
f"id: {lead_id}\n"
f"пользователь: {username or ''}\n"
f"оценка: {estimate_id or 'без привязки'}\n"
"Телефон — в trade_in_leads по id."
)
try:
await get_telegram_client().send_message(
chat_id=settings.telegram_support_chat_id,
text=message,
message_thread_id=settings.telegram_support_topic_id or None,
)
except Exception:
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
return
db = SessionLocal()
try:
db.execute(
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
{"id": lead_id},
)
db.commit()
except Exception:
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
finally:
db.close()
@router.post("/lead")
async def create_trade_in_lead(
payload: TradeInLeadInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
background_tasks: BackgroundTasks,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
@ -185,6 +232,15 @@ async def create_trade_in_lead(
_CONSENT_POLICY_VERSION,
)
if _bot_configured():
background_tasks.add_task(
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
)
else:
logger.warning(
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
)
return {
"id": row["id"],
"created_at": row["created_at"].isoformat(),

View file

@ -31,6 +31,7 @@ from app.schemas.trade_in import (
CoverageProbeInput,
CoverageProbeResponse,
DkpCorridor,
ExposureWindow,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseInfoForEstimate,
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
),
{"orig": str(estimate_id), "temp": str(temp_id)},
)
w = result.exposure_window # #2898: окно целиком или NULL целиком
db.execute(
text(
"""
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability,
est_days_p25 = :est_days_p25,
est_days_p50 = :est_days_p50,
est_days_p75 = :est_days_p75,
est_days_n = :est_days_n,
revival_completed_at = NOW()
WHERE id = CAST(:id AS uuid)
"""
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
"ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability,
"est_days_p25": w.p25_days if w else None,
"est_days_p50": w.p50_days if w else None,
"est_days_p75": w.p75_days if w else None,
"est_days_n": w.n if w else None,
},
)
db.execute(
@ -678,7 +688,8 @@ def load_estimate(
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,
relaxations, reliability
relaxations, reliability,
est_days_p25, est_days_p50, est_days_p75, est_days_n
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
AND {ESTIMATE_READABLE_SQL}
@ -718,6 +729,7 @@ def load_estimate(
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m,
)
@ -842,6 +854,19 @@ def load_estimate(
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
# ответ AttributeError'ом.
market_percentile=getattr(row, "market_percentile", None),
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
exposure_window=(
ExposureWindow(
p25_days=row.est_days_p25,
p50_days=row.est_days_p50,
p75_days=row.est_days_p75,
n=row.est_days_n,
)
if getattr(row, "est_days_n", None) is not None
else None
),
est_days_on_market=getattr(row, "est_days_p50", None),
period_months=12,
analogs=analogs,
actual_deals=actual_deals,
@ -896,6 +921,10 @@ def load_estimate(
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m,
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
)

View file

@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
CORRIDOR_CLAMP_MIN_N = 10
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -378,17 +384,6 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
# данные ЭТОГО адреса, а не на среднее по городу.
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
@ -408,25 +403,6 @@ class Settings(BaseSettings):
# False ⇒ прежнее поведение целиком, без релиза.
estimate_require_local_evidence: bool = True
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -521,128 +497,13 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
#
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
estimate_corridor_clamp_slack: float = 0.40
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
# к point И range (expected_sold_low/high/price) консистентно.
# False -> старое поведение без clamp (backward-compat).
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
estimate_expected_sold_le_asking: bool = Field(
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
)
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
estimate_sb_mad_k_small_n: float = 2.5
estimate_sb_small_n_threshold: int = 10
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
estimate_anchor_tier_c_corridor_mult: float = 1.5
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
estimate_outlier_small_n_threshold: int = 15
estimate_outlier_tukey_k_small: float = 1.0
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
estimate_manual_review_elite_ppm2: int = 250000
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
# building (building_cadastral_number | нормализованный address)
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
# («лот считается один раз»).
#
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
# 13.89%, coverage 83.33% → 83.33%, bias 3.83% → 3.83%, median width 0.743 →
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
estimate_dedup_analogs_enabled: bool = True
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -711,6 +572,16 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
estimate_unknown_attr_penalty_enabled: bool = False
estimate_unknown_attr_penalty_min_n: int = 5
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
estimate_tier_h_min_comps: int = 5
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
#
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
@ -725,20 +596,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
estimate_is_apartments_filter_enabled: bool = False
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
estimate_wide_corridor_threshold: float = 1.2
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
@ -763,26 +620,7 @@ class Settings(BaseSettings):
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
avito_imv_use_browser_fetcher: bool = False
# ── #764: per-cadastral-quarter price index correction ───────────────────
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → skip (location in median).
estimate_quarter_match_skip_ratio: float = 0.6
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
estimate_quarter_index_max_for_small_n: float = 2.0
estimate_quarter_index_small_n_threshold: int = 50
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
@ -971,7 +809,7 @@ class Settings(BaseSettings):
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
@ -1194,14 +1032,6 @@ class Settings(BaseSettings):
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
@ -1221,25 +1051,19 @@ class Settings(BaseSettings):
cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
# True = curl_cffi через settings.scraper_proxy_url.
#
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
# Прод переведён на браузер через docker-compose.prod.yml (environment
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
# других окружений вслепую.
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
# environment'ом сервиса scraper, а tradein-backend (ручка админки
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
avito_detail_backfill_use_curl: bool = Field(
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
)
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший

View file

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи.
"""
@ -163,9 +163,13 @@ class DkpCorridor(BaseModel):
[low,high]×slack добавляется текстовая пометка.
None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
из коридора, а поле обязано быть верным и на GET-rehydrate.
"""
count: int # число ДКП-сделок в выборке
@ -192,10 +196,13 @@ class DkpCorridor(BaseModel):
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей.
цену зона n=1..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
@ -207,9 +214,9 @@ class DkpCorridor(BaseModel):
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import settings # локально: schemas остаётся import-light
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
return self.count < settings.estimate_corridor_clamp_min_n
return self.count < CORRIDOR_CLAMP_MIN_N
class PriceTrendPoint(BaseModel):
@ -223,6 +230,21 @@ class PriceTrendPoint(BaseModel):
ppm2: int # медиана ₽/м² за месяц
class ExposureWindow(BaseModel):
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
"""
p25_days: int
p50_days: int
p75_days: int
n: int # сколько снятых объявлений в выборке
class AggregatedEstimate(BaseModel):
estimate_id: UUID
median_price_rub: int
@ -285,7 +307,11 @@ class AggregatedEstimate(BaseModel):
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
last_scraped_at: datetime | None = None
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
# #2898: окно срока экспозиции (p25p75) — показывать его, а не одно число.
exposure_window: ExposureWindow | None = None
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
est_days_on_market: int | None = None
cian_valuation: CianValuationSummary | None = None
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
@ -344,6 +370,9 @@ class AggregatedEstimate(BaseModel):
# "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
# восстанавливаются из подписи якорного блока в confidence_explanation
# (estimator.analog_tier_from_explanation), радиусные остаются null.
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:

View file

@ -67,8 +67,12 @@ class BlockRatioBreaker:
ratio_threshold: float
safety_min: int
snapshot_size: int
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
# площадки, рвут прогон. 0 — критерий выключен.
infra_streak_limit: int = 0
_window: deque[bool] = field(init=False, repr=False)
_consecutive_blocks: int = field(default=0, init=False)
_consecutive_infra: int = field(default=0, init=False)
_pure_block_run: bool = field(default=True, init=False)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
@ -106,10 +110,16 @@ class BlockRatioBreaker:
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
Свой предохранитель у такого отказа серия infra_streak_limit (#3439): без
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
1600, status='done'). Серию гасит любой ответ площадки успех, её блок, 404.
"""
if kind != BAN_KIND_PLATFORM:
self._consecutive_infra += 1
self.record_failure()
return
self._consecutive_infra = 0
self._consecutive_blocks += 1
self._window.append(True)
@ -117,6 +127,7 @@ class BlockRatioBreaker:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak()
self._consecutive_infra = 0
self._pure_block_run = False
self._window.append(False)
@ -131,8 +142,11 @@ class BlockRatioBreaker:
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула)."""
return
задачи -- своя калибровка, свои ограничения прокси-пула).
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
значит наш тракт жив."""
self._consecutive_infra = 0
def _flush_streak(self) -> None:
if self._consecutive_blocks:
@ -142,9 +156,11 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
"""
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
return "infra_streak"
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
@ -176,6 +192,11 @@ class BlockRatioBreaker:
should_abort(), так что в логи не попадает.
"""
reason = self.abort_reason()
if reason == "infra_streak":
return (
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
"без единого ответа площадки"
)
if reason == "ratio":
return (
f"доля блоков {self.window_blocks}/{self.window_len} в окне "

View file

@ -34,6 +34,7 @@ from typing import Any, Literal
from uuid import uuid4
from fastapi import HTTPException
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError,
IMVAuthError,
@ -55,7 +56,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings
from app.core.db import SessionLocal, run_db_thread
from app.schemas.trade_in import (
AggregatedEstimate,
@ -63,6 +64,7 @@ from app.schemas.trade_in import (
AvitoImvSummary,
CianValuationSummary,
DkpCorridor,
ExposureWindow,
PriceTrendPoint,
TradeInEstimateInput,
)
@ -283,6 +285,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
return None
# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier).
# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок
# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние.
# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём,
# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не
# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка
# ?id= показывала бы такой коридор как работающий.
# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся
# радиусные тиры на GET или подпись начнут переписывать мимо этой константы.
ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"}
ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"}
def analog_tier_from_explanation(explanation: str | None) -> str | None:
"""Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None.
Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того
же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой
путь цены коридор там клампит. None = радиусный путь либо старая формулировка.
"""
for tier, label in ANCHOR_TIER_LABELS.items():
if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""):
return ANCHOR_API_TIERS[tier]
return None
def rehydrate_search_radius_m(
relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None:
@ -316,7 +344,7 @@ def rehydrate_search_radius_m(
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor
# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor
# и Tier-C anchor gate, т.е. едет итоговая цена.
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
@ -416,6 +444,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
("премиум", float("inf")),
)
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
# число = правка здесь + регрессионный гейт бэктеста
# (tests/test_backtest_regression_gate.py).
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
WIDE_CORRIDOR_THRESHOLD = 1.2
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
# метаданные — median/expected_sold/ranges не трогают.
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
MANUAL_REVIEW_ELITE_PPM2 = 250_000
# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на
# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует
# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов:
# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет).
QUARTER_INDEX_MIN_N_DEALS = 10
# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога —
# локация квартала уже в медиане → skip.
QUARTER_MATCH_SKIP_RATIO = 0.6
# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают
# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия.
QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0
QUARTER_INDEX_SMALL_N_THRESHOLD = 50
# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные
# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал.
QUARTER_INDEX_FACTOR_MIN = 0.6
QUARTER_INDEX_FACTOR_MAX = 1.8
# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline
# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок
# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в
# app.core.config (его же читает DkpCorridor.advisory_only, #3452).
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack)
# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) →
# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без
# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию.
CORRIDOR_CLAMP_SLACK = 0.40
# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n):
# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10
# коридора: достаточно мягко для эконома, ловит явный undershoot.
RADIUS_FLOOR_FACTOR = 0.8
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо
# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх.
OUTLIER_SMALL_N_THRESHOLD = 15
OUTLIER_TUKEY_K_SMALL = 1.0
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
# для trade-in физически невозможен, это артефакт high-price бакета.
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
IMV_BLEND_WEIGHT = 0.5
IMV_BLEND_THRESHOLD = 1.15
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт 15-25%. v2
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
# по ppm²), hard guardrail и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
SB_FLOOR_SIGMA = 0.25
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
SB_MAD_K_SMALL_N = 2.5
SB_SMALL_N_THRESHOLD = 10
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
# FSD<0.15) проходят без изменений.
SB_GATE_MIN_N = 3
SB_GATE_MAX_FSD = 0.20
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -1988,15 +2117,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float:
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут
# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с
# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей
# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в
# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities
# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS
# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а
# deals.city хранит е-формы («Артемовский»).
_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset(
name.lower().replace("ё", "е")
for name in CITY_DISPLAY_NAMES.values()
if (region := regions_mod.region_by_city(name)) is None or region.code == 66
)
# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для
# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких
# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил»
# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ
# именам городов, но сохраняем ту же дисциплину сортировки, что и
# geocoder._OBLAST_CITY_RE).
_TARGET_CITY_RE = re.compile(
r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True))
+ "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True))
+ r")\b"
)
@ -2004,8 +2147,8 @@ _TARGET_CITY_RE = re.compile(
def _resolve_target_city(address_text: str | None) -> str | None:
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
границе слова/фразы работает и для forward-формата («Екатеринбург, ул.
Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города
развёрток) по границе слова/фразы работает и для forward-формата («Екатеринбург, ул.
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
важна. ёе нормализация ДО поиска (симметрично _normalize_building_key).
@ -2013,7 +2156,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
Returns None если ни один город не распознан (адрес вне известного
списка) caller (_fetch_dkp_corridor) откатывается на behaviour без
city-фильтра. NB: коридор НЕ чисто advisory при
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor +
n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor +
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
"""
if not address_text:
@ -2223,7 +2366,7 @@ def _fetch_dkp_corridor(
latest_deal = dd
ppm2_values = sorted(adjusted)
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
scope = "street"
@ -2592,7 +2735,7 @@ def _fetch_anchor_comps(
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
floor (int|None), total_floors (int|None) последние два для floor-веса (#680-WB).
"""
min_comps = settings.estimate_sb_min_comps
min_comps = SB_MIN_COMPS
# ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address)
@ -2975,12 +3118,8 @@ def _compute_same_building_anchor(
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
effective_mad_k = mad_k
if (
tier != "A"
and settings.estimate_sb_mad_k_small_n < mad_k
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
):
effective_mad_k = settings.estimate_sb_mad_k_small_n
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
effective_mad_k = SB_MAD_K_SMALL_N
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
if len(surviving_idx) < min_comps:
logger.info(
@ -3394,7 +3533,7 @@ def _price_from_inputs(
"""
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
# по кросс-постам.
listings = _dedup_cross_source(listings)
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
@ -3591,32 +3730,28 @@ def _price_from_inputs(
area_target=area_m2,
rooms_target=rooms,
tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma,
rooms_boost=settings.estimate_sb_rooms_match_boost,
sigma=SB_AREA_SIGMA,
rooms_boost=SB_ROOMS_MATCH_BOOST,
floor_target=floor,
total_floors_target=total_floors,
floor_sigma=settings.estimate_sb_floor_sigma,
min_comps=settings.estimate_sb_min_comps,
mad_k=settings.estimate_sb_mad_k,
floor_sigma=SB_FLOOR_SIGMA,
min_comps=SB_MIN_COMPS,
mad_k=SB_MAD_K,
)
# #1795 шаг 3: гейт Tier C.
if (
anchor is not None
and anchor_tier == "C"
and settings.estimate_anchor_tier_c_corridor_mult > 0
):
if anchor is not None and anchor_tier == "C":
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
else:
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
logger.info(
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
" → keep radius median (anchor suppressed)",
int(anchor["anchor_ppm2"]),
settings.estimate_anchor_tier_c_corridor_mult,
ANCHOR_TIER_C_CORRIDOR_MULT,
int(gate_threshold),
)
anchor = None
@ -3624,10 +3759,7 @@ def _price_from_inputs(
# #audit-1: low-confidence gate.
if anchor is not None:
gate_low = anchor["confidence"] == "low"
gate_thin = (
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
if gate_low or gate_thin:
logger.info(
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
@ -3667,13 +3799,13 @@ def _price_from_inputs(
anchor_comps_used = anchor.get("comps") or anchor_comps
est_ppm2 = anchor["anchor_ppm2"]
# PREMIUM GUARDRAIL (hard).
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
if est_ppm2 < floor_ppm2:
est_ppm2 = floor_ppm2
new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * area_m2)
# FSD-диапазон.
half = settings.estimate_fsd_k * anchor["fsd"]
half = FSD_K * anchor["fsd"]
new_range_low = int(point * max(0.0, 1.0 - half))
new_range_high = int(point * (1.0 + half))
# Спред комплов.
@ -3699,10 +3831,10 @@ def _price_from_inputs(
range_low = new_range_low
range_high = new_range_high
confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
# #695: explanation описывает ИМЕННО якорные комплы.
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
explanation = (
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
) + repair_note
# #695 (QA fixup): n_analogs по anchor-популяции.
@ -3726,7 +3858,7 @@ def _price_from_inputs(
# #1871 P2: split-дома wide-corridor disclosure.
if anchor_tier == "A" and median_price > 0:
corridor_pct = (range_high - range_low) / median_price
if corridor_pct > settings.estimate_wide_corridor_threshold:
if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом "
@ -3830,8 +3962,8 @@ def _price_from_inputs(
area=area_m2,
anchor_total=anchor_total,
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
weight=IMV_BLEND_WEIGHT,
threshold=IMV_BLEND_THRESHOLD,
market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None
),
@ -3843,7 +3975,7 @@ def _price_from_inputs(
median_price,
new_median,
anchor_used,
settings.estimate_imv_blend_weight,
IMV_BLEND_WEIGHT,
range_high,
new_range_high,
)
@ -3911,15 +4043,15 @@ def _price_from_inputs(
# Bimodal/nominal guard (Guard-4).
if (
target_qi > settings.estimate_quarter_index_max_for_small_n
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
):
logger.info(
"quarter_index: bimodal guard triggered "
"(index=%.3f n=%d < %d) for %s — no-op",
target_qi,
target_n_deals,
settings.estimate_quarter_index_small_n_threshold,
QUARTER_INDEX_SMALL_N_THRESHOLD,
target_quarter,
)
else:
@ -3944,11 +4076,11 @@ def _price_from_inputs(
if lot_quarters_for_guard2
else 0.0
)
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
logger.info(
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
same_quarter_ratio,
settings.estimate_quarter_match_skip_ratio,
QUARTER_MATCH_SKIP_RATIO,
target_quarter,
)
else:
@ -4001,8 +4133,8 @@ def _price_from_inputs(
base_range_high=range_high,
target_index=target_qi,
avg_analog_index=avg_analog_index,
min_factor=settings.estimate_quarter_index_factor_min,
max_factor=settings.estimate_quarter_index_factor_max,
min_factor=QUARTER_INDEX_FACTOR_MIN,
max_factor=QUARTER_INDEX_FACTOR_MAX,
)
analogs_with_qi = sum(
1 for lq, _lp in analog_quarters if lq in analog_index_map
@ -4024,7 +4156,7 @@ def _price_from_inputs(
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
slack = settings.estimate_corridor_clamp_slack
slack = CORRIDOR_CLAMP_SLACK
if dkp_raw is not None and median_ppm2 > 0:
old_ppm2 = median_ppm2
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
@ -4036,7 +4168,7 @@ def _price_from_inputs(
corridor_count=dkp_raw["count"],
anchor_tier=anchor_tier,
slack=slack,
min_n=settings.estimate_corridor_clamp_min_n,
min_n=CORRIDOR_CLAMP_MIN_N,
)
if clamped:
logger.info(
@ -4059,9 +4191,9 @@ def _price_from_inputs(
and dkp_raw is not None
and dkp_raw.get("low_ppm2", 0) > 0
and median_ppm2 > 0
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N
):
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR
if median_ppm2 < radius_floor_ppm2:
floor_factor = radius_floor_ppm2 / median_ppm2
logger.info(
@ -4069,7 +4201,7 @@ def _price_from_inputs(
" → lifting (factor=%.3f)",
int(median_ppm2),
dkp_raw["low_ppm2"],
settings.estimate_radius_floor_factor,
RADIUS_FLOOR_FACTOR,
int(radius_floor_ppm2),
floor_factor,
)
@ -4089,7 +4221,7 @@ def _price_from_inputs(
if asking_to_sold_ratio is not None and median_price > 0:
effective_ratio = asking_to_sold_ratio
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
if effective_ratio > 1.0:
logger.info(
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
effective_ratio,
@ -4132,7 +4264,7 @@ def _price_from_inputs(
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
# the headline so the calibrated PI range below wraps the clamped point.
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
if settings.estimate_expected_sold_le_asking and expected_sold_price:
if expected_sold_price:
expected_sold_price = min(expected_sold_price, median_price)
if expected_sold_per_m2:
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
@ -4214,7 +4346,7 @@ def _price_from_inputs(
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
# own MAD-clip drops comps below SB_MIN_COMPS), it returns None
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline"
@ -5166,14 +5298,14 @@ async def estimate_quality(
return _lookup_quarter_index(
db,
quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
)
def _qis_lookup(qs: list[str]) -> dict[str, float]:
return _lookup_quarter_indexes(
db,
quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
)
# ── Deterministic pricing orchestration ──────────────────────────────────
@ -5311,10 +5443,11 @@ async def estimate_quality(
estimate_id = uuid4()
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
# попадание в неё пишется явной строкой: число оценок за сутки —
# Зона n=1..9 на экране неотличима от работающего коридора, поэтому
# попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный
# коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки —
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
if dkp_corridor is not None and dkp_corridor.advisory_only:
@ -5322,7 +5455,7 @@ async def estimate_quality(
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
estimate_id,
dkp_corridor.count,
settings.estimate_corridor_clamp_min_n,
CORRIDOR_CLAMP_MIN_N,
(dkp_raw or {}).get("scope", "unknown"),
target_city or "unknown",
)
@ -5385,6 +5518,19 @@ async def estimate_quality(
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
source_counts = _source_counts([lot.source for lot in analogs_lots])
freshness_pre = _compute_freshness_minutes(metadata_lots)
# #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц
# покажет то же окно, что в момент расчёта, а не пересчёт по новой истории.
exposure_window = _exposure_window(
await run_db_thread(
_fetch_exposure_days,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=search_radius_m,
)
)
# DaData enrichment (PR Q1) — заполняется только если service отработал.
# При DaData = None все колонки идут в DB как NULL (graceful).
dadata_metro_json = (
@ -5414,6 +5560,7 @@ async def estimate_quality(
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis,
relaxations, reliability,
est_days_p25, est_days_p50, est_days_p75, est_days_n,
created_by,
expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot
@ -5437,6 +5584,7 @@ async def estimate_quality(
:expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis,
CAST(:relaxations_json AS jsonb), :reliability,
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
:created_by,
:expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version,
@ -5497,6 +5645,11 @@ async def estimate_quality(
# пропадал при открытии оценки по сохранённой ссылке (?id=).
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
"reliability": reliability,
# #2898: окно пишем целиком или NULL целиком (n < порога).
"est_days_p25": exposure_window.p25_days if exposure_window else None,
"est_days_p50": exposure_window.p50_days if exposure_window else None,
"est_days_p75": exposure_window.p75_days if exposure_window else None,
"est_days_n": exposure_window.n if exposure_window else None,
"created_by": created_by,
"expires_at": expires_at,
**_estimate_consent_persist_fields(require_consent, client_ip),
@ -5573,10 +5726,8 @@ async def estimate_quality(
# radius-путь: analog_tier "W" → "city", остальные → "district".
# None только если нет аналогов (median=0, insufficient_data=True).
if median_price > 0:
if anchor_tier == "A":
api_analog_tier: str | None = "same_building"
elif anchor_tier == "C":
api_analog_tier = "micro_radius"
if anchor_tier in ANCHOR_API_TIERS:
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
elif analog_tier == "W":
api_analog_tier = "city"
else:
@ -5641,7 +5792,6 @@ async def estimate_quality(
confidence,
range_low,
range_high,
settings,
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
asking_ppm2=int(median_ppm2),
@ -5670,7 +5820,8 @@ async def estimate_quality(
data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at,
price_trend=price_trend,
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
exposure_window=exposure_window,
est_days_on_market=exposure_window.p50_days if exposure_window else None,
cian_valuation=(
CianValuationSummary(
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
@ -5781,8 +5932,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
None) premium_building флаг не выставится, даже если дом объективно элитный по
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости
`estimate_manual_review_high_value_rub` (expected_sold_price порога) и
`estimate_manual_review_elite_ppm2` (asking /м² порога) независимо от
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price порога) и
`MANUAL_REVIEW_ELITE_PPM2` (asking /м² порога) независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции.
"""
@ -5819,7 +5970,6 @@ def _manual_review(
confidence: str,
range_low: int | None,
range_high: int | None,
settings: Settings,
asking_ppm2: int | None = None,
) -> tuple[bool, list[str]]:
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
@ -5849,15 +5999,13 @@ def _manual_review(
reasons: list[str] = []
if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
high_value = settings.estimate_manual_review_high_value_rub
if expected_sold_price and expected_sold_price >= high_value:
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
reasons.append("высокая стоимость (≥20 млн ₽)")
if confidence == "low":
reasons.append("низкая уверенность оценки")
wide_ratio = settings.estimate_manual_review_wide_range_ratio
if range_low and range_high and range_high / range_low >= wide_ratio:
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
reasons.append("широкий диапазон цены")
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
reasons.append(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
@ -5865,23 +6013,88 @@ def _manual_review(
return (bool(reasons), reasons)
def _estimate_days_on_market(
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
) -> int | None:
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция
# снятых объявлений — house_placement_history.exposure_days.
#
# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка
# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%.
# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109).
EXPOSURE_WINDOW_MIN_N = 30
EXPOSURE_WINDOW_MONTHS = 24
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
(наши парсеры не всегда его отдают честно показываем «нет данных»).
def _exposure_window(days: list[int]) -> ExposureWindow | None:
"""Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N.
method="inclusive" та же линейная интерполяция, что percentile_cont в
sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу.
"""
values = [
int(lot["days_on_market"])
for lot in (*listings, *deals)
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
]
if len(values) < 3:
if len(days) < EXPOSURE_WINDOW_MIN_N:
return None
values.sort()
return values[len(values) // 2]
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
return ExposureWindow(
p25_days=int(p25 + 0.5),
p50_days=int(p50 + 0.5),
p75_days=int(p75 + 0.5),
n=len(days),
)
def _fetch_exposure_days(
db: Session,
*,
lat: float | None,
lon: float | None,
rooms: int,
area: float,
radius_m: int,
) -> list[int]:
"""exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE
в домах в радиусе подбора аналогов. Best-effort: ошибка [] (окна нет).
ponytail: без дедупа avito_imv/yandex_valuation прод 17.09: у 926 из 9482
yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет.
"""
if lat is None or lon is None or not area:
return []
try:
# SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию,
# в которой следом идёт INSERT оценки.
with db.begin_nested():
rows = db.execute(
text(
"""
SELECT h.exposure_days
FROM house_placement_history h
JOIN houses ho ON ho.id = h.house_id
WHERE ST_DWithin(
ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
)
AND h.rooms = :rooms
AND h.area_m2 BETWEEN :area_min AND :area_max
AND h.exposure_days IS NOT NULL
AND h.removed_date IS NOT NULL
AND h.removed_date <= CURRENT_DATE
AND h.removed_date > CURRENT_DATE
- make_interval(months => CAST(:months AS integer))
"""
),
{
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms": rooms,
"area_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE),
"months": EXPOSURE_WINDOW_MONTHS,
},
).all()
except Exception as exc:
logger.warning("exposure_window lookup failed (graceful): %s", exc)
return []
return [int(r[0]) for r in rows]
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
@ -6551,8 +6764,9 @@ def _fetch_analogs(
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
Tier A) + ST_DWithin(radius_m). Если 3 результатов tier='S'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
Если 5 результатов возвращаем; tier='H'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
±estimate_tier_h_floors_tol (0.30). Если estimate_tier_h_min_comps (5)
результатов возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны.
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
@ -6747,12 +6961,12 @@ def _fetch_analogs(
)
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
# ── Tier H: same class (year ±15, total_floors ±30%) ────────────────────
# ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings)
if year_built is not None and total_floors is not None:
year_min = year_built - 15
year_max = year_built + 15
tf_min = math.floor(total_floors * 0.7)
tf_max = math.ceil(total_floors * 1.3)
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
tier_h_rows = (
db.execute(
@ -6860,7 +7074,7 @@ def _fetch_analogs(
min_n=settings.estimate_unknown_attr_penalty_min_n,
)
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
if len(tier_h) >= 5:
if len(tier_h) >= settings.estimate_tier_h_min_comps:
logger.info(
"analogs tier=H year=%d±15 tf=%d-%d%d results",
year_built,
@ -7328,15 +7542,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
)
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
_DEDUP_STREET_NAME_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE,
)
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
@ -7348,10 +7565,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE,
)
# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица
# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в
# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков»
# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков»
# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома.
_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile(
rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+",
flags=re.IGNORECASE,
)
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
@ -7537,6 +7763,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
# «Минометчиков» должны схлопываться в один канонический токен.
s = s.replace("ё", "е")
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
m = _DEDUP_STREET_NAME_RE.search(s)
@ -7655,8 +7882,7 @@ def _union_find_phys_dedup(
не дедупятся остаются уникальными.
include_price=True статистический ключ (влияет на n_analogs/median/cv,
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
_dedup_cross_source). include_price=False display-only ключ без
вызывающий _dedup_cross_source). include_price=False display-only ключ без
price_bucket (см. _dedup_display_lots) ценовой дрейф между кросс-постами
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
пары для merge (require_diff_source) без цены единственный оставшийся
@ -7777,13 +8003,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
median/cv/source_counts/sources_used (представитель несёт свой source лот
считается один раз; source кросс-поста, не ставший представителем, выпадает
из выборки по нему нет независимой ценовой точки). Флаг
estimate_dedup_analogs_enabled OFF no-op (байт-идентичный проход, нужно
для frozen backtest regression gate). Ключ включает price_bucket
из выборки по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
снят (#2378) — бэктест #1966 OFF vs ON был
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%), дедуп меняет
только честность счёта n_analogs. Ключ включает price_bucket
(_lot_dedup_components include_price=True по умолчанию) см.
_union_find_phys_dedup.
"""
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
if len(lots) < 2:
return lots
return _union_find_phys_dedup(lots, include_price=True)
@ -7829,9 +8056,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
случай: перевыставили на другой площадке дешевле/дороже) без такого
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
статистикой (median/n_analogs), этот только честностью UI-карточек и
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
Независим от _dedup_cross_source: тот управляет статистикой
(median/n_analogs), этот только честностью UI-карточек. НЕ трогает
n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены
из отдельного (не обязательно совпадающего по итоговой длине) пула.
"""
@ -7843,10 +8069,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small несколько элитных
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL несколько элитных
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
тянут радиусную медиану вверх. threshold=0 выключено (старое поведение).
тянут радиусную медиану вверх.
"""
if len(lots) < 5:
return lots # на маленькой выборке нечего фильтровать
@ -7856,11 +8082,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
return lots
tukey_k = 1.5
if (
settings.estimate_outlier_tukey_k_small < 1.5
and len(prices) < settings.estimate_outlier_small_n_threshold
):
tukey_k = settings.estimate_outlier_tukey_k_small
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
tukey_k = OUTLIER_TUKEY_K_SMALL
q1 = _percentile(prices, 0.25)
q3 = _percentile(prices, 0.75)

View file

@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки.
#
# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону
# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил
# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов
# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с
# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона.
_MATCH_SQL = text(
"""
WITH gar_scope AS (
SELECT
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
FROM gar_house_flats
WHERE flat_count > 0
AND tradein_canon_addr(norm_address) <> ''
@ -622,15 +628,15 @@ _MATCH_SQL = text(
),
gar_ranked AS (
SELECT
canon, house_guid, flat_count,
canon, house_guid, flat_count, region_code,
ROW_NUMBER() OVER (
PARTITION BY canon ORDER BY flat_count DESC, house_guid
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
) AS rn,
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
FROM gar_scope
),
gar_pick AS (
SELECT canon, house_guid, flat_count
SELECT canon, house_guid, flat_count, region_code
FROM gar_ranked
WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
@ -644,10 +650,33 @@ _MATCH_SQL = text(
WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address)
) = gp.canon
AND CAST(h.region_code AS text) = gp.region_code
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
"""
)
# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам
# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid
# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и
# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем.
_CLEAR_FOREIGN_REGION_SQL = text(
"""
UPDATE houses h
SET gar_house_guid = NULL,
gar_flat_count = NULL,
gar_matched_at = NULL,
gar_match_method = NULL
FROM gar_house_flats g
WHERE g.house_guid = h.gar_house_guid
AND h.gar_match_method = 'canon_addr'
AND CAST(h.region_code AS text) <> g.region_code
AND (
CAST(:region AS text) IS NULL
OR CAST(h.region_code AS text) = CAST(:region AS text)
)
"""
)
def match_houses_to_gar(
db: Session,
@ -666,11 +695,14 @@ def match_houses_to_gar(
"""
if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code)
cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount
logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
"gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d"
" (region=%s, city=%s)",
matched,
cleared,
region_code,
city_filter,
)

View file

@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
Словарь `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
резолвит) т.е. новый город области в любом случае добавляется СЮДА, и
гейт хинта не создаёт новой связности.
(`_names_non_ekb_city` его не увидит) т.е. новый город области в любом
случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
(`estimator._resolve_target_city` дополнительно знает города развёрток,
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по

View file

@ -25,30 +25,31 @@ WHAT this is:
«улица Вайнера, 66» share one cluster_key. Rows whose canon is NULL/blank are never clustered
(cluster_key NULL ignored). Only canons shared by >1 house_id form a cluster.
GEO GUARD (anti over-merge, CANON PASS ONLY #2187): because the canon strips город/район,
GEO GUARD (anti over-merge, BOTH PASSES #2690): because the canon strips город/район,
two different buildings with the same street+number in different region-66 towns (e.g. «Ленина
5») would share a canon. To avoid merging them, within a canon-cluster a house is a LOSER only
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-canon houses
>250 m away or with NULL geom on either side are left as separate rows (conservative). The
FIAS pass deliberately SKIPS this guard: a shared ФИАС/ГАР UUID IS the building identity and
strictly outranks geo-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).
5») would share a canon. To avoid merging them, within a cluster a house is a LOSER only
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-key houses
>250 m away or with NULL geom on either side are left as separate rows (conservative).
The FIAS pass used to skip this guard (#2187, «a shared ФИАС UUID IS the building identity»).
That premise is false: house_fias_id is DaData's answer to OUR address string (KEY section
below), so a street+number without a town gets the same UUID as the same street+number in
another town. Prod run 2026-09-05 (house_merge_log, merge_pass='fias'): 408 merges, 129 of
them >250 m apart, 53 >3 km, worst 362 km («р-н Синарский, улица Кирова, 4» of
Каменск-Уральский into Екатеринбург), plus 67 with no coordinates on a side.
TWO PASSES (2026-07-02 follow-up): the SAME clusterkeeperre-pointcarry-identitydelete
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
(`_mapping_sql`) no 300-line copy-paste:
1. FIAS pass clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
«Сулимова, 3/2» same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
colliding with a same-named ЕКБ street).
cluster. Catches duplicates the canon MISSES: посёлок truncation (Кедровка/Шувакиш
colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse
(«Сулимова, 32» vs «Сулимова, 3/2» same canon «сулимова32»).
2. CANON pass the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but
DIFFERENT house_fias_id provably different buildings the canon collapsed (the
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to the
CANON pass ONLY (#2187): the FIAS pass merges on UUID identity regardless of geom — a
боевой прогон left 130 same-fias groups (335 houses, 1 529 listings) split because the
guard blocked them (NULL geom on a side, or >250 m from a broken coordinate).
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to
BOTH passes (#2690; the fias-pass exemption of #2187 is reverted — see GEO GUARD).
IDENTITY CARRY-OVER (each pass, BEFORE deleting losers): the keeper's NULL identity / geo-QC
fields are filled from its losers with COALESCE semantics (keeper value wins; donor = the
@ -114,9 +115,9 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
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.
distance_m is recorded on BOTH passes. It is what exposed the fias pass merging without a
proximity check (#2690, 2026-09-05): «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
@ -224,8 +225,8 @@ _KEEPER_ORDER = f"""
# cluster key differs, parametrised into `_mapping_sql`, so there is NO copy-paste of the pipeline.
#
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
# truncation (Кедровка/Шувакиш). Only non-null fias clusters.
# duplicates the canon misses — посёлок truncation (Кедровка/Шувакиш) and, until migration 322
# (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters.
_FIAS_KEY_EXPR = """
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
THEN 'fias:' || lower(house_fias_id)
@ -311,35 +312,15 @@ def _ranked_cte(cluster_key_case: str) -> str:
)"""
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
def _mapping_sql(cluster_key_case: str) -> 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).
row shares one fias by construction). So does the 250 m GEO GUARD on BOTH passes (#2690):
neither key is independent of our address string, so neither may merge what proximity rejects.
`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)}
@ -350,16 +331,22 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
--
-- 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.
-- distance_m is what showed the fias pass merging 362 km apart while it had no guard (#2690).
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}
AND id <> keeper_id
-- GEO GUARD (both passes #2690). Neither cluster key tells towns apart: the canon strips
-- город/район, and house_fias_id is DaData's answer to that same town-less address. 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, or NULL geom on either side, left as separate rows (conservative).
AND keeper_geom IS NOT NULL
AND loser_geom IS NOT NULL
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250
AND NOT (
NULLIF(loser_fias, '') IS NOT NULL
AND NULLIF(keeper_fias, '') IS NOT NULL
@ -368,12 +355,10 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""
# Canon-pass mapping — geo guard ON (cross-town over-merge protection for street+number canons).
# Canon-pass mapping.
_BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# Fias-pass mapping — same pipeline, clustered by the ФИАС building UUID (runs first). Geo guard
# OFF (#2187): a shared ГАР UUID IS the building identity and outranks proximity — same-fias rows
# 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))
# Fias-pass mapping — same pipeline and same guards, clustered by the ФИАС UUID (runs first).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR))
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
#
@ -870,7 +855,6 @@ def _run_merge_pass(
*,
build_sql: Any,
pass_label: str,
geo_guard: bool,
batch_id: str,
run_id: int | None,
initiator: str,
@ -911,7 +895,7 @@ def _run_merge_pass(
"run_id": run_id,
"initiator": initiator,
"merge_pass": pass_label,
"geo_guard": geo_guard,
"geo_guard": True, # both passes are guarded since #2690
},
)
@ -1032,8 +1016,8 @@ def merge_duplicate_houses(
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
1. FIAS pass cluster by lower(NULLIF(house_fias_id, '')) (catches slash-collapse /
посёлок canon bugs the address canon misses).
2. CANON pass cluster by canonical address (250 m geo guard + cross-fias anti-over-merge
guard).
2. CANON pass cluster by canonical address.
Both passes carry the 250 m geo guard and the cross-fias anti-over-merge guard.
Each pass: pick keeper re-point children (UNIQUE-collision-safe) carry identity onto the
keeper delete losers backfill sources/aliases. BOTH passes run in ONE transaction.
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
@ -1057,7 +1041,6 @@ def merge_duplicate_houses(
db,
build_sql=_BUILD_MAPPING_SQL_FIAS,
pass_label="fias",
geo_guard=False,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,
@ -1068,7 +1051,6 @@ def merge_duplicate_houses(
db,
build_sql=_BUILD_MAPPING_SQL,
pass_label="canon",
geo_guard=True,
batch_id=batch_id,
run_id=run_id,
initiator=initiator,

View file

@ -357,7 +357,23 @@ async def _job_landing_showcase_deals(
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
ctx.runs.mark_done(db, run_id, counters)
# #3511: пустая витрина — не успех. Результатного ключа кита
# (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных
# судит её по статусу, и `done` с written=0 гасил часы свежести так же,
# как удачный прогон, — а страница тем временем теряла таблицу целиком.
if not counters.get("written"):
logger.error(
"landing_showcase_deals run_id=%d: витрина пуста (written=0, "
"considered=%s, eligible=%s) — прогон неуспешен",
run_id,
counters.get("considered"),
counters.get("eligible"),
)
ctx.runs.mark_failed(
db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters
)
else:
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
@ -528,6 +544,9 @@ async def _job_domclick_city_sweep(
region_code=kit_resolve_region_code(params),
resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies,
watchdog_sec=(
int(params["watchdog_sec"]) if params.get("watchdog_sec") is not None else None
),
)

View file

@ -16,7 +16,8 @@ reap_stale_leases) — она рассчитана на долгоживущие
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
и без мутаций.
и без мутаций пула. Единственная запись атрибуция прогона (`scrape_runs.proxy_id`, #3404),
своей короткой сессией, см. `resolve_proxy_url`.
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
@ -69,7 +70,7 @@ 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
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS, attribute_run_proxy
logger = logging.getLogger(__name__)
@ -277,6 +278,21 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
_safe_label(candidate.id, candidate.label, candidate.url),
candidate.ban_count,
)
# #3404: прогоны на этом пути (yandex_detail_backfill, yandex_address_backfill,
# curl-ветка avito_detail_backfill) lease не берут, а атрибуцию раньше писал только
# acquire() — у yandex_detail_backfill proxy_id был NULL в 56 прогонах из 56.
# Своя сессия, не db вызывающего: attribute_run_proxy коммитит, а на сбое
# откатывает, db же — долгоживущая сессия прогона посреди работы (см.
# _pick_candidate). Сбой атрибуции проглатывается внутри — выдачу не роняет.
from scraper_kit.orchestration.run_context import current_run_id
run_id = current_run_id.get()
if run_id is not None:
attr_db = _SessionLocal()
try:
attribute_run_proxy(attr_db, run_id, candidate.id)
finally:
attr_db.close()
return candidate.url
diag = _diagnose_no_candidate(db, source)

View file

@ -275,12 +275,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600).
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
Fallback НЕ трогает последний пригодный узел выделенной (не-'any') affinity: если
fallback заберёт его под чужой источник, «свой» останется без прокси вообще хуже,
чем голодание исходного источника, которое фикс призван устранить. Кандидат
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
enabled-узел (EXISTS-подзапрос) т.е. выдача не обнулит доступность выделенной
affinity целиком.
участвует в fallback, только если его affinity='any' ИЛИ у источника этой affinity
без него останется ДРУГОЙ кандидат (EXISTS-подзапрос): узел той же affinity или
'any', здоровый, с живой арендой порта и не забаненный этим источником (#3299 —
до него 'any'-узлы не считались, и единственный выделенный узел не выдавался никому).
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
@ -401,17 +402,29 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
-- fallback увести последний реально рабочий узел выделенной
-- affinity и обрушить её (два domclick-узла, один забанен
-- domclick'ом → второй уходит под avito → domclick без прокси).
--
-- #3299: вопрос «останется ли у источника sp.provider_affinity
-- хоть один кандидат без sp», а не «есть ли ВТОРОЙ узел той же
-- привязки». 'any'-узлы основной запрос выдаёт выделенному
-- источнику наравне, значит и backup'ом они считаются; прежний
-- `= sp.provider_affinity` прятал единственный выделенный узел от
-- всех. Здоровье и срок аренды как в основном запросе.
-- leased_by НЕ проверяем: аренда вернётся через минуты, а
-- отказ из-за чужой аренды прятал бы узел при любом параллельном
-- прогоне (тот же довод, что у защиты в mark_banned).
OR EXISTS (
SELECT 1
FROM scrape_proxies AS other
WHERE other.provider_affinity = sp.provider_affinity
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
AND other.enabled
AND other.consecutive_fails < CAST(:max_fails AS integer)
AND (other.expires_at IS NULL OR other.expires_at > now())
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.source = sp.provider_affinity
AND b2.banned_until > now()
)
)
@ -446,9 +459,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
db.commit()
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
# mid-run) — см. attribute_run_proxy docstring.
# #3404: покрывает все пути АРЕНДЫ (curl — acquire на каждый вызов, браузер —
# sticky lease на весь прогон, ре-acquire при ротации узла mid-run). Путь без
# аренды (proxy_egress.resolve_proxy_url) пишет атрибуцию сам — см. docstring.
attribute_run_proxy(db, run_id, proxy_id)
if fallback_used:
logger.warning(
@ -486,11 +499,12 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
Единственный писатель `acquire()` сразу после выдачи lease'а: покрывает и
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
`_LEASE_ROTATE_AFTER_FAILS`) то есть смена узла ЗА прогон фиксируется сама,
без отдельного вызова с чьей-либо стороны.
Два писателя. `acquire()` сразу после выдачи lease'а: покрывает и curl-путь
(acquire на каждый вызов), и браузерный sticky lease (один acquire на весь прогон),
и ре-acquire при ротации узла mid-run (`browser_fetcher`,
`_LEASE_ROTATE_AFTER_FAILS`) то есть смена узла ЗА прогон фиксируется сама.
И `proxy_egress.resolve_proxy_url` egress без аренды (yandex_detail_backfill и
др.); до этого у таких прогонов proxy_id оставался NULL (#3404, прод 17.09: 0 из 56).
`scrape_runs.proxy_id` ПОСЛЕДНИЙ использованный узел (перезаписывается при
каждой новой выдаче); полная цепочка узлов, если она менялась, в
@ -535,8 +549,7 @@ def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
db.commit()
if row is None:
logger.debug(
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
"finalized?)",
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already finalized?)",
run_id,
)
except Exception:
@ -983,6 +996,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
WHERE sp.id <> CAST(:proxy_id AS bigint)
AND sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
-- Как в acquire(): узел с истёкшей арендой порта остаётся enabled,
-- но не выдаётся. Без этой строки он «спасал» источник, и бан уходил
-- последнему живому узлу (ревью #3565 к #3299).
AND (sp.expires_at IS NULL OR sp.expires_at > now())
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
@ -999,17 +1016,22 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
-- не считается (иначе защита сочла бы affinity живой, когда она
-- уже нет).
-- #3299: предикат backup'а — буква в букву как в fallback
-- acquire() (там же обоснование): 'any'-узлы в счёт, здоровье
-- и срок аренды проверяются.
OR EXISTS (
SELECT 1
FROM scrape_proxies other
WHERE other.provider_affinity = sp.provider_affinity
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
AND other.enabled
AND other.consecutive_fails < CAST(:max_fails AS integer)
AND (other.expires_at IS NULL OR other.expires_at > now())
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.source = sp.provider_affinity
AND b2.banned_until > now()
)
)

View file

@ -14,6 +14,7 @@ Idempotent-логика (на каждый yandex-lot):
- история ПУСТА:
* price_previous задан И != price_rub seed previous-точку (change_time = now-1d,
timestamp синтетический gate реального времени смены не несёт), затем current-точку;
НО previous, отличающаяся от текущей цены на порядок и больше, не сеется (#3385);
* иначе только current-точку (now).
- история ЕСТЬ и latest_price != price_rub одна current-точка (now) зафиксировали смену.
- история ЕСТЬ и latest_price == price_rub skip (цена не менялась).
@ -41,6 +42,14 @@ logger = logging.getLogger(__name__)
_SOURCE = "yandex"
# #3385: price.previous, отличающаяся от текущей цены в 9.5 раза и больше (в любую
# сторону), — не рынок, а потерянный разряд или цена в тысячах. Прод 17.09.2026, все
# 8313 посевов yandex: самый большой честный скачок ×2.13, дальше пусто до ×9.74; выше
# порога 11 посевов (×10 — 7, ×175…×1000 — 4), и у всех нижняя цена 0.137 тыс/м².
# Посев синтетический (now-1d, реального времени смены gate не несёт), без него
# теряется только фиктивная точка — текущая цена пишется всё равно.
_SEED_IMPLAUSIBLE_RATIO = 9.5
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
_RESOLVE_SQL = text(
"""
@ -96,6 +105,12 @@ def _insert_point(
)
def _implausible_seed(previous: float, current: float) -> bool:
"""previous и current разнятся на порядок и больше (или одна из цен не > 0)."""
low, high = sorted((float(previous), float(current)))
return low <= 0 or high / low >= _SEED_IMPLAUSIBLE_RATIO
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend.
@ -115,6 +130,7 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
inserted = 0
skipped = 0
seed_rejected = 0
errors = 0
for lot in lots:
@ -140,20 +156,29 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
{"listing_id": listing_id},
).fetchone()
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
# к предыдущей точке и подтверждения у следующей. На этом пути серия
# максимум из двух точек (последняя лежащая в БД + текущая), у
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
# То есть проводка была бы декорацией: ветка, которая по построению
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
# DELETE уже вставленной строки. Отдельная задача: #3385.
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история
# есть» на проде не записала ни одной строки (из 28 558 строк этого
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер
# record_listing_price_change (save_listings идёт первым), и сюда
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории
# — пары «посев из price.previous → текущая цена», ошибочна нижняя.
if latest is None:
# История пуста — seed (+ опционально previous-точка).
prev = lot.price_previous_rub
if prev is not None and prev != lot.price_rub:
if (
prev is not None
and prev != lot.price_rub
and _implausible_seed(prev, lot.price_rub)
):
seed_rejected += 1
logger.warning(
"yandex_price_history: price.previous=%s не сеется — на порядок "
"от текущей %s (source_id=%s)",
prev,
lot.price_rub,
lot.source_id,
)
elif prev is not None and prev != lot.price_rub:
_insert_point(
db,
listing_id=listing_id,
@ -191,10 +216,11 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
db.commit()
logger.info(
"yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d",
"yandex_price_history: lots=%d inserted=%d skipped=%d seed_rejected=%d errors=%d",
len(lots),
inserted,
skipped,
seed_rejected,
errors,
)
return inserted

View file

@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
"ABORT -- 1 consecutive blocks".
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
7193/7264/7302 по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
"""
from __future__ import annotations
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
# класс бага что #2322/#2310.
logger = logging.getLogger(__name__)
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не
зовёт эту функцию в том режиме вовсе.
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
активна. Значение true стоит только у tradein-backend, который добор не запускает.
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом»
это предсуществующее заблуждение, а не описание текущего прода.
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
админки scrape_avito_detail_backfill в tradein-backend, поэтому режим задаётся
дефолтом кода, а не environment'ом одного сервиса.
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
скользящем окне, см. settings.detail_backfill_block_ratio_window/
_threshold (module docstring).
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже).
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
@ -549,6 +554,10 @@ async def run_avito_detail_backfill(
window_size=int(settings.detail_backfill_block_ratio_window),
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
safety_min=max_consecutive_blocks,
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
infra_streak_limit=max_consecutive_failures,
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
# safety-net включён только когда снапшот короче окна и ratio-критерий
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
# config.scraper_proxy_url=None для backconnect-gate'а
# (elif not use_curl own-session path), теряя reconnect-
# on-403 поведение legacy (settings.scraper_proxy_url
# напрямую). Не влияет на use_curl=True (прод-дефолт)
# напрямую). Не влияет на use_curl=True
# там reconnect_on_block=False уже гасит backconnect.
config=RealScraperConfig(),
origin=serp_origin,
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict()
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
if block_ban_kinds:
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
# утверждать про площадку то, чего не было.
stop_reason: str | None = None
if no_proxy_stop:
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db,
run_id,
"пул прокси пуст — к площадке не ходили (#3288)",
current_counters,
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
elif abort_reason == "infra_streak":
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
stop_reason = (
f"тракт недоступен: {breaker.abort_explanation()}, обогащено "
f"{counters.enriched} из {counters.attempted}; причина: "
f"{_top_failure(failure_census)} (#3439)"
)
if stop_reason is not None:
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
"avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d "
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
run_id,
stop_reason,
counters.attempted,
counters.enriched,
counters.blocked,

View file

@ -1,7 +1,8 @@
"""Daily per-source snapshot writer (#570).
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в
и раз в сутки пишет в listing_source_snapshots строку на КАЖДОЕ ИЗМЕНЕНИЕ (#2993, см.
_SNAPSHOT_SQL; раньше полная копия каждые сутки) + change-log в
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
(сейчас DORMANT) скраперов см. шапку data/sql/079_listing_source_history.sql.
@ -72,9 +73,28 @@ def _clamp_budget_sec(raw: Any) -> float:
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
# ── Snapshot upsert: строка только на изменение (#2993) ───────────────────────
# Снимок на (listing_source_id, CURRENT_DATE) пишется, только если у источника ещё нет
# ни одного снимка ИЛИ хоть одно из четырёх значений (price_rub, is_active, last_seen_at,
# payload_hash) отличается от его ПОСЛЕДНЕГО снимка. Было: полная копия listing_sources
# каждые сутки, прод 14-17.09 — 290-293 тыс. строк в сутки, из них отличались от
# предыдущего снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23.
#
# Состояние источника на дату D = его последний снимок с snapshot_date <= D. Для всех
# четырёх колонок это то же значение, что записала бы суточная модель: сутки, в которые
# ни одна из них не менялась, и есть пропущенные строки. Поэтому last_seen_at обязан
# быть в сравнении: пол переобхода (deactivate_stale_avito._revisit_floor_from_where_sql,
# PR-A #3056) берёт last_seen_at последнего снимка не позже якоря — без него пары
# «прошлое наблюдение → текущее» считались бы от устаревшей свежести. is_active там же:
# он выводится из now(), и переход «свежий → протух» при неизменном last_seen_at — тоже
# изменение состояния на дату.
#
# p — последний снимок ВКЛЮЧАЯ сегодняшний: повторный прогон в те же сутки сравнивает
# с тем, что уже записано сегодня, и перезаписывает строку только если значение снова
# сдвинулось (ON CONFLICT → last-write-wins, как раньше). Пер-строчный LATERAL
# point-lookup по PK, как в event-diff (#2607); прод 17.09: 293 602 lookup'а — 1.0 с.
# Старые суточные снимки не трогаются — история до перехода остаётся как есть.
#
# is_active derived: last_seen_at в пределах окна свежести на момент снимка.
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
@ -85,15 +105,33 @@ _SNAPSHOT_SQL = text(
last_seen_at, payload_hash, observed_at, run_id
)
SELECT
id,
cur.id,
CURRENT_DATE,
price_rub,
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
last_seen_at,
md5(raw_payload::text),
cur.price_rub,
cur.is_active,
cur.last_seen_at,
cur.payload_hash,
now(),
CAST(:run_id AS bigint)
FROM listing_sources
FROM (
SELECT
id,
price_rub,
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
last_seen_at,
md5(raw_payload::text) AS payload_hash
FROM listing_sources
) cur
LEFT JOIN LATERAL (
SELECT s.snapshot_date, s.price_rub, s.is_active, s.last_seen_at, s.payload_hash
FROM listing_source_snapshots s
WHERE s.listing_source_id = cur.id
ORDER BY s.snapshot_date DESC
LIMIT 1
) p ON true
WHERE p.snapshot_date IS NULL
OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash)
IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash)
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
price_rub = EXCLUDED.price_rub,
is_active = EXCLUDED.is_active,
@ -108,6 +146,9 @@ _SNAPSHOT_SQL = text(
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре
# значения равны последнему снимку, а значит ни одно событие ниже сработать
# не могло бы — набор событий тот же, что при суточной копии.
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
#
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
@ -249,7 +290,8 @@ def snapshot_listing_sources(
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
Два set-based statement'а в одной транзакции:
1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins.
1. upsert снимка на (listing_source_id, CURRENT_DATE) только для источников,
изменившихся с последнего снимка (#2993) — last-write-wins.
2. diff сегодняшнего снимка против последнего предыдущего три события,
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL.

View file

@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
incomplete failed обогащения нет, значит следующий снапшот
(detail_enriched_at IS NULL) возьмёт её снова.
Вечный недогруз (#3191, 13.0916.09): карточка, которая не догружается НИКОГДА, без
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
недогруз пишется на объявление (миграция 321): сутки его не берём, после
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
не двигает брейкер это сведения о карточке, а не о площадке.
"""
from __future__ import annotations
@ -137,6 +144,13 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
OFFER_ID_PATTERN = "^[0-9]+$"
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
# Недогруз на объявлении (#3191). Разовый недогруз (1,8 МБ вместо 3,9 в замере 28.08)
# с другого захода отдаётся целиком, поэтому пауза, а не приговор. Вечный — пустая
# SPA-оболочка ~381 КБ без INITIAL_STATE, одинаковая с разных IP (проба 17.09) — после
# трёх суточных попыток из очереди выходит и считается в incomplete_given_up.
INCOMPLETE_RETRY_AFTER_HOURS = 24
INCOMPLETE_MAX_ATTEMPTS = 3
@dataclass
class YandexDetailBackfillResult:
@ -161,6 +175,8 @@ class YandexDetailBackfillResult:
url_from_offer_id: int = 0
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0
# Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов.
incomplete_given_up: int = 0
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int]:
@ -172,6 +188,7 @@ class YandexDetailBackfillResult:
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
"incomplete_given_up": self.incomplete_given_up,
"duration_sec": int(self.duration_sec),
}
@ -228,7 +245,8 @@ async def run_yandex_detail_backfill(
WHEN source_url ~ CAST(:offer_url_pattern AS text)
THEN source_url
ELSE {CANONICAL_URL_SQL}
END AS source_url
END AS source_url,
detail_incomplete_count
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
@ -239,6 +257,15 @@ async def run_yandex_detail_backfill(
)
OR source_id ~ CAST(:offer_id_pattern AS text)
)
-- #3191: недогруженную карточку не берём сутки и не берём
-- совсем после INCOMPLETE_MAX_ATTEMPTS.
AND detail_incomplete_count < CAST(:incomplete_max_attempts AS int)
AND (
detail_incomplete_at IS NULL
OR detail_incomplete_at < now() - make_interval(
hours => CAST(:incomplete_retry_hours AS int)
)
)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
@ -249,6 +276,8 @@ async def run_yandex_detail_backfill(
"batch_size": batch_size,
"offer_url_pattern": OFFER_URL_PATTERN,
"offer_id_pattern": OFFER_ID_PATTERN,
"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS,
"incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS,
},
)
.mappings()
@ -283,6 +312,19 @@ async def run_yandex_detail_backfill(
).one()
counters.url_from_offer_id = int(pending.url_from_offer_id)
counters.unenrichable_pending = int(pending.unenrichable_pending)
counters.incomplete_given_up = int(
db.execute(
text(
"""
SELECT count(*) FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND detail_incomplete_count >= CAST(:incomplete_max_attempts AS int)
"""
),
{"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS},
).scalar_one()
)
if counters.url_from_offer_id:
logger.info(
"yandex_detail_backfill: run_id=%dу %d объявлений сохранённый "
@ -443,26 +485,59 @@ async def run_yandex_detail_backfill(
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
# IS NULL) — но не раньше чем через сутки: недогруз пишется на
# объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт.
incomplete_reason = detail_incomplete_reason(
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
)
if incomplete_reason is not None:
counters.incomplete += 1
counters.failed += 1
consecutive_none += 1
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
# всем). Повторный недогруз карточки, уже недогружавшейся
# раньше, — сведения о ней, а не о площадке: он брейкер не
# двигает, иначе вечные карточки, стоящие в очереди подряд,
# рвали бы прогон при каждом повторе (#3191, 16 прогонов
# attempted=5 incomplete=5). Первый недогруз двигает, как раньше.
prior_incomplete = int(row.get("detail_incomplete_count") or 0)
if prior_incomplete == 0:
consecutive_none += 1
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
consecutive_blocks = 0
try:
db.execute(
text(
"""
UPDATE listings
SET detail_incomplete_count = detail_incomplete_count + 1,
detail_incomplete_at = now()
WHERE id = CAST(:listing_id AS bigint)
"""
),
{"listing_id": listing_id},
)
db.commit()
except Exception as mark_exc:
# Не записали — карточка просто вернётся в следующий
# снапшот без паузы (поведение до #3191-счётчика).
db.rollback()
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d "
"недогруз не записан: %s",
run_id,
listing_id,
mark_exc,
)
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
"-> недогруженная карточка, отказ: %s (попытка %d/%d, "
"consecutive=%d)",
run_id,
listing_id,
source_url,
incomplete_reason,
prior_incomplete + 1,
INCOMPLETE_MAX_ATTEMPTS,
consecutive_none,
)
if consecutive_none >= max_consecutive_blocks:

View file

@ -0,0 +1,60 @@
-- 308_domclick_msk_disable_until_incremental_save.sql
-- Выключить домклик-свипы 77/50: в текущем виде они по построению не могут ничего собрать.
--
-- Apply after: 307_domclick_msk_window_no_collision.sql
--
-- WHY (замерено 17.09.2026 одиночным прогоном, без соседей по сайдкару и пулу):
--
-- run 7344, source='domclick_city_sweep_moskva', прокси id=13, стартовал 06:12:48.
-- За ДВА часа пройдено 2 бакета из 6: 'st' занял 34.2 мин, '1' шёл 86+ мин и не
-- закончился. Watchdog прогона — 11100 с (3 ч 05 мин).
--
-- Это НЕ одновременность и НЕ блок площадки: прогон был единственным, узел не банился,
-- QRATOR-блока в логе нет. Причина структурная — ДомКлик режет offset на 2000, поэтому
-- на больших выдачах скрейпер бисекцией делит диапазон по цене на листья, и КАЖДЫЙ лист
-- пагинируется отдельно. У Москвы ≈23 690 лотов вторички против ≈6 300 у ЕКБ, листьев
-- кратно больше. А формула watchdog'а (pipeline.run_domclick_city_sweep) считает число
-- фетчей как _DOMCLICK_NUM_BUCKETS * pages = 6 * 100 и объём выдачи не учитывает вовсе:
-- 11100 = 600 * (6 + 12) + 300. Она линейна по pages, поэтому уменьшение
-- pages_per_anchor урезает и сам watchdog — лечения не даёт.
--
-- Хуже, чем просто «не успевает». _domclick_phase — «единственная citywide-фаза:
-- fetch_city + save»: лоты копятся в памяти и сохраняются ОДНИМ save_listings после
-- всех шести бакетов. Снятие фазы по watchdog (asyncio.wait_for → TimeoutError) теряет
-- всё собранное. При этом чекпоинт #3118 пишется ПОСЛЕ этого, из живой ссылки на
-- скрейпер: done_buckets = унаследованное _s.completed_buckets. То есть бакеты
-- помечаются пройденными, хотя ни одна их строка не сохранена, и следующий прогон
-- пропускает их через skip_buckets. Два-три таких прогона закрывают чекпоинт целиком —
-- итоговый сбор по 77/50 равен нулю навсегда.
--
-- Пока строки включены, вред не нулевой: каждые трое суток каждая забирает один из ДВУХ
-- узлов с provider_affinity='any' (id 13 и 14) на три часа и не отдаёт ничего. 17.09
-- из-за этого ЕКБ-свип run 7339 отбился 'banned' за 0 секунд, пока оба узла держали
-- московские прогоны. Выключение восстанавливает статус-кво ЕКБ.
--
-- ЧТО НУЖНО ДЛЯ ВОЗВРАТА (не в этой миграции — это правка кода в scraper-kit):
-- 1. Инкрементальное сохранение по бакетам: save_listings внутри цикла fetch_city
-- (колбэк on_bucket, как у run_cian_full_load), чтобы снятие по watchdog сохраняло
-- собранное, а чекпоинт перестал врать.
-- 2. Watchdog, зависящий от объёма выдачи (snippetsCount), а не только от pages.
-- 3. Кооперативная отмена по бакетам: runs.is_cancelled сейчас проверяется только ПЕРЕД
-- SERP-фазой, поэтому повисший свип нельзя снять до watchdog'а.
-- 4. При возврате — сбросить унаследованный чекпоинт (counters.done_buckets прогонов
-- 7333/7344), иначе resume пропустит бакеты 'st' и '1', по которым ничего не сохранено.
--
-- ЕКБ-строка 'domclick_city_sweep' НЕ трогается: её выдача помещается в watchdog
-- (run 7219 16.09 — 70 минут, 6372 seen / 237 new), у неё дефект не проявляется.
--
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source IN (...) — повторный прогон переписывает то же
-- значение. Возврат — ручным UPDATE enabled = true после правок выше, либо новой
-- миграцией.
BEGIN;
SET LOCAL lock_timeout = '5s';
UPDATE scrape_schedules
SET enabled = false
WHERE source IN ('domclick_city_sweep_moskva', 'domclick_city_sweep_moskovskaya_oblast');
COMMIT;

View file

@ -0,0 +1,70 @@
-- 310_yandex_seed_decimal_slips.sql
-- Удалить из offer_price_history yandex-посевы, отличающиеся от следующей точки на
-- порядок и больше (#3385).
--
-- Apply after: 307_domclick_msk_window_no_collision.sql
--
-- ЧТО НЕ ТАК
-- services/yandex_price_history.py при пустой истории сеет точку из gate-поля
-- price.previous: change_time = now 1 сутки (синтетическое время), следом текущая
-- цена лота с change_time = now; обе строки одним INSERT-батчем, поэтому recorded_at у
-- них совпадает. У 11 объявлений price.previous — не рынок: 1 370 000 → 13 700 000
-- (87.8 м², нижняя цена 15.6 тыс/м²), 10 900 → 10 900 000 (цена в тысячах). Замер
-- прода 17.09.2026 по всем 8313 yandex-посевам: ×10 — 7 (одно из них ×9.74), ×175…×1000
-- — 4, «вниз» — 0; самый большой скачок ниже порога — ×2.13, между ×2.13 и ×9.74 нет
-- ни одного. Ошибочна именно НИЖНЯЯ, посевная точка: у нижних 0.137 тыс/м², у верхних
-- 118375 тыс/м²; верхняя совпадает с listings.price_rub у 10 из 11, а 10387563 дальше
-- дешевеет от неё же (14M → 13M → 12M, триггерные строки).
--
-- Код починен в том же PR: посев на порядок и больше больше не пишется. Эта миграция —
-- задним числом по уже записанным строкам.
--
-- КРИТЕРИЙ — ПОДПИСЬ ПОСЕВА, А НЕ ТОЛЬКО ОТНОШЕНИЕ
-- Удаляется строка o, только если у того же объявления есть строка s, записанная тем же
-- батчем (s.recorded_at = o.recorded_at) ровно через 24 часа (s.change_time =
-- o.change_time + 24 hours), и цены разнятся в 9.5 раза и больше. Так посевы пишет
-- только этот писатель; триггерные строки (change_time = recorded_at) и строки других
-- источников условием не задеваются. Интервал '24 hours', а не '1 day': прибавление
-- суток к timestamptz зависит от таймзоны сессии, а Python считает ровно 24 часа.
-- diff_percent у второй строки не трогаем: писатель его не заполняет, у всех 11 он NULL,
-- и после удаления посева вторая строка становится первой — NULL там и есть верное.
--
-- СКОЛЬКО ЖДЁМ
-- 11 строк на 17.09.2026 (7 × 10 и 4 × 175…1000). До деплоя кода новые посевы ещё
-- могут появиться (06.09 → 17.09 прибавился один). Порог остановки 30: поймали больше —
-- критерий ловит не то, миграция падает, а не молча чистит историю.
--
-- ИДЕМПОТЕНТНОСТЬ
-- Удалённый посев второй раз не находится, у оставшейся строки пары больше нет →
-- повторный прогон удаляет 0. Новых объектов схемы нет.
BEGIN;
-- Конвенция проекта (#2752): DELETE берёт блокировки строк offer_price_history; без
-- lock_timeout деплой встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
deleted_rows bigint;
BEGIN
DELETE FROM offer_price_history o
USING offer_price_history s
WHERE o.source = 'yandex'
AND s.source = 'yandex'
AND s.listing_id = o.listing_id
AND s.recorded_at = o.recorded_at
AND s.change_time = o.change_time + interval '24 hours'
AND o.price_rub > 0
AND s.price_rub > 0
AND greatest(o.price_rub, s.price_rub) / least(o.price_rub, s.price_rub) >= 9.5;
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
RAISE NOTICE 'offer_price_history: удалено yandex-посевов на порядок = %', deleted_rows;
-- Исключение откатывает всю транзакцию вместе с DELETE выше.
IF deleted_rows > 30 THEN
RAISE EXCEPTION
'offer_price_history: к удалению % yandex-посевов при замере 17.09.2026 = 11 — '
'критерий ловит не то, миграция остановлена', deleted_rows;
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,99 @@
-- 311_canon_strip_district_glued_to_house_number.sql
--
-- CONTEXT (#1772): один дом живёт в нескольких записях houses, аналоги из того же дома
-- разъезжаются. Схлопывание house_dedup_merge кластеризует по tradein_canon_addr, а канон
-- промахивается на адресах, где район ПРИКЛЕЕН к номеру дома без запятой (наследие слипа
-- «29р-н» из #1773 — источник починен, но записанные дома сохранили склеенный адрес):
-- «Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический» → «евгениясавкова17арнакадемический»
-- «Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический» → «евгениясавкова44накадемический»
-- Во втором случае хуже, чем шум: токен-срез S5 принимает «бр» (бульвар) и съедает литеру Б.
-- Срез S4 (мигр. 147) снимает «р-н ...» только как отдельный сегмент перед запятой, поэтому
-- двойник «ул. Евгения Савкова,17А» (канон «евгениясавкова17а») с этим домом не кластеризуется
-- никогда, и перепись остатка (residual_*) такие пары не видит — у них РАЗНЫЙ канон.
--
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 147 плюс один шаг
-- S0b сразу после lower+ё→е: «<цифра>[литера]р-н <название>» до запятой/конца → «<цифра>[литера]».
-- Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2» теряет «р-н ...» и превращается
-- в «мк2» (прод 17.09: срез «р-н [^,]*» без якоря менял канон у 1024 домов вместо 44; в выборке
-- 30 изменений вне шаблона 29 — «мкр-н …», одно — хвост «· рВерх-Исетский»).
-- Литера сохраняется: «17Ар-н» → «17а», «17А» не схлопывается с «17».
--
-- Замер на проде 2026-09-17 (read-only, выражение шага inline в SELECT):
-- houses: канон меняется у 44 из 49 159 — ровно у 44 домов с шаблоном «[0-9][а-я]?р-н »;
-- у 20 из них появляется двойник с тем же каноном в пределах 250 м (0222 м, все — тот же
-- адрес в другом написании, напр. «Академика Вавилова, 9р-н Академический» ↔ «улица
-- Академика Вавилова, 9», 0 м, 705 и 105 объявлений); разных ФИАС ни в одной паре нет.
-- gar_house_flats: 0 из 3,27 млн строк содержат шаблон → канон ГАР-стороны не меняется,
-- значения функционального индекса остаются верными.
--
-- DEPENDENCIES (existing prod objects):
-- - tradein_canon_addr(text) — мигр. 144/147 (заменяем тело, IMMUTABLE, сигнатура та же).
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
-- WHERE flat_count > 0 (мигр. 144). Postgres не перестраивает функциональный индекс при
-- смене тела функции: СТАРЫЕ ключи остаются, новые строки получают ключ нового тела.
-- Старый ключ неверен ровно у строк, где срабатывает S0b (lower+ё→е ~ '[0-9][а-я]?р-н ').
-- Прод 17.09: 0 из 201 513 индексируемых строк → сегодня REINDEX ничего не меняет.
-- Оставлен в ТОЙ ЖЕ транзакции, что и замена тела: замер не покрывает строки, загруженные
-- между 17.09 и деплоем, а атомарная пара «тело + индекс» не оставляет окна со старыми ключами.
--
-- ЦЕНА REINDEX (прод 17.09, только чтение): таблица 3,27 млн строк / 942 МБ heap, индекс 8,8 МБ.
-- Эквивалент перестройки — seq scan + tradein_canon_addr по flat_count > 0 в один поток — 5,6 с
-- (скан 0,96 с, вычисление ключей ~4,6 с). Всё это время REINDEX держит SHARE на таблице и
-- ACCESS EXCLUSIVE на индексе: планировщик любого запроса к gar_house_flats ждёт. Читают и
-- пишут таблицу только ручной ГАР-загрузчик и ре-матч (app/services/gar_flats_loader.py; в
-- scrape_schedules их нет; в pg_stat_statements с 27.08 — только INSERT загрузчика и ручные
-- замеры) — путь запроса пользователя не задет.
-- lock_timeout 5s ограничивает ОЖИДАНИЕ лока (#2752): если в момент деплоя идёт загрузка ГАР,
-- миграция падает целиком (тело откатывается вместе с REINDEX), деплой красный, повторить позже.
--
-- Почему не REINDEX CONCURRENTLY (раннер гонит файл psql'ем в autocommit, так что вне
-- BEGIN/COMMIT он возможен, образец — 270): CIC ждёт ВСЕ транзакции базы старше своего
-- снимка, а сборщики держат «idle in transaction» минутами (17.09: 3 мин) — деплой висит без
-- lock_timeout, который CIC не терпит; оборванный CIC оставляет невалидный *_ccnew и красный
-- деплой с ручной чисткой на проде; тело функции коммитится раньше индекса. Ради ~5 с лока на
-- таблице пакетного загрузчика это хуже.
--
-- POST-DEPLOY: ничего запускать не нужно. Следующий прогон расписания house_dedup_merge
-- (еженедельно) сольёт пары, прошедшие страж 250 м, с журналом в house_merge_log.
--
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op. Раннер деплоя
-- гонит файл через psql ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
SELECT regexp_replace( -- S6: оставить только [а-я0-9]
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
regexp_replace( -- S1b: «б-р» → «бульвар»
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
'[^а-я0-9]', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
COMMENT ON FUNCTION tradein_canon_addr(text) IS
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311): '
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9].';
REINDEX INDEX gar_house_flats_canon_idx;
COMMIT;

View file

@ -0,0 +1,29 @@
-- 320_listings_domclick_zero_area_to_null.sql
-- Issue #3252: Домклик отдаёт `livingArea: 0` / `kitchenArea: 0` как «не указано»
-- (живая карточка 2078257603, 17.09: обе площади 0, ремонт пустой). Парсер карточки
-- писал этот 0 в колонку как настоящую площадь; COALESCE(:new, old) в UPDATE при этом
-- затирал нулём ранее известное значение. Писатель починен в том же PR
-- (providers/domclick/detail.py::_pos_float), но починка разбора строк не чинит:
-- COALESCE(NULL, 0) оставит 0 навсегда.
--
-- ЗАМЕР ПРОДА 2026-09-17 (SELECT source, count(*) FILTER (WHERE kitchen_area_m2=0) ...):
-- domklik: kitchen_area_m2 = 0 — 303 строки, living_area_m2 = 0 — 84 строки;
-- все обогащены карточкой 26.0814.09 (detail_enriched_at), ни одной до этого.
-- avito / cian / yandex / n1: нулей нет вовсе.
-- Ожидаемо тронуто: 303 + 84 строки. Отрицательных площадей нет ни у кого.
--
-- Idempotent: повторный прогон — 0 строк.
BEGIN;
UPDATE listings
SET kitchen_area_m2 = NULL
WHERE source = 'domklik'
AND kitchen_area_m2 = 0;
UPDATE listings
SET living_area_m2 = NULL
WHERE source = 'domklik'
AND living_area_m2 = 0;
COMMIT;

View file

@ -0,0 +1,52 @@
-- 321_listings_detail_incomplete_attempts.sql
-- Счётчик недогруженных карточек Яндекса на объявлении (#3191, вечный недогруз).
--
-- Apply after: 310_yandex_seed_decimal_slips.sql
-- Deploy order: схема первой (деплой применяет миграции до пересоздания контейнеров);
-- старый код новые колонки не читает, поэтому окно между шагами безопасно.
--
-- ── ЧТО НЕ ТАК ────────────────────────────────────────────────────────────────
-- PR #3364 сделал недогруженную карточку отказом: detail_enriched_at не ставится,
-- объявление остаётся в очереди yandex_detail_backfill. Попыток на объявлении при
-- этом никто не считал, и карточка, которая не догружается НИКОГДА, возвращалась в
-- голову каждого снапшота. Прод 13.09 21:14 16.09 18:52: пять объявлений
-- (10776456, 10775964, 10775945, 10775747, 10775594; scraped_at 12.09 15:02, подряд
-- в порядке очереди) давали 381 КБ без encryptedPhones/redirectPhones в каждом
-- прогоне, пять недогрузов подряд выбивали брейкер consecutive_none=5, и 16 прогонов
-- закончились ровно attempted=5 incomplete=5, а остальные обрывались, дойдя до них.
-- Страница — пустая SPA-оболочка без INITIAL_STATE (id оффера в ней не встречается),
-- та же с другого IP (проба 17.09): это свойство объявления, а не сети.
--
-- ── ЧТО ДЕЛАЕТ ФАЙЛ ───────────────────────────────────────────────────────────
-- detail_incomplete_count — сколько раз карточка пришла недогруженной;
-- detail_incomplete_at — когда последний раз. Читает и пишет только
-- app/tasks/yandex_detail_backfill.py: снапшот не берёт карточку в течение суток
-- после недогруза и не берёт совсем после трёх.
--
-- ── СТОИМОСТЬ ─────────────────────────────────────────────────────────────────
-- ADD COLUMN с константным DEFAULT в PostgreSQL 11+ не переписывает heap (значение
-- лежит в каталоге), поэтому 19 ГБ listings не копируются. ACCESS EXCLUSIVE берётся
-- на миллисекунды; ожидание лока ограничено lock_timeout (.claude/rules/sql.md).
--
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS. Данные существующих строк не трогаются.
--
-- Критерий приёмки (записан ДО применения):
-- 1. Запись в _schema_migrations по имени этого файла.
-- 2. information_schema.columns: обе колонки у listings.
-- 3. После первого прогона yandex_detail_backfill, дошедшего до пяти объявлений:
-- у них detail_incomplete_count = 1, и следующий прогон их не запрашивает.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE listings
ADD COLUMN IF NOT EXISTS detail_incomplete_count smallint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS detail_incomplete_at timestamptz;
COMMENT ON COLUMN listings.detail_incomplete_count IS
'Сколько раз detail-страница приходила недогруженной (#3191). Пишет yandex_detail_backfill; после 3 объявление выходит из очереди добора.';
COMMENT ON COLUMN listings.detail_incomplete_at IS
'Время последнего недогруза detail-страницы (#3191). Суточная пауза перед повтором в yandex_detail_backfill.';
COMMIT;

View file

@ -0,0 +1,124 @@
-- 322_canon_keep_slash_in_house_number.sql
--
-- CONTEXT (#2189): канон-ключ tradein_canon_addr последним шагом оставлял только [а-я0-9],
-- и дробь в номере дома исчезала: «Сулимова, 3/2» ≡ «Сулимова, 32», «Краснолесья, 10/7» ≡
-- «107», «Машиностроителей, 4/1» ≡ «41». В ГАР это разные дома с разными house_guid
-- («ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120), а матчер ГАР↔houses
-- (gar_flats_loader._MATCH_SQL) при фильтре по городу берёт из такого ключа строку с
-- max(flat_count): так «Сулимова 32» получил 414 квартир дома 3/2. Ревизия DaData 02.07
-- подтвердила класс (23 из 25 расхождений — в пользу DaData, метка dadata_fias_revision).
--
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 311 без изменений,
-- кроме последнего шага: S6 оставляет [а-я0-9/], новый S7 убирает «/», если хотя бы с одной
-- стороны от неё не цифра. «3/2» → «3/2», «3 / 2» → «3/2» (пробелы S6 уже снял), а угловые
-- адреса «Гагарина, 27 / Первомайская, 81» и «204Г/1» дают прежний ключ.
--
-- Новый ключ — ТОЛЬКО уточнение старого: убери из нового все «/» — получишь старый. Значит,
-- два адреса с одинаковым новым ключом имели одинаковый и старый; склеить дома, которые
-- раньше не склеивались, правка не может, только развести.
--
-- Замер на проде 2026-09-17 (read-only, выражение нового тела inline в SELECT):
-- houses: ключ меняется у 2549 из 49 204; групп «один новый ключ — несколько старых» 0.
-- Распалось 47 прежних канон-кластеров — 113 пар домов, и ни одна не ближе 250 м
-- (минимум 256 м), общего ФИАС нет ни в одной, разный ФИАС у 5. Страж 250 м
-- house_dedup_merge эти пары и так не сливал, число слияний не меняется.
-- gar_house_flats: «/» между цифрами в 7237 строках с квартирами (66: 938, 77: 2711,
-- 50: 3588); групп «один новый ключ — несколько старых» 0.
-- Симуляция _MATCH_SQL по затронутым ключам (регион: сопоставлений было → стало,
-- неоднозначных ключей было → стало): 66 с фильтром ЕКБ 399 → 364, 17 → 9;
-- 77: 259 → 242, 38 → 12; 50: 400 → 448, 348 → 249. Каждая потерянная пара —
-- дробь против числа («Высоцкого, 4/2» ↔ ГАР «42», 725 квартир).
--
-- DEPENDENCIES (existing prod objects):
-- - tradein_canon_addr(text) — мигр. 144/147/311 (заменяем тело, IMMUTABLE, сигнатура та же).
-- ДЕПЛОИТЬ ПОСЛЕ 311 (PR #3558): тело включает её шаг S0b, иначе порядок файлов неважен.
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
-- (мигр. 144). Значения у 7237 строк меняются → REINDEX обязателен: Postgres не
-- перестраивает функциональный индекс при смене тела функции (урок мигр. 147).
--
-- ЧИСТКА СОПОСТАВЛЕНИЙ, СДЕЛАННЫХ СКЛЕЕННЫМ КЛЮЧОМ. Матчер (_MATCH_SQL) только проставляет
-- gar_house_guid и никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир),
-- при перематче новым ключом не найдёт пары и так и останется с чужими квартирами. Поэтому
-- здесь сбрасываются ровно те canon_addr-сопоставления, у которых ключи дома и его ГАР-строки
-- совпадают без «/», но различаются с ней, — то есть держались только на потерянной дроби.
-- Сопоставления, разошедшиеся с ключом по другим причинам (прод: 4 дома, адрес сменился после
-- матча), и методы dadata_fias/dadata_fias_revision не трогаются.
-- Замер 17.09: 47 домов (регион ГАР 66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных
-- объявлений; все 47 — дробь против числа («Удельная, 1/1» → «11», «Ландау, 5/1» → «51»).
-- Сомнительны 2 из 47: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50) — возможно,
-- тот же дом, у которого источник потерял дробь; без ГАР-числа они честнее, чем с угаданным.
-- Порог остановки 100: сбросили больше — критерий ловит не то, миграция падает целиком.
-- Перематч (новые и исправленные пары) — отдельный шаг, см. PR.
--
-- POST-DEPLOY: новые ключи houses сразу действуют в house_dedup_merge. Перематч ГАР — по желанию
-- владельца: python -m app.tasks.gar_flats_load --match-only --region 66 (и 77, 50).
--
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op; сброшенные дома
-- второй раз не находятся (gar_match_method уже NULL). Раннер деплоя гонит файл через psql
-- ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
BEGIN;
-- Конвенция проекта (#2752): REINDEX и UPDATE houses берут блокировки — не вставать в очередь.
SET LOCAL lock_timeout = '5s';
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
SELECT regexp_replace( -- S7: «/» остаётся только между цифрами (#2189)
regexp_replace( -- S6: оставить только [а-я0-9/]
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
regexp_replace( -- S1b: «б-р» → «бульвар»
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
'[^а-я0-9/]', '', 'g'),
'(?<![0-9])/|/(?![0-9])', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
COMMENT ON FUNCTION tradein_canon_addr(text) IS
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311 + 322): '
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9] '
'и «/» между цифрами («3/2» ≠ «32»).';
REINDEX INDEX gar_house_flats_canon_idx;
DO $$
DECLARE
reset_rows bigint;
BEGIN
UPDATE houses h
SET gar_house_guid = NULL, gar_flat_count = NULL, gar_matched_at = NULL, gar_match_method = NULL
FROM gar_house_flats g
WHERE g.house_guid = h.gar_house_guid
AND h.gar_match_method = 'canon_addr'
AND tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address))
<> tradein_canon_addr(g.norm_address)
AND replace(tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)), '/', '')
= replace(tradein_canon_addr(g.norm_address), '/', '');
GET DIAGNOSTICS reset_rows = ROW_COUNT;
RAISE NOTICE 'houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = %', reset_rows;
-- Исключение откатывает всю транзакцию, включая замену функции.
IF reset_rows > 100 THEN
RAISE EXCEPTION
'houses: к сбросу % canon_addr-сопоставлений при замере 17.09.2026 = 47 — '
'критерий ловит не то, миграция остановлена', reset_rows;
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,31 @@
-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа.
--
-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а
-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через
-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится.
--
-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше
-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет:
-- историю размещений на дату старой оценки не восстановить.
--
-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём
-- est_days_p50, старые строки дают null, как и раньше.
--
-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать
-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS est_days_p25 integer,
ADD COLUMN IF NOT EXISTS est_days_p50 integer,
ADD COLUMN IF NOT EXISTS est_days_p75 integer,
ADD COLUMN IF NOT EXISTS est_days_n integer;
COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS
'#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, '
'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. '
'NULL — выборка < 30.';
COMMIT;

View file

@ -0,0 +1,29 @@
-- 325_listing_source_snapshots_change_only_comment.sql
-- #2993 — listing_source_snapshots переходит на «строку на изменение»
-- (app/tasks/listing_source_snapshot.py, _SNAPSHOT_SQL; решение владельца 2026-08-23).
--
-- Схема не меняется, меняется смысл строк — поэтому только комментарии. Прежние
-- («Ежедневный снимок…», «на дату снимка») после перехода стали бы неправдой:
-- `WHERE snapshot_date = D` отдаёт теперь не состояние всех источников на D, а только
-- изменившиеся в D. Состояние на дату — последний снимок с snapshot_date <= D.
-- Суточные строки до перехода остаются в таблице как есть.
--
-- COMMENT берёт ShareUpdateExclusiveLock (конфликтует с autovacuum/ANALYZE, не с
-- чтением и записью) — lock_timeout, чтобы деплой не повис за VACUUM 8-млн таблицы.
BEGIN;
SET LOCAL lock_timeout = '5s';
COMMENT ON TABLE listing_source_snapshots IS
'Снимок listing_sources на источник (#570), строка только при изменении (#2993): '
'пишется, если price_rub/is_active/last_seen_at/payload_hash отличаются от последнего '
'снимка источника или снимка ещё нет. Состояние на дату D — последняя строка с '
'snapshot_date <= D. До перехода (#2993) — полная суточная копия. '
'PK (listing_source_id, snapshot_date), last-write-wins за день.';
COMMENT ON VIEW v_listing_source_price_on_date IS
'Цена/активность источника с даты снимка (#570): listing_source_snapshots ⋈ listing_sources. '
'С #2993 строки только на даты изменений — цена на дату D = последняя строка с snapshot_date <= D.';
COMMIT;

View file

@ -0,0 +1,56 @@
-- 326_domclick_msk_reenable_after_incremental_save.sql
-- Вернуть домклик-свипы Москвы (77) и области (50) — причина выключения устранена.
--
-- Apply after: 325_listing_source_snapshots_change_only_comment.sql
--
-- WHY:
-- Миграция 308 выключила эти строки: свип копил лоты в памяти и сохранял их ОДНИМ
-- save_listings после всех шести корзин, поэтому снятие по watchdog теряло всё
-- собранное, а чекпоинт при этом помечал корзины пройденными. На выдаче размером с
-- Москву (≈23 690 лотов вторички против ≈6 300 у ЕКБ) снятие было гарантировано, и
-- итоговый сбор равнялся нулю навсегда.
--
-- PR #3592 это снял: save_listings зовётся из колбэка on_bucket сразу после КАЖДОЙ
-- успешной корзины, туда же переехал чекпоинт — done_buckets теперь означает
-- «собрано И сохранено». Снятие по watchdog больше не теряет собранное, а корзина
-- без сохранённых строк в чекпоинт не попадает. Тем же колбэком добавлена
-- кооперативная отмена по корзинам (раньше is_cancelled проверялся только перед
-- SERP-фазой, и повисший свип нельзя было снять три часа).
--
-- interval_days = 1, а не 3 как было: полный проход Москвы в одно окно watchdog'а
-- по-прежнему НЕ помещается (замер run 7344: 2 корзины из 6 за два часа). Механизм
-- добора — ротация стартовой корзины (start_bucket_index = run_id % 6) плюс
-- skip_buckets из чекпоинта: каждый прогон берёт корзины, которых ещё нет в
-- done_buckets. При суточном такте шесть корзин закрываются примерно за трое суток,
-- при трёхсуточном — за девять, а корпус живёт 14 суток (LISTINGS_FRESH_DAYS).
-- Девять суток на полный оборот не оставляли бы запаса на пропуски из-за
-- QRATOR-банов.
--
-- watchdog_sec НЕ задаётся намеренно. Override в коде есть (PR #3592, читается из
-- default_params обоими хендлерами), но поднимать таймаут до замера нечем
-- обосновать: с инкрементальным сохранением ранний снос перестал быть потерей, а
-- более длинный прогон дольше держит один из ДВУХ узлов provider_affinity='any'
-- (id 13 и 14), за которые конкурирует cian. Сначала смотрим реальный выход за
-- прогон, потом решаем про таймаут.
--
-- Окна не меняются: Москва 0-3, область 9-12 (разведены миграцией 307), ЕКБ 3-6.
-- Ни одно окно не содержит двух домклик-строк — это условие, за нарушение которого
-- 17.09 ЕКБ-свип run 7339 отбился 'banned' за 0 секунд.
--
-- Чекпоинт прогонов 7333/7344 сбрасывать не нужно: у обоих buckets_completed пуст,
-- они были задрейнены деплоем ещё до первой завершённой корзины.
--
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source IN (...) — повторный прогон пишет те же
-- значения. next_run_at не трогаем: планировщик посчитает его сам по окну, а явная
-- простановка здесь разъехалась бы с реальным временем применения миграции.
BEGIN;
SET LOCAL lock_timeout = '5s';
UPDATE scrape_schedules
SET enabled = true,
default_params = jsonb_set(default_params, '{interval_days}', '1'::jsonb, true)
WHERE source IN ('domclick_city_sweep_moskva', 'domclick_city_sweep_moskovskaya_oblast');
COMMIT;

View file

@ -1674,12 +1674,16 @@ class _HouseIdResolution:
total: int = 0 # deals processed in the full-engine path under the flag
resolved: int = 0 # deals where match_house_readonly returned an id
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
# #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст
# у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе.
house_type_from_houses: int = 0
def as_json(self) -> dict[str, int]:
return {
"resolved": self.resolved,
"total": self.total,
"imv_reachable": self.imv_reachable,
"house_type_from_houses": self.house_type_from_houses,
}
@ -1975,12 +1979,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
``params`` block. Touches NO DB / network and does NOT consult
``settings_at_capture`` it prices against the live committed
``estimator.settings`` defaults (so a settings change is caught as a metric
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
потока (см. комментарий у пина ниже). Deterministic: same fixture identical
dict.
``estimator.settings`` defaults and module constants (so a change is caught as a
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
(закоммиченная такая); более старая упадёт на «control flow diverged from
capture» её надо перезахватить. Deterministic: same fixture identical dict.
"""
est = _import_estimator_full()
m = est.m
@ -1991,83 +1994,60 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
pred_ppm2_all: list[float] = []
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
# вызывается, а реплей с dedup=False их оценивает и упирается в
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
# False — для старых фикстур без settings_at_capture поведение прежнее).
_dedup_capture = bool(
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
)
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
try:
for rec in deals:
kw = dict(rec["kwargs"])
sold_ppm2_all.append(float(rec["sold_ppm2"]))
kw["geo"] = est.GeocodeResult(**kw["geo"])
kw["ratio_resolver"] = _make_call_stub(
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
)
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
kw["quarter_index_lookup"] = _make_call_stub(
rec.get("qi_calls") or [],
label="quarter_index_lookup",
coerce=_coerce_qi_return,
on_exhausted=None,
unrecorded_counter=unrecorded,
)
kw["quarter_indexes_lookup"] = _make_call_stub(
rec.get("qis_calls") or [],
label="quarter_indexes_lookup",
coerce=_coerce_qis_return,
on_exhausted={},
unrecorded_counter=unrecorded,
)
for rec in deals:
kw = dict(rec["kwargs"])
sold_ppm2_all.append(float(rec["sold_ppm2"]))
kw["geo"] = est.GeocodeResult(**kw["geo"])
kw["ratio_resolver"] = _make_call_stub(
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
)
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
kw["quarter_index_lookup"] = _make_call_stub(
rec.get("qi_calls") or [],
label="quarter_index_lookup",
coerce=_coerce_qi_return,
on_exhausted=None,
unrecorded_counter=unrecorded,
)
kw["quarter_indexes_lookup"] = _make_call_stub(
rec.get("qis_calls") or [],
label="quarter_indexes_lookup",
coerce=_coerce_qis_return,
on_exhausted={},
unrecorded_counter=unrecorded,
)
pr = m._price_from_inputs(**kw)
pr = m._price_from_inputs(**kw)
_es_ppm2_raw = pr.expected_sold_per_m2
_es_price_raw = pr.expected_sold_price
_r_low_raw = pr.expected_sold_range_low
_r_high_raw = pr.expected_sold_range_high
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
es_price = float(_es_price_raw) if _es_price_raw is not None else None
r_low = float(_r_low_raw) if _r_low_raw is not None else None
r_high = float(_r_high_raw) if _r_high_raw is not None else None
prediction = Prediction(
deal_id=int(rec["deal_id"]),
rooms=rec["rooms"],
area_m2=float(rec["area_m2"]),
sold_ppm2=float(rec["sold_ppm2"]),
median_ppm2=float(pr.median_ppm2),
confidence=pr.confidence,
anchor_tier=pr.anchor_tier,
expected_sold_ppm2=es_ppm2,
expected_sold_price=es_price,
range_low=r_low,
range_high=r_high,
)
predictions.append(prediction)
pred_ppm2_all.append(prediction.median_ppm2)
finally:
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
_es_ppm2_raw = pr.expected_sold_per_m2
_es_price_raw = pr.expected_sold_price
_r_low_raw = pr.expected_sold_range_low
_r_high_raw = pr.expected_sold_range_high
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
es_price = float(_es_price_raw) if _es_price_raw is not None else None
r_low = float(_r_low_raw) if _r_low_raw is not None else None
r_high = float(_r_high_raw) if _r_high_raw is not None else None
prediction = Prediction(
deal_id=int(rec["deal_id"]),
rooms=rec["rooms"],
area_m2=float(rec["area_m2"]),
sold_ppm2=float(rec["sold_ppm2"]),
median_ppm2=float(pr.median_ppm2),
confidence=pr.confidence,
anchor_tier=pr.anchor_tier,
expected_sold_ppm2=es_ppm2,
expected_sold_price=es_price,
range_low=r_low,
range_high=r_high,
)
predictions.append(prediction)
pred_ppm2_all.append(prediction.median_ppm2)
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
@ -2123,7 +2103,6 @@ def _predict_full_spine(
whether the house IMV anchor actually resolved a row for this deal.
"""
m = est.m
settings = est.settings
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
db, deal, est, target_house_id=target_house_id
@ -2212,7 +2191,7 @@ def _predict_full_spine(
res = m._lookup_quarter_index(
db,
quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
)
if capture is not None:
ret = _sanitize_json(list(res) if res is not None else None)
@ -2223,7 +2202,7 @@ def _predict_full_spine(
res = m._lookup_quarter_indexes(
db,
quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
)
if capture is not None:
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
@ -2575,6 +2554,18 @@ def run_backtest_full(
target_house_id = _resolve_house_id_for_deal(db, deal)
if target_house_id is not None:
resolution.resolved += 1
# #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда
# его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м.
# deals.house_type пуст у всех сделок, так что без этого штраф за тип дома
# в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses`
# сдвинули бы метрики по другим признакам и смешали бы замер.
if deal.house_type is None:
facts = est.m._lookup_house_facts(
db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon
)
if facts is not None and facts.house_type is not None:
deal = dataclasses.replace(deal, house_type=facts.house_type)
resolution.house_type_from_houses += 1
try:
pr = _predict_full_spine(
db,
@ -2641,10 +2632,11 @@ def run_backtest_full(
if resolution is not None:
metrics["house_id_resolution"] = resolution.as_json()
logger.info(
"house_id resolution: resolved=%d/%d imv_reachable=%d",
"house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d",
resolution.resolved,
resolution.total,
resolution.imv_reachable,
resolution.house_type_from_houses,
)
if dump_fixture is not None and capture is not None:
@ -2658,7 +2650,9 @@ def _write_fixture(
) -> None:
"""Freeze captured per-deal replay records into a committed JSON fixture.
``settings_at_capture`` records every ``estimate_*`` Settings field as an
``settings_at_capture`` records every ``estimate_*`` Settings field plus the
estimator's numeric module constants (#1970: калибровочные пороги переехали из
Settings в код без них снимок не говорит, с какими порогами захватили) as an
informational snapshot (NOT consulted by ``replay_fixture``). A final
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
JSON-plain before ``json.dump(allow_nan=False)``.
@ -2668,6 +2662,11 @@ def _write_fixture(
for name in sorted(type(est.settings).model_fields)
if name.startswith("estimate_")
}
settings_at_capture.update(
(name, _sanitize_json(value))
for name, value in sorted(vars(est.m).items())
if name.isupper() and isinstance(value, int | float)
)
fixture = {
"schema_version": FIXTURE_SCHEMA_VERSION,
"engine": "full",

View file

@ -2,19 +2,19 @@
"calibration": {
"high": {
"coverage_pct": 75.0,
"mape_pct": 19.6,
"mape_pct": 19.97,
"n": 8,
"n_covered": 6
},
"low": {
"coverage_pct": 83.59,
"coverage_pct": 83.67,
"mape_pct": 15.64,
"n": 1562,
"n_covered": 1029
"n_covered": 1030
},
"medium": {
"coverage_pct": 70.0,
"mape_pct": 27.59,
"mape_pct": 26.53,
"n": 30,
"n_covered": 21
}
@ -26,17 +26,17 @@
],
"expected_sold": {
"overall": {
"mape_pct": 15.88,
"mape_pct": 15.92,
"median_bias_pct": 2.87,
"n": 1269,
"n_no_analogs": 0,
"p25_pct": -10.88,
"p75_pct": 22.98
"p25_pct": -10.94,
"p75_pct": 22.81
},
"per_area_bucket": {
"0 <30": {
"mape_pct": 19.76,
"median_bias_pct": -1.49,
"median_bias_pct": -1.02,
"n": 161,
"p25_pct": -16.45,
"p75_pct": 34.7
@ -45,21 +45,21 @@
"mape_pct": 15.64,
"median_bias_pct": 4.37,
"n": 503,
"p25_pct": -10.58,
"p25_pct": -10.56,
"p75_pct": 22.86
},
"2 44-62": {
"mape_pct": 13.86,
"median_bias_pct": 4.15,
"mape_pct": 13.93,
"median_bias_pct": 4.13,
"n": 379,
"p25_pct": -7.92,
"p75_pct": 23.85
"p25_pct": -8.12,
"p75_pct": 23.5
},
"3 62-85": {
"mape_pct": 15.97,
"median_bias_pct": -2.33,
"n": 179,
"p25_pct": -12.1,
"p25_pct": -11.99,
"p75_pct": 18.18
},
"4 >=85": {
@ -74,7 +74,7 @@
"0": {
"label": "студия",
"mape_pct": 19.76,
"median_bias_pct": -1.49,
"median_bias_pct": -1.02,
"n": 161,
"n_no_analogs": 0,
"p25_pct": -16.45,
@ -86,17 +86,17 @@
"median_bias_pct": 4.37,
"n": 503,
"n_no_analogs": 0,
"p25_pct": -10.58,
"p25_pct": -10.56,
"p75_pct": 22.86
},
"2": {
"label": "2к",
"mape_pct": 13.86,
"median_bias_pct": 4.15,
"mape_pct": 13.93,
"median_bias_pct": 4.13,
"n": 379,
"n_no_analogs": 0,
"p25_pct": -7.92,
"p75_pct": 23.85
"p25_pct": -8.12,
"p75_pct": 23.5
},
"3": {
"label": "3к",
@ -104,7 +104,7 @@
"median_bias_pct": -2.33,
"n": 179,
"n_no_analogs": 0,
"p25_pct": -12.1,
"p25_pct": -11.99,
"p75_pct": 18.18
},
"4": {
@ -126,11 +126,11 @@
"p75_pct": -12.79
},
"комфорт": {
"mape_pct": 10.68,
"mape_pct": 10.69,
"median_bias_pct": -5.78,
"n": 425,
"p25_pct": -15.88,
"p75_pct": 4.49
"p25_pct": -15.98,
"p75_pct": 4.54
},
"премиум": {
"mape_pct": null,
@ -140,11 +140,11 @@
"p75_pct": null
},
"эконом": {
"mape_pct": 20.17,
"mape_pct": 20.22,
"median_bias_pct": 18.98,
"n": 697,
"p25_pct": 1.25,
"p75_pct": 41.97
"p25_pct": 0.96,
"p75_pct": 42.31
},
"элит": {
"mape_pct": 36.11,
@ -162,9 +162,9 @@
},
"range_coverage": {
"overall": {
"coverage_pct": 83.22,
"coverage_pct": 83.29,
"n": 1269,
"n_covered": 1056
"n_covered": 1057
},
"per_confidence": {
"high": {
@ -173,9 +173,9 @@
"n_covered": 6
},
"low": {
"coverage_pct": 83.59,
"coverage_pct": 83.67,
"n": 1231,
"n_covered": 1029
"n_covered": 1030
},
"medium": {
"coverage_pct": 70.0,

View file

@ -0,0 +1,911 @@
<!doctype html>
<!--
#2422: живая выдача Циана, снята 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200, ~3 МБ):
GET https://ekb.cian.ru/cat.php?deal_type=sale&engine_version=2&offer_type=flat&region=4743&room2=1
Урезано до того, что читает CianScraper._parse_serp_html / _offer_to_lot:
- из 154 записей _cianConfig['frontend-serp'] оставлены projectName и initialState;
- в initialState только results.totalOffers и 4 из 28 offers, по одному на ветку:
331661795 вторичка, 333599038 новостройка с домом в geo.address,
333279363 новостройка с decoration=fine, 321628098 новостройка без улицы и дома;
- у offer только ключи, которые читает парсер; phones/user/userId убраны (PII),
photos урезаны до 3 fullUrl, у geo.address/undergrounds только читаемые поля.
Форма записи (.concat([...]) с JSON-массивом) и значения полей не менялись.
-->
<html><head><meta charset="utf-8"></head><body>
<script type="text/javascript">
window._cianConfig = window._cianConfig || {};
window._cianConfig['frontend-serp'] = (window._cianConfig['frontend-serp'] || []).concat([
{
"key": "projectName",
"value": "frontend-serp",
"priority": 1000,
"filter": {}
},
{
"key": "initialState",
"value": {
"results": {
"totalOffers": 13895,
"offers": [
{
"cianId": 331661795,
"id": 331661795,
"fullUrl": "https://ekb.cian.ru/sale/flat/331661795/?context=4.JppPDee85nA.3hAoeZtPv9-CCGqBSDAI8EqioM8KBdEBjsgdLaVHspglG86Z8YBFyLQQuLYOzUIJ9UfTORZd8UX_kw&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"bargainAllowed": null,
"vatType": null,
"agentFee": null,
"clientFee": null,
"paymentPeriod": null,
"leaseType": null,
"deposit": null,
"mortgageAllowed": true,
"vatPrice": null,
"utilitiesTerms": null,
"price": 7990000,
"leaseTermType": null,
"priceRur": 7990000,
"tags": [],
"priceType": "all",
"priceForWorkplace": null,
"saleType": "alternative",
"currency": "rur",
"agentBonus": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "47.2",
"livingArea": "28.0",
"kitchenArea": "7.5",
"floorNumber": 2,
"building": {
"floorsCount": 17,
"parking": {
"locationType": null,
"type": "ground",
"priceEntry": null,
"purposeType": null,
"isFree": null,
"isAvailable": null,
"currency": null,
"priceMonthly": null,
"placesCount": null
},
"deadline": null,
"type": null,
"materialType": "panel",
"heatingType": null,
"accessType": null,
"classType": null,
"buildYear": 1980,
"cargoLiftsCount": 1,
"passengerLiftsCount": 1
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.820035,
"lng": 60.634287
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Октябрьский",
"fullName": "р-н Октябрьский",
"id": 290
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Парковый",
"fullName": "мкр. Парковый",
"id": 1489
},
{
"type": "metro",
"geoType": "underground",
"name": "Геологическая",
"fullName": "м. Геологическая",
"id": 346
},
{
"type": "street",
"geoType": "street",
"name": "Большакова",
"fullName": "улица Большакова",
"id": 1486289
},
{
"type": "house",
"geoType": "house",
"name": "13",
"fullName": "13",
"id": 10385250
}
],
"undergrounds": [
{
"name": "Геологическая",
"time": 5,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Чкаловская",
"time": 5,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": false
},
{
"name": "Площадь 1905 года",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": false
}
]
},
"newbuilding": null,
"balconiesCount": 0,
"loggiasCount": 1,
"bedroomsCount": null,
"hasFurniture": true,
"isSoldFurnished": true,
"decoration": null,
"isByHomeowner": false,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "В продаже двухкомнатная квартира с качественным ремонтом. \n\nРайон расположения квартиры с развитой инфраструктурой. Рядом множество детских садов и школ, городской парк развлечений \"Маяковского\" Различные бесплатные спортивные школы и секции. \n\nВо дворе дома есть бесплатные парковочные места. Места общего пользования в доме после капитального ремонта, на стенах первого этажа керамогранит, оба лифта новые. Установлена качественная сейф-дверь. В прихожей на полу керамогранитная плитка. Имеется теплый пол в прихожей. Удобно содержать пол в прихожей в чистоте. \nОстается шкаф купе в прихожей. На кухне установлен кухонный гарнитур, оставляем бытовую технику - варочную панель, духовой шкаф, вытяжку, посудомоечную машину. Окна во всей квартире и на лоджии заменены на новые пластиковые. Сан узел раздельный, в кафеле. Стояки ГВС И ХВС заменены в 2017 году.\n\nВ квартире заменена вся электропроводка. Утеплена лоджия, на полу кафельная плитка, на лоджии имеется теплый пол. Так же в стоимость квартиры входят все светильники и шторы. Рассмотрим трейд ин: у Вас не продана своя недвижимость - у нас есть кому ее предложить.\n Найдем покупателя на Вашу квартиру с фиксацией цены на наш объект. Звоните и записывайтесь на просмотр! \nДобавляйте объявление в избранное, что бы не потерять его. Просто звоните или пишите и мы найдем способ как Вам приобрести квартиру в Вашем бюджете! 2-комнатная квартира в Екатеринбурге.",
"descriptionMinhash": [
16147461,
5781654,
4548911,
115530169,
2088518,
13989882,
5236906,
29626041,
89222106,
31854558,
1755821,
30053968,
4178831,
69970867,
23526500,
79279,
22630780,
105139689,
7157020,
7498881,
439139,
40231865,
5793994,
2284150,
9460550,
20249216,
16249532,
8291340,
9226251,
55362858,
4609260,
18591719
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2915101466-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2914203429-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/kvartira-ekaterinburg-bolshakova-ulica-2915101469-1.jpg"
}
],
"addedTimestamp": 1789387127,
"offerType": "flat",
"category": "flatSale",
"fromDeveloper": false,
"buildersIds": [],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 333599038,
"id": 333599038,
"fullUrl": "https://ekb.cian.ru/sale/flat/333599038/?context=4.RIlzprvdmBE.EoIb2G8IrpAJRWmWSKXZB54SBE-BI5E9ffyir3i1BCpfutwDcbFaqH8RXGcSSW2eGb_oYww9PzpC-g&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"price": 4834000,
"vatPrice": null,
"deposit": null,
"saleType": "fz214",
"bargainAllowed": null,
"vatType": null,
"agentBonus": null,
"priceForWorkplace": null,
"agentFee": null,
"priceRur": 4834000,
"leaseType": null,
"mortgageAllowed": null,
"clientFee": null,
"paymentPeriod": null,
"tags": [],
"priceType": "all",
"utilitiesTerms": null,
"leaseTermType": null,
"currency": "rur"
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "36.04",
"livingArea": "19.8",
"kitchenArea": null,
"floorNumber": 22,
"building": {
"accessType": null,
"materialType": "monolith",
"cargoLiftsCount": null,
"deadline": {
"quarterEnd": "2028-12-31",
"year": 2028,
"quarter": "fourth",
"isComplete": false
},
"type": null,
"buildYear": null,
"heatingType": null,
"passengerLiftsCount": 7,
"classType": null,
"floorsCount": 32,
"parking": null
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lng": 60.538293335967175,
"lat": 56.875540746307834
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Железнодорожный",
"fullName": "р-н Железнодорожный",
"id": 287
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Старая Сортировка",
"fullName": "мкр. Старая Сортировка",
"id": 1494
},
{
"type": "metro",
"geoType": "underground",
"name": "Уральская",
"fullName": "м. Уральская",
"id": 343
},
{
"type": "street",
"geoType": "street",
"name": "Проходной",
"fullName": "переулок Проходной",
"id": 1486939
},
{
"type": "house",
"geoType": "house",
"name": "1Б",
"fullName": "1Б",
"id": 13406495
}
],
"undergrounds": [
{
"name": "Уральская",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Уралмаш",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Машиностроителей",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"showJkReliableFlag": false,
"isFromDeveloper": true,
"name": "Астон. Движение Вверх",
"isFromLeadFactory": false,
"isFichering": true,
"isFromBuilder": true,
"isPremium": false,
"house": {
"finishDate": {
"year": 2028,
"quarter": 4
},
"section": null,
"name": "Дом 5",
"isFinished": false,
"id": 10930182
},
"hasFlatTourBooking": false,
"comparisonStatus": null,
"id": 5779142,
"isSalesLeader": null,
"isFromSeller": false,
"countPromos": 2,
"snippetType": "advanced"
},
"balconiesCount": null,
"loggiasCount": null,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": null,
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "2-комнатная квартира с предчистовой отделкой в ЖК \"Астон. Движение вверх\" на 22 этаже. Общая площадь 36.04 м², жилая 19.83 м². \n\n\"Астон. Движение вверх\" два монолитно-блочных дома на Новой Сортировке в Екатеринбурге: один высотой 16 этажей, другой 32 этажа. Проект продолжает историю соседнего квартала \"Астон. Движение\", который уже полюбился жителям района. Строительство ведётся на месте бывшего Виншампанкомбината у этого места есть своя душа и почти вековая история.\n\nАрхитектура обыгрывает наследие территории: палитра фасадов, плавные линии и изгибы переосмысляют фактуры продукции комбината. В проекте много внимания уделено свету увеличенное остекление, продуманные планировки без глухих коридоров и тёмных углов. Из окон верхних этажей открывается панорама города это самый высокий комфорт-класс в микрорайоне.\n\nВ квартирах просторные кухни-гостиные, мастер-спальни с гардеробными, балконы и тёплые лоджии. В некоторых планировках окна в ванной комнате, что наполняет утро солнечным светом. В 32-этажной секции пять грузопассажирских лифтов, в 16-этажной по два лифта на этаж.\n\nДвор закрыт от машин, с зонами для детей разного возраста: для малышей песочницы и качели, для подростков пространство для встреч с друзьями. В просторных входных группах на первых этажах есть место для колясок. Первая очередь квартала уже сдана по утрам здесь пахнет кофе из кафе на первых этажах, вечерами гуляют с колясками и собаками.\n\nРасположение пересечение улицы Пехотинцев и Проходного переулка, в 5 минутах от Бебеля. Рядом детские сады, школы, поликлиники, торговые и фитнес-центры, остановки общественного транспорта. До центра Екатеринбурга 2530 минут на машине. Срок сдачи IV квартал 2028 года.\n\nЗастройщик: федеральный девелопер Астон, реализующий проекты в пяти городах России. Уже передано более 400 000 м² жилья. Продам 2-комнатную квартиру в Екатеринбурге.",
"descriptionMinhash": [
18308017,
8359824,
15811915,
9618444,
2088518,
6143519,
1770676,
52418947,
12009655,
11955293,
15731187,
1006418,
4178831,
5064683,
5165062,
28709808,
27942,
105139689,
37372265,
6351486,
31972117,
53400966,
5793994,
30731728,
9460550,
5740363,
24836331,
794253,
37707718,
2676229,
24769842,
1768206
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954042-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954049-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2957954053-1.jpg"
}
],
"addedTimestamp": 1789377541,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
10137
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 333279363,
"id": 333279363,
"fullUrl": "https://ekb.cian.ru/sale/flat/333279363/?context=4.eJtxwNSILJE.YF5Ute3CO5a6ojZp8vw4v4wbhDX3F1ibU5rfq6T0Mt7TEHmtX3_CR5L7_Brx_JZMKAgoly3KHiIsxg&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"tags": [],
"currency": "rur",
"priceType": "all",
"paymentPeriod": null,
"mortgageAllowed": null,
"leaseTermType": null,
"priceForWorkplace": null,
"clientFee": null,
"agentBonus": null,
"saleType": "pdkp",
"price": 8523568,
"priceRur": 8523568,
"deposit": null,
"vatPrice": null,
"vatType": null,
"leaseType": null,
"agentFee": null,
"utilitiesTerms": null,
"bargainAllowed": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "56.03",
"livingArea": "21.8",
"kitchenArea": null,
"floorNumber": 4,
"building": {
"floorsCount": 8,
"deadline": {
"year": 2026,
"isComplete": true,
"quarterEnd": "2026-09-30",
"quarter": "third"
},
"cargoLiftsCount": null,
"type": null,
"accessType": null,
"passengerLiftsCount": null,
"classType": null,
"parking": null,
"materialType": null,
"heatingType": null,
"buildYear": null
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.833388,
"lng": 60.520165
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Верх-Исетский",
"fullName": "рВерх-Исетский",
"id": 286
},
{
"type": "mikroraion",
"geoType": "district",
"name": "ВИЗ",
"fullName": "мкр. ВИЗ",
"id": 1468
},
{
"type": "metro",
"geoType": "underground",
"name": "Геологическая",
"fullName": "м. Геологическая",
"id": 346
},
{
"type": "street",
"geoType": "street",
"name": "Крауля",
"fullName": "улица Крауля",
"id": 1486179
},
{
"type": "house",
"geoType": "house",
"name": "170Б",
"fullName": "170Б",
"id": 16773087
}
],
"undergrounds": [
{
"name": "Геологическая",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Площадь 1905 года",
"time": 10,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Уральская",
"time": 13,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"id": 49143,
"showJkReliableFlag": false,
"isFromSeller": false,
"isFichering": true,
"name": "Янтарная Долина",
"isPremium": true,
"house": {
"id": 10931059,
"name": "6 очередь",
"section": "Секция 2",
"finishDate": {
"quarter": 3,
"year": 2026
},
"isFinished": true
},
"hasFlatTourBooking": false,
"comparisonStatus": null,
"isSalesLeader": null,
"isFromDeveloper": true,
"isFromBuilder": true,
"isFromLeadFactory": false,
"countPromos": 3,
"snippetType": "maximum"
},
"balconiesCount": 1,
"loggiasCount": 1,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": "fine",
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "Жилой квартал 'Янтарная долина' расположен в районе Новый ВИЗ, вблизи Верх-Исетского пруда. Предлагаем вам квартиры в шестой очереди строительства — секциях 6.2-6.4 по адресу улица Крауля, 170Б. Все секции 8-этажные, в доме 147 квартир. Дом сдан. Продажа по ДКП.\nРайон\nРайон Новый ВИЗ сочетает городской комфорт и близость природных зон. Это одно из самых перспективных направлений в Екатеринбурге, идеальное для семейного проживания.\n\n5 минут пешком до остановки транспорта\n5 школ и 4 детсада поблизости\n10 минут пешком до набережной Верх-Исетского пруда\n15 минут на машине до центра города\n16 минут на машине до ТЦ 'МЕГА'\n\nДвор\nВ центре жилого квартала — зелёный двор для насыщенной жизни, игр и отдыха. Озеленение включает лиственные и хвойные деревья, декоративные кустарники и цветники.\n\nКонцепция 'двор без машин'\nЗакрытая территория с пропускной системой и без посторонних\nКруглосуточное видеонаблюдение и освещение территории в тёмное время суток\nДетские площадки для разного возраста, футбол и уличный баскетбол\n \n\nПодъезды\nПодъезд в 'Янтарной долине' — уютное и светлое пространство, в котором комфортно.\n\nДоступ в подъезды на уровне земли — без барьеров\nДомофоны в тёплых остеклённых тамбурах\nВместительный грузопассажирский лифт во всех секциях\nПомещения для колясок и велосипедов на 1 этаже\nУдобная навигация с номерами этажей и квартир\n\nПланировки\nНаш подход 'разумные метры' при проектировании квартир учитывает состав семьи, число жильцов, их потребности, а также эргономику пространства.\n\n58 уникальных планировок\nнесколько вариантов для каждого типа квартир: от студий до 3-комнатных\nобъединённые кухни-гостиные для простора и общения\nрациональное использование каждого метра\nквартиры с гардеробными и тёплыми лоджиями\n\nОтделка\nВ каждой квартире 'Янтарной долины' мы позаботились о чистовой отделке, чтобы ваш переезд прошёл быстро и без лишних хлопот.\n\nПрочный ламинат на полу в комнатах, прихожих и кухнях, плитка в санузлах, виниловые обои на флизелиновой основе на стенах, белые натяжные потолки\nМежкомнатные двери светлых тонов и сейф-дверь на входе в квартиру\nРадиаторы с терморегуляторами и горизонтальной разводкой, двухкамерные стеклопакеты с приточными клапанами вентиляции, розетки и выключатели\nВ санузлах установлены раковины и унитазы Продается 2-комнатная квартира в Екатеринбурге.",
"descriptionMinhash": [
1799783,
5781654,
15811915,
4788959,
35289122,
6143519,
18432622,
45820160,
2258400,
11955293,
25662122,
7185657,
23168773,
14391071,
12464540,
13379849,
41127868,
40846940,
36828281,
6351486,
53971654,
349072,
10292969,
2066855,
30469210,
5740363,
14611990,
794253,
929833,
501391,
122143391,
5031640
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950202-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950220-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2950950228-1.jpg"
}
],
"addedTimestamp": 1789408293,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
10188
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
},
{
"cianId": 321628098,
"id": 321628098,
"fullUrl": "https://ekb.cian.ru/sale/flat/321628098/?context=4.ZmmZlV8JbZQ.lF2e-AgOjLa29ue-yfljyTo7TzR6qFjcVKH6jOXZzubqAE_l5Ll_VyYwlGbvrZUPDs24HvZ1DfBOXg&mlSearchSessionGuid=924c429bf7495771ff77b66074cdf292",
"bargainTerms": {
"bargainAllowed": null,
"vatType": null,
"agentFee": null,
"clientFee": null,
"paymentPeriod": null,
"leaseType": null,
"deposit": null,
"mortgageAllowed": null,
"vatPrice": null,
"utilitiesTerms": null,
"price": 7815000,
"leaseTermType": null,
"priceRur": 7815000,
"tags": [],
"priceType": "all",
"priceForWorkplace": null,
"saleType": "free",
"currency": "rur",
"agentBonus": null
},
"roomsCount": 2,
"flatType": "rooms",
"totalArea": "54.3",
"livingArea": "25.2",
"kitchenArea": "16.6",
"floorNumber": 6,
"building": {
"floorsCount": 31,
"parking": null,
"deadline": {
"quarter": "fourth",
"year": 2028,
"isComplete": false,
"quarterEnd": "2028-12-31"
},
"type": null,
"materialType": null,
"heatingType": null,
"accessType": null,
"classType": null,
"buildYear": null,
"cargoLiftsCount": null,
"passengerLiftsCount": 8
},
"cadastralNumber": null,
"buildingCadastralNumber": null,
"geo": {
"coordinates": {
"lat": 56.789383109239054,
"lng": 60.60020415337982
},
"address": [
{
"type": "location",
"geoType": "location",
"name": "Свердловская",
"fullName": "Свердловская область",
"id": 4612
},
{
"type": "location",
"geoType": "location",
"name": "Екатеринбург",
"fullName": "Екатеринбург",
"id": 4743
},
{
"type": "raion",
"geoType": "district",
"name": "Чкаловский",
"fullName": "р-н Чкаловский",
"id": 291
},
{
"type": "mikroraion",
"geoType": "district",
"name": "Вторчермет",
"fullName": "мкр. Вторчермет",
"id": 1509
},
{
"type": "metro",
"geoType": "underground",
"name": "Ботаническая",
"fullName": "м. Ботаническая",
"id": 348
}
],
"undergrounds": [
{
"name": "Ботаническая",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": true
},
{
"name": "Чкаловская",
"time": 6,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
},
{
"name": "Геологическая",
"time": 9,
"transportType": "transport",
"lineColor": "00701A",
"lineId": 26,
"isDefault": null
}
]
},
"newbuilding": {
"isFromBuilder": true,
"isFichering": true,
"name": "Стадиум",
"id": 5705732,
"house": {
"section": "Секция 4",
"name": "Корпус 1",
"id": 10906188,
"isFinished": false,
"finishDate": {
"quarter": 4,
"year": 2028
}
},
"hasFlatTourBooking": false,
"isSalesLeader": null,
"comparisonStatus": null,
"isPremium": true,
"showJkReliableFlag": false,
"isFromSeller": false,
"isFromLeadFactory": false,
"isFromDeveloper": true,
"countPromos": 10,
"snippetType": "maximum"
},
"balconiesCount": null,
"loggiasCount": null,
"bedroomsCount": null,
"hasFurniture": null,
"decoration": null,
"isByHomeowner": null,
"isPro": true,
"isApartments": false,
"isRosreestrChecked": false,
"description": "ЖК \"Стадиум\" — это ваш шанс жить в одном из самых зеленых уголков Екатеринбурга, но при этом в шаговой доступности от всего важного. Комплекс строится между улицами 8 Марта, Рижским переулком, Московской и Объездной дорогой — в сердце развивающегося Чкаловского района на пересечении границ микрорайонов Ботаники, Автовокзала и Вторчермета. \n\nПредставьте: утром вы выходите в свой закрытый двор-парк с зонами для игр и спорта, а через 10 минут уже в ТЦ \"Ботаника Молл\" или деловом центре \"Астор\". Рядом — детсады, школы, поликлиники, и главное: буквально рукой подать до Парка завода РТИ, Юго-Западного лесопарка и легендарного Ботанического сада. Четыре корпуса разной этажности (от 9 до 31) спроектированы так, чтобы из квартир открывались виды на лесные массивы. \n\nЗдесь воздух по-настоящему свежий, а квартиры — это и евроформаты, и мастер-спальни с ванной комнатой, варианты с террасами и эксклюзивные планировочные решения с зенитными фонарями и панорамными окнами. Современные холлы с 4-метровыми потолками и зоной ресепшна, подземный паркинг и продуманное озеленение создают ощущение, что вы живете за городом, но со всеми плюсами мегаполиса. \n\n\"Стадиум\" — для тех, кто хочет просыпаться под пение птиц, но не готов отказываться от ритма большого города. Это баланс, который так ценят екатеринбуржцы.\" \n\nПреимущества: \n\nВидовые квартиры с террасами \n\nПодземный двухуровневый паркинг \n\nЭксклюзивные варианты с зенитными фонарями \n\nПанорамное остекление \n\nКрышная газовая котельная \n\nПодготовка под установку бризеров \n\nВытяжная вентиляция с механическим побуждением \n\nЧистая питьевая вода в каждой квартире \n\nЛобби с зоной ресепшн и хранением посылок \n\nРаздельные велосипедные и колясочные \n\nСобственная сервисная компания \n\nДизайнерская архитектура фасадов \n\nКонцепция спорта и здорового образа жизни \n\nМесто покоя и природы среди шума города\nЕкатеринбург, 8 Марта , 01 кв.324",
"descriptionMinhash": [
41920473,
15036167,
44672376,
14416152,
2088757,
6143519,
11414134,
2885405,
2746117,
42658346,
10643968,
39685766,
19735224,
5057571,
13742308,
95802641,
4256809,
38329502,
105651759,
59650532,
8686384,
35024833,
4368848,
38907129,
9460550,
6586538,
9615691,
3940137,
1195648,
10860617,
8412860,
37661809
],
"photos": [
{
"fullUrl": "https://images.cdn-cian.ru/images/2622692186-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2626766693-1.jpg"
},
{
"fullUrl": "https://images.cdn-cian.ru/images/2626766766-1.jpg"
}
],
"addedTimestamp": 1789634608,
"offerType": "flat",
"category": "newBuildingFlatSale",
"fromDeveloper": true,
"buildersIds": [
8624
],
"isLayoutApproved": null,
"isByCommercialOwner": null,
"isCianPartner": false,
"descriptionWordsHighlighted": []
}
]
}
},
"priority": 10,
"filter": {}
}
]);
</script>
</body></html>

View file

@ -0,0 +1,120 @@
{
"_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=YES page=1. Урезано: 1 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.",
"response": {
"search": {
"offers": {
"entities": [
{
"offerId": "6216493807134609867",
"url": "//realty.yandex.ru/offer/6216493807134609867",
"price": {
"currency": "RUR",
"value": 27620850,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 366859,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 27620850,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 27620850,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 366859,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 27620850,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"minMonthlyMortgagePayment": 282584,
"mortgageInfo": {
"bestMortgage": {
"id": "2974521",
"payment": "282584"
},
"minPreferentialMortgageRate": 6
}
},
"area": {
"value": 75.29,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 29.03,
"unit": "SQUARE_METER"
},
"roomsTotal": 2,
"floorsOffered": [
12
],
"floorsTotal": 16,
"building": {
"builtYear": 2026,
"siteId": 2006795,
"siteName": "Александровский сад"
},
"location": {
"point": {
"latitude": 56.821068,
"longitude": 60.609924,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, жилой район Центральный, ЖК Александровский Сад",
"streetAddress": "Свердловская область, Екатеринбург, Декабристов",
"metro": {
"metroGeoId": 101986,
"metroCityRgid": 559132,
"name": "Геологическая",
"metroTransport": "ON_FOOT",
"timeToMetro": 9,
"latitude": 56.827534,
"longitude": 60.602486,
"minTimeToMetro": 8,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Геологическая",
"timeToMetro": 9
}
]
},
"creationDate": "2026-03-17T11:04:44Z",
"description": "Александровский сад настоящий бизнес-класс в центре Екатеринбурга от девелопера Forum. Забудьте о суете города. Ваш двор это пейзажный сад, где царят умиротворение и гармония. Прогулочные дорожки с мягкой вечерней подсветкой проведут по уединенным",
"author": {
"category": "DEVELOPER"
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/20177950/9476e26d-8cbc-4b69-a7bd-03fefa0a960a/main",
"//avatars.mds.yandex.net/get-realty-offers/16258130/d185b4dc-8386-4fa1-bdf9-0865da1f8e5d/main",
"//avatars.mds.yandex.net/get-realty-offers/13970442/da0f5415-d014-466b-b5e9-0fe70d5e8680/main",
"//avatars.mds.yandex.net/get-realty-offers/14824781/f882caeb-4d40-4775-9f0a-acb01177ff0e/main",
"//avatars.mds.yandex.net/get-realty-offers/20657783/1359e480-6c21-4c07-b1de-9e36149f83fd/main",
"//avatars.mds.yandex.net/get-realty-offers/14671802/38d6dc24-8366-4460-9e99-06a1605e1919/main"
]
}
],
"pager": {
"page": 0,
"pageSize": 20,
"sitesPageSize": 20,
"totalItems": 32234,
"totalPages": 25
}
}
}
}
}

View file

@ -0,0 +1,355 @@
{
"_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=NO page=1. Урезано: 3 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.",
"response": {
"search": {
"offers": {
"entities": [
{
"offerId": "5228381943217465492",
"url": "https://novostroi-ekb.ru/catalog/sale/vtorichka/object/1064",
"price": {
"currency": "RUR",
"value": 4400000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "DECREASED",
"previous": 4450000,
"hasPriceHistory": true,
"valuePerPart": 129412,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 4400000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 4400000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 129412,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 4400000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 34,
"unit": "SQUARE_METER"
},
"kitchenSpace": {
"value": 7,
"unit": "SQUARE_METER"
},
"roomsTotal": 1,
"floorsOffered": [
8
],
"floorsTotal": 9,
"ceilingHeight": 2.5,
"building": {
"builtYear": 1978,
"buildingType": "BRICK"
},
"location": {
"point": {
"latitude": 56.903275,
"longitude": 60.6229,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Шефская улица, 85",
"streetAddress": "Шефская улица, 85",
"metro": {
"metroGeoId": 101947,
"metroCityRgid": 559132,
"name": "Проспект Космонавтов",
"metroTransport": "ON_FOOT",
"timeToMetro": 7,
"latitude": 56.901844,
"longitude": 60.61394,
"minTimeToMetro": 5,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Проспект Космонавтов",
"timeToMetro": 7
},
{
"name": "Машиностроителей",
"timeToMetro": 14
}
]
},
"creationDate": "2026-08-19T06:23:51Z",
"description": "1 комнатная квартира 34 кв .м с развитой инфраструктурой , станция метро «КОСМАНАВТОВ» в шаговой доступности . Фукциональная планировка правильной формы, дополнительное место хранения гардеробная , в квартире сделана качественная предчистовая отделка",
"author": {
"category": "AGENCY",
"organization": "САМОЛЕТ ПЛЮС"
},
"predictions": {
"predictedPrice": {
"min": "3795000",
"max": "4638000",
"value": "4217000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/14333116/89f721ff-ad4b-49f1-8cec-432ad11baeab/main",
"//avatars.mds.yandex.net/get-realty-offers/13853239/caf4740f-dca8-47a3-8005-56968d20a3b1/main",
"//avatars.mds.yandex.net/get-realty-offers/14244677/f1943334-731e-4539-98f6-9d38d4a725ce/main",
"//avatars.mds.yandex.net/get-realty-offers/12726755/346626db-ba69-4a49-8940-76f08d6aa924/main",
"//avatars.mds.yandex.net/get-realty-offers/20074534/021cc849-6554-4059-9423-19193e3006b9/main",
"//avatars.mds.yandex.net/get-realty-offers/13912669/b240dbf0-1adc-4dbd-9ce8-c0ca6c478fc8/main"
]
},
{
"offerId": "6282460993454547968",
"url": "//realty.yandex.ru/offer/6282460993454547968",
"price": {
"currency": "RUR",
"value": 10290000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 294000,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 10290000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 10290000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 294000,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 10290000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 35,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 11,
"unit": "SQUARE_METER"
},
"kitchenSpace": {
"value": 14,
"unit": "SQUARE_METER"
},
"roomsTotal": 1,
"floorsOffered": [
27
],
"floorsTotal": 27,
"ceilingHeight": 2.75,
"building": {
"builtYear": 2023,
"buildingType": "MONOLIT",
"siteId": 1668651,
"siteName": "Парковый квартал"
},
"location": {
"point": {
"latitude": 56.815243,
"longitude": 60.62593,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Машинная улица, 4",
"streetAddress": "Машинная улица, 4",
"metro": {
"metroGeoId": 116399,
"metroCityRgid": 559132,
"name": "Чкаловская",
"metroTransport": "ON_FOOT",
"timeToMetro": 17,
"latitude": 56.807995,
"longitude": 60.610184,
"minTimeToMetro": 12,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Чкаловская",
"timeToMetro": 17
},
{
"name": "Ботаническая",
"timeToMetro": 13
},
{
"name": "Площадь 1905 Года",
"timeToMetro": 16
},
{
"name": "Геологическая",
"timeToMetro": 20
}
]
},
"creationDate": "2026-08-02T14:04:19Z",
"description": "Продам квартиру в 10 минутах от центра города. Рядом Парк Маяковского. Хорошая транспортная доступность: до метро можно дойти пешком. Великолепный вид из окна.Хороший обзор. Высокие потолки, 2,75.Нет соседей сверху. Качественный",
"author": {
"category": "OWNER"
},
"predictions": {
"predictedPrice": {
"min": "8741000",
"max": "10683000",
"value": "9712000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/10211679/add.69def35c26379a08babb742069d310dc.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14373170/add.e2e2b76847d9b69e3abe0c64efee75c4.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14717586/add.c65fd15bb15736448bac2a597ffd0bae.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/11512705/add.b894c4e448f696251deaed148ee9c30e.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/13847384/add.f13072669ebf54647b1682fc70fa9e99.realty-api-vos/main",
"//avatars.mds.yandex.net/get-realty-offers/14527622/add.1708ed4ada10c2d407f64b0e744d8047.realty-api-vos/main"
]
},
{
"offerId": "8530722463088927619",
"url": "//realty.yandex.ru/offer/8530722463088927619",
"price": {
"currency": "RUR",
"value": 5600000,
"period": "WHOLE_LIFE",
"unit": "WHOLE_OFFER",
"trend": "UNCHANGED",
"hasPriceHistory": false,
"valuePerPart": 269231,
"unitPerPart": "SQUARE_METER",
"valueForWhole": 5600000,
"unitForWhole": "WHOLE_OFFER",
"price": {
"value": 5600000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
},
"pricePerPart": {
"value": 269231,
"currency": "RUB",
"priceType": "PER_METER",
"pricingPeriod": "WHOLE_LIFE"
},
"priceForWhole": {
"value": 5600000,
"currency": "RUB",
"priceType": "PER_OFFER",
"pricingPeriod": "WHOLE_LIFE"
}
},
"area": {
"value": 20.8,
"unit": "SQUARE_METER"
},
"livingSpace": {
"value": 12,
"unit": "SQUARE_METER"
},
"floorsOffered": [
8
],
"floorsTotal": 11,
"ceilingHeight": 2.7,
"building": {
"builtYear": 2021,
"buildingType": "MONOLIT_BRICK",
"siteId": 1948687,
"siteName": "Северный квартал"
},
"location": {
"point": {
"latitude": 56.89804,
"longitude": 60.6199,
"precision": "EXACT"
},
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, переулок Ритслянда, 15",
"streetAddress": "переулок Ритслянда, 15",
"metro": {
"metroGeoId": 101947,
"metroCityRgid": 559132,
"name": "Проспект Космонавтов",
"metroTransport": "ON_FOOT",
"timeToMetro": 5,
"latitude": 56.901844,
"longitude": 60.61394,
"minTimeToMetro": 5,
"lineColors": [
"f03d2f"
],
"rgbColor": "f03d2f"
},
"metroList": [
{
"name": "Проспект Космонавтов",
"timeToMetro": 5
},
{
"name": "Уралмаш",
"timeToMetro": 13
},
{
"name": "Машиностроителей",
"timeToMetro": 12
}
]
},
"creationDate": "2026-09-15T06:17:51Z",
"description": "Продается прекрасная студия в ЖК Северные кварталы, застройщик Брусника. Дом комфорт класса Площадь студии 20,8 плюс теплая лоджия 4кв.м (убрана перегородка) -благодаря этому площадь студии увеличена до 24,8кв.м - компактно и функционально для одного",
"author": {
"category": "AGENCY",
"organization": "DOMOS CLUB"
},
"predictions": {
"predictedPrice": {
"min": "4562000",
"max": "5576000",
"value": "5069000"
}
},
"mainImages": [
"//avatars.mds.yandex.net/get-realty-offers/14727395/1635cddb-a57c-4cce-a6c1-c70b9aef13cd/main",
"//avatars.mds.yandex.net/get-realty-offers/14621926/3ae41eaf-5b30-46ec-b844-b40af2bb697a/main",
"//avatars.mds.yandex.net/get-realty-offers/12719313/6921877e-51f6-4d02-93ef-0395b99776bb/main",
"//avatars.mds.yandex.net/get-realty-offers/13297592/8a8d583b-d205-460e-aec8-c9cb827eae73/main",
"//avatars.mds.yandex.net/get-realty-offers/10246351/8eee4902-aa24-42dc-aaa2-9259c82146d8/main",
"//avatars.mds.yandex.net/get-realty-offers/14621926/116dc1df-3288-4236-acfe-2f660fb2ac8b/main"
]
}
],
"pager": {
"page": 0,
"pageSize": 20,
"sitesPageSize": 20,
"totalItems": 4077,
"totalPages": 25
}
}
}
}
}

View file

@ -0,0 +1,45 @@
"""scraper_kit не импортирует app.* (#2422 п.2).
Инвариант держал test_kit_avito_has_no_app_imports, удалённый вместе с golden-parity
(#2419). Проверка обходит весь пакет, а не только providers: импорт app внутри kit —
цикл зависимостей, где бы он ни стоял. Смотрит AST, поэтому находит и ленивый импорт
внутри функции, и не срабатывает на слово «app» в комментарии.
"""
from __future__ import annotations
import ast
import os
from pathlib import Path
import scraper_kit
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
KIT_ROOT = Path(scraper_kit.__file__).parent
def _app_imports(path: Path) -> list[str]:
found: list[str] = []
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0:
names = [node.module or ""]
else:
continue
found += [f"{n} (строка {node.lineno})" for n in names if n.split(".")[0] == "app"]
return found
def test_kit_has_no_app_imports() -> None:
files = sorted(KIT_ROOT.rglob("*.py"))
# Обход не пустой и дошёл до провайдеров — иначе тест зелёный по построению.
assert KIT_ROOT / "providers" / "avito" / "houses.py" in files
assert len(files) > 40
offenders = {
str(p.relative_to(KIT_ROOT)): imports for p in files if (imports := _app_imports(p))
}
assert offenders == {}

View file

@ -0,0 +1,401 @@
"""Разбор kit-парсеров на сохранённых страницах площадок: фикстура → лот (#2422, #2408).
Golden-parity тесты сверяли kit с legacy-копией и ушли вместе с ней (#2419). После
этого разбор avito.houses, cian.serp и yandex.serp не проверял никто: правка
извлечения поля в kit проходила бы зелёной. Здесь ожидания записаны прямо значениями
из сохранённых страниц, без второй реализации в роли эталона.
Фикстуры:
- avito_house_572538.html, avito_houses_sample.html карточки домов Авито
(вторичка), рейтинг только в reviews.filterEntries (#1789);
- avito_novostroyka_12879380.html карточка ЖК (developmentPage);
- avito_serp_sample.html выдача Авито, собранная из живой страницы (#726);
- cian_serp_ekb_2room_trimmed.html, yandex_gate_ekb_*_trimmed.json живые выдачи
2026-09-17, урезанные до читаемых парсером полей (что и как урезано в шапке
каждого файла; разбор урезанной и полной выдачи совпал поле в поле, кроме числа
фото).
Не покрыто здесь, потому что уже покрыто на фикстурах в своих файлах: avito.detail
(test_avito_detail_fields_3047.py), cian.detail (test_cian_views_parse.py,
test_cian_bti_house_persist.py), domclick (test_domclick_sweep.py,
scrapers/test_domclick_detail.py), base (scrapers/test_base_clamp_date.py).
"""
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.houses import (
HouseCatalogEnrichment,
_extract_preloaded_state,
parse_houses_state,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.yandex.serp import _extract_gate_data, _parse_gate_json
FIXTURES = Path(__file__).parent.parent / "fixtures"
def _read(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
# ── avito.houses: карточка дома (вторичка) ─────────────────────────────────────
def _house(name: str, url: str) -> HouseCatalogEnrichment:
state = _extract_preloaded_state(_read(name))
assert state is not None, name
return parse_houses_state(state, url)
@pytest.fixture(scope="module")
def house_572538() -> HouseCatalogEnrichment:
return _house("avito_house_572538.html", "https://www.avito.ru/catalog/houses/572538")
@pytest.fixture(scope="module")
def house_115188() -> HouseCatalogEnrichment:
return _house("avito_houses_sample.html", "https://www.avito.ru/catalog/houses/115188")
def test_avito_house_characteristics(house_572538: HouseCatalogEnrichment) -> None:
h = house_572538.house
assert h.ext_id == 572538
assert h.ext_id_hash == "MTE3LjQ1Nzk0Mg"
assert h.full_address == "Свердловская область, Екатеринбург, ул. Сони Морозовой, 190"
assert (h.lat, h.lon) == (56.832359, 60.625583)
assert h.year_built == 1994
assert h.total_floors == 10
assert h.house_type == "brick"
assert h.material_floors == "Железобетонные"
assert h.hot_water == "Центральное"
assert h.passenger_elevators == 1
assert h.cargo_elevators is None # параметра нет на странице — не 0
assert h.has_concierge is True
assert h.has_playground is True
assert h.closed_yard is True
def test_avito_house_rating_from_reviews_filter_entries_1789(
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
) -> None:
"""В обеих карточках housePage отдаёт бейдж без оценки (scoreString «—»), оценка
есть только в reviews.filterEntries[type=score]. Без запасного пути #1789
рейтинг и число отзывов были бы None."""
h = house_572538.house
assert h.rating_score == pytest.approx(14 / 3)
assert h.rating_string == "4,7"
assert h.reviews_count == 3
assert [(r["score"], r["count"]) for r in h.rating_distribution] == [
(5, 2),
(4, 1),
(3, 0),
(2, 0),
(1, 0),
]
h2 = house_115188.house
assert (h2.rating_score, h2.rating_string, h2.reviews_count) == (4.0, "4,0", 4)
def test_avito_house_reviews(house_572538: HouseCatalogEnrichment) -> None:
reviews = {r.ext_review_id: r for r in house_572538.reviews}
assert list(reviews) == [2360701, 2018777, 1757422]
full = reviews[2018777]
assert full.author_name == "Иван"
assert full.score == 5
assert full.rated_date == date(2025, 9, 14)
assert full.text_main is not None and full.text_main.startswith("Желательно добавить")
assert full.text_pros is not None and full.text_pros.startswith("Удобное расположение")
assert full.text_cons is not None and full.text_cons.startswith("Возможно, проблемы")
# Секции без заголовка нет — основной текст пуст, плюсы/минусы на месте.
no_main = reviews[1757422]
assert no_main.rated_date == date(2025, 7, 9)
assert no_main.text_main is None
assert no_main.text_pros is not None and no_main.text_pros.startswith("Двор закрытый")
# Отзыв 2360701 датирован «16 апреля» без года — такую дату разбор сейчас не
# читает (rated_date=None). Это дефект, а не контракт, поэтому здесь не закреплён.
def test_avito_house_reviews_next_page(
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
) -> None:
assert house_572538.reviews_next_page_url is None # запись pages без nextPageUrl
assert house_115188.reviews_next_page_url == (
"/web/3/rating/model?categoryId=24&catalogId=283&paths[]=115188&limit=10&cursor=3"
"&sortBy[]=modelTypeId&sortBy[]=publishedAt&modelTypeId=14&developmentId=115188"
)
def test_avito_house_placement_history(house_572538: HouseCatalogEnrichment) -> None:
history = house_572538.placement_history
assert len(history) == 8
first = history[0]
assert first.ext_item_id == "2304054124"
assert first.title == "3-к. квартира, 108\xa0м², 5/10\xa0эт."
assert (first.start_price, first.last_price) == (11_000_000, 14_000_000)
assert first.start_price_date == date(2022, 1, 8)
assert first.last_price_date == date(2026, 1, 17)
assert first.exposure_days == 677
assert first.removed_date is None # виджет даты снятия не отдаёт
assert first.raw_payload is not None and "itemImage" not in first.raw_payload
def test_avito_house_mini_serp_and_seller(house_572538: HouseCatalogEnrichment) -> None:
# Цена в виджете — строка «21 700 000 ₽» с неразрывными пробелами.
assert [(m.ext_item_id, m.price_rub) for m in house_572538.mini_serp] == [
(8007602481, 21_700_000),
(8029678809, 21_600_000),
]
first = house_572538.mini_serp[0]
assert first.metro_text == "Геологическая, 1,5 км"
assert first.metro_color == "#0A6F20"
assert first.seller is not None
assert first.seller.ext_seller_id == "i251003466"
assert first.seller.name == "Самолет Плюс Центральный"
assert first.seller.seller_type == "Компания"
def test_avito_house_recommendations(house_115188: HouseCatalogEnrichment) -> None:
recs = house_115188.recommendations
assert len(recs) == 14
assert recs[0].title == "1-к. квартира, 30,3\xa0м², 3/5\xa0эт."
assert recs[0].price_text == "4\xa0000\xa0000 ₽"
assert recs[0].address == "Свердловская область, Екатеринбург, улица 40-летия Октября, 32А"
assert recs[0].image_url is not None and recs[0].image_url.startswith(
"https://50.img.avito.st/"
)
# ── avito.houses: карточка ЖК (developmentPage) ────────────────────────────────
def test_avito_novostroyka_development_page() -> None:
e = _house(
"avito_novostroyka_12879380.html",
"https://www.avito.ru/catalog/novostroyki/ekaterinburg/semeynyy_kvartal_beregovoy/12879380",
)
h = e.house
assert h.ext_id == 12879380
assert h.ext_id_hash == "MGDKHN70PQ"
assert h.title == "Семейный квартал «Береговой»"
assert h.short_address == "ул. Щербакова, жилые дома"
assert h.full_address is None # на странице пустая строка
assert (h.lat, h.lon) == (56.77005, 60.67244)
assert h.house_type == "monolith"
assert h.house_class == "comfort"
assert (h.passenger_elevators, h.cargo_elevators) == (42, 60)
assert (h.developer_name, h.developer_key) == ("Талан", "6749804656-3468412387")
assert h.year_built is None # дом строится: «Срок сдачи», а не год постройки
assert h.rating_score is None
assert e.reviews == [] and e.placement_history == []
# ── avito.serp ─────────────────────────────────────────────────────────────────
def test_avito_serp_cards_to_lots() -> None:
scraper = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True)) # type: ignore[arg-type]
lots = scraper._parse_html(
_read("avito_serp_sample.html"),
source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/prodam?p=1",
)
got = [
(lot.source_id, lot.price_rub, lot.rooms, lot.area_m2, lot.floor, lot.total_floors)
for lot in lots
]
assert got == [
("8043936560", 5_500_000, 2, 54.0, 5, 9),
("8163084615", 4_200_000, 1, 38.0, 3, 16),
]
assert lots[0].source_url == (
"https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_54m_59et._8043936560"
)
# sortTimeStamp в миллисекундах, дата по Москве (#726).
assert [lot.listing_date for lot in lots] == [date(2023, 11, 15), date(2023, 11, 16)]
assert {lot.listing_segment for lot in lots} == {"vtorichka"}
assert scraper.parse_failures == 0
# ── cian.serp ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def cian_lots() -> dict[str, Any]:
scraper = CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
html = _read("cian_serp_ekb_2room_trimmed.html")
lots = scraper._parse_serp_html(html)
assert scraper._extract_total_offers(html) == 13_895
assert (scraper.state_extraction_attempts, scraper.state_extraction_failures) == (2, 0)
return {lot.source_id: lot for lot in lots}
def test_cian_serp_all_offers_parsed(cian_lots: dict[str, Any]) -> None:
assert list(cian_lots) == ["331661795", "333599038", "333279363", "321628098"]
def test_cian_serp_secondary_offer(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["331661795"]
assert lot.source == "cian"
assert lot.source_url.startswith("https://ekb.cian.ru/sale/flat/331661795/")
assert lot.listing_segment == "vtorichka"
assert (lot.house_source, lot.house_ext_id) == ("cian", None)
assert lot.price_rub == 7_990_000
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (
2,
47.2,
28.0,
7.5,
)
assert (lot.floor, lot.total_floors, lot.year_built) == (2, 17, 1980)
assert lot.house_type == "panel"
assert lot.address == "р-н Октябрьский, мкр. Парковый, улица Большакова, 13"
assert (lot.lat, lot.lon) == (56.820035, 60.634287)
assert lot.listing_date == date(2026, 9, 14)
assert (lot.mortgage_available, lot.sale_type) == (True, "alternative")
assert (lot.is_homeowner, lot.is_pro_seller) == (False, True)
assert lot.phones == [] # #2217: телефоны не сохраняем
assert lot.metro_stations[0] == {
"name": "Геологическая",
"time": 5,
"mode": "transport",
"line_color": "00701A",
"line_id": 26,
"is_default": True,
}
# descriptionMinhash приходит списком — склеен через запятую в исходном порядке.
assert lot.description_minhash.startswith("16147461,5781654,4548911,")
assert len(lot.photo_urls) == 3
def test_cian_serp_newbuilding_with_house(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["333599038"]
assert lot.listing_segment == "novostroyki"
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5779142")
assert lot.address == "р-н Железнодорожный, мкр. Старая Сортировка, переулок Проходной, 1Б"
assert lot.year_built == 2028 # buildYear нет, берётся год сдачи из deadline
assert lot.house_type == "monolith"
assert (lot.price_rub, lot.area_m2, lot.floor, lot.total_floors) == (4_834_000, 36.04, 22, 32)
assert lot.sale_type == "fz214"
def test_cian_serp_newbuilding_decoration_and_balcony(cian_lots: dict[str, Any]) -> None:
lot = cian_lots["333279363"]
assert lot.repair_state == "good" # decoration=fine
assert lot.has_balcony is True # balconiesCount=1
assert lot.house_type is None # materialType на странице null
assert lot.year_built == 2026
def test_cian_serp_newbuilding_without_street_keeps_address_houseless(
cian_lots: dict[str, Any],
) -> None:
"""В geo.address нет ни улицы, ни дома; в названии ЖК номера нет, кадастра нет.
Номер дома не выдумывается (#1773)."""
lot = cian_lots["321628098"]
assert lot.address == "р-н Чкаловский, мкр. Вторчермет"
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5705732")
# ── yandex.serp ────────────────────────────────────────────────────────────────
def _gate(name: str) -> dict[str, Any]:
return json.loads(_read(name))
@pytest.fixture(scope="module")
def yandex_secondary() -> dict[str, Any]:
lots = _parse_gate_json(_gate("yandex_gate_ekb_vtorichka_trimmed.json"), 1, "NO")
return {lot.source_id: lot for lot in lots}
def test_yandex_gate_pager() -> None:
extracted = _extract_gate_data(_gate("yandex_gate_ekb_vtorichka_trimmed.json"))
assert extracted is not None
entities, pager = extracted
assert len(entities) == 3
assert (pager["totalItems"], pager["totalPages"]) == (4077, 25)
def test_yandex_agency_offer_with_partner_url(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["5228381943217465492"]
# url в выдаче — сайт агентства; source_url строится из offerId (#2235).
assert lot.source_url == "https://realty.yandex.ru/offer/5228381943217465492/"
assert lot.yandex_offer_id == "5228381943217465492"
assert lot.listing_segment == "vtorichka"
assert (lot.price_rub, lot.price_per_m2) == (4_400_000, 129_412)
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (1, 34.0, None, 7.0)
assert (lot.floor, lot.total_floors, lot.year_built) == (8, 9, 1978)
assert lot.house_type == "brick" # BRICK
assert lot.ceiling_height_m == 2.5
assert lot.address == "Россия, Свердловская область, Екатеринбург, Шефская улица, 85"
assert (lot.lat, lot.lon) == (56.903275, 60.6229)
assert (lot.house_source, lot.house_ext_id) == (None, None)
assert lot.listing_date == lot.publish_date == date(2026, 8, 19)
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, True, "САМОЛЕТ ПЛЮС")
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
4_217_000,
3_795_000,
4_638_000,
)
assert (lot.price_trend, lot.price_previous_rub) == ("DECREASED", 4_450_000)
assert lot.metro_stations[0] == {"name": "Проспект Космонавтов", "min": 7}
# В фикстуре 6 фото с «//»: берутся первые 5 со схемой.
assert len(lot.photo_urls) == 5
assert all(u.startswith("https://avatars.mds.yandex.net/") for u in lot.photo_urls)
def test_yandex_owner_offer_in_complex(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["6282460993454547968"]
assert lot.source_url == "https://realty.yandex.ru/offer/6282460993454547968"
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (True, None, None)
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "1668651")
assert lot.house_type == "monolith" # MONOLIT
assert (lot.floor, lot.total_floors) == (27, 27)
assert (lot.living_area_m2, lot.kitchen_area_m2) == (11.0, 14.0)
def test_yandex_studio_without_rooms_total(yandex_secondary: dict[str, Any]) -> None:
lot = yandex_secondary["8530722463088927619"]
assert lot.rooms == 0 # roomsTotal отсутствует — студия
assert lot.area_m2 == 20.8
assert lot.kitchen_area_m2 is None
assert lot.house_type == "monolith_brick" # MONOLIT_BRICK
def test_yandex_newbuilding_without_valuation() -> None:
lots = _parse_gate_json(_gate("yandex_gate_ekb_novostroyki_trimmed.json"), 1, "YES")
assert len(lots) == 1
lot = lots[0]
assert lot.source_id == "6216493807134609867"
assert lot.listing_segment == "novostroyki"
assert lot.price_rub == 27_620_850
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
None,
None,
None,
)
assert lot.ceiling_height_m is None
assert lot.house_type is None
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "2006795")
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, None, None)
assert lot.address == (
"Россия, Свердловская область, Екатеринбург, жилой район Центральный, "
"ЖК Александровский Сад"
)

View file

@ -36,6 +36,7 @@ import pytest
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS
from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
)
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
captured.update(kwargs)
return mock_session
with patch("curl_cffi.requests.AsyncSession", _fake_session):
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
asyncio.run(_call_kit_own_session(config=None))
assert captured.get("proxies") is None
@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP
captured.update(kwargs)
return mock_session
with patch("curl_cffi.requests.AsyncSession", _fake_session):
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
assert captured.get("proxies") == {
"http": "http://test-proxy.local:8080",
"https": "http://test-proxy.local:8080",
}
# #2386: сессия строится общей build_document_session — параметры те же, что были у
# собственной (impersonate/timeout=25/document-заголовки).
assert captured.get("impersonate") == DEFAULT_IMPERSONATE
assert captured.get("timeout") == 25
assert captured.get("headers") == DOCUMENT_HEADERS

View file

@ -83,9 +83,9 @@ _SSR_LITERAL = """{
},
"legalOptions": {"saleType": "Свободная продажа"},
"egrnData": {
"area": 38.2,
"floor": 5,
"owners_count": 1,
"area": {"status": "success", "value": 38.2},
"floor": {"status": "success", "value": 5},
"owners_count": {"status": "success", "value": 1},
"collateral": true,
"collateral_sber": false
},
@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None:
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
assert "house_type" not in raw
assert raw["domclick_building_guid"] == "abc-guid-123"
assert raw["egrn_area"] == 38.2
assert raw["egrn_area"] == {"status": "success", "value": 38.2}
assert raw["demand"]["calls"] == 5
assert raw["demand"]["favorites"] == 12
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import.
@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
resolve_cian_zhk_url_via_search,
)
# config обязателен (#2385); на пути с переданной session он не читается.
_CONFIG = SimpleNamespace(cian_proxy_url=None)
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None:
async def test_resolve_via_search_happy_path() -> None:
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
session = _mock_session(status_code=200, text=_serp_fixture())
url = await resolve_cian_zhk_url_via_search(48853, session=session)
url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session)
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
# caller owns a passed-in session — resolver must NOT close it.
session.close.assert_not_awaited()
@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None:
async def test_resolve_via_search_no_match_returns_none() -> None:
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
session = _mock_session(status_code=200, text="<html>no results</html>")
assert await resolve_cian_zhk_url_via_search(999, session=session) is None
assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None
@pytest.mark.asyncio
async def test_resolve_via_search_non_200_returns_none() -> None:
"""A non-200 (block / 404) → None without attempting extraction."""
session = _mock_session(status_code=429, text="too many requests")
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
@pytest.mark.asyncio
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
session = MagicMock()
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
session.close = AsyncMock()
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None

View file

@ -167,7 +167,9 @@ class FakeSession:
exp = row.get("expires_at")
return exp is None or exp > datetime.now(UTC)
if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
# primary: своя affinity ИЛИ 'any'. Полный фрагмент, а не "provider_affinity IN":
# с #3299 такой же IN есть и в backup-подзапросе fallback'а.
if "provider_affinity IN (:provider, 'any')" in sql:
cands = [
r
for r in self.rows
@ -189,17 +191,27 @@ class FakeSession:
# тоже гейтим по подстроке (b2-подзапрос), иначе мок «чинил» бы
# незащищённый SQL сам.
backup_must_be_usable = "b2.banned_until > now()" in sql
# #3299: backup — 'any' или та же affinity, здоровый, с живой арендой; гейт
# по подстроке, как выше — иначе мок сам «чинил» бы старый предикат.
counts_any = "IN (sp.provider_affinity, 'any')" in sql
def _has_backup(row: dict[str, Any]) -> bool:
if row["provider_affinity"] == "any":
return True
affinities = (
(row["provider_affinity"], "any")
if counts_any
else (row["provider_affinity"],)
)
return any(
other["provider_affinity"] == row["provider_affinity"]
other["provider_affinity"] in affinities
and other["enabled"]
and (not counts_any or other["consecutive_fails"] < max_fails)
and (not counts_any or _not_expired(other))
and other["id"] != row["id"]
and not (
backup_must_be_usable
and self._has_active_ban(other["id"], other["provider_affinity"])
and self._has_active_ban(other["id"], row["provider_affinity"])
)
for other in self.rows
)
@ -392,13 +404,24 @@ class FakeSession:
# fallback-safe: другой ПРИГОДНЫЙ узел ТОЙ ЖЕ affinity (банимый узел
# остаётся enabled и тоже считается — бан теперь per-source; а вот
# забаненный своим же источником backup'ом не считается).
# #3299: тот же гейт, что в acquire-ветке мока.
counts_any = "IN (sp.provider_affinity, 'any')" in sql
affinities = (
(sp["provider_affinity"], "any") if counts_any else (sp["provider_affinity"],)
)
return any(
other["provider_affinity"] == sp["provider_affinity"]
other["provider_affinity"] in affinities
and other["enabled"]
and (not counts_any or other["consecutive_fails"] < max_fails)
and (
not counts_any
or other.get("expires_at") is None
or other["expires_at"] > datetime.now(UTC)
)
and other["id"] != sp["id"]
and not (
backup_must_be_usable
and self._has_active_ban(other["id"], other["provider_affinity"])
and self._has_active_ban(other["id"], sp["provider_affinity"])
)
for other in self.rows
)
@ -712,11 +735,16 @@ def test_acquire_fallback_skips_expired_lease() -> None:
def test_acquire_fallback_prefers_non_expired_over_expired() -> None:
"""Просроченный узел пропускается, живой той же чужой affinity — выдан fallback'ом."""
"""Просроченный узел пропускается, живой той же чужой affinity — выдан fallback'ом.
Узел 3 (занят чужим прогоном) резерв cian: с #3299 просроченный узел 1 резервом
не считается, и без узла 3 живой узел 2 был бы последним для cian и защищён.
"""
db = FakeSession(
[
_proxy(1, affinity="cian", expires_at=datetime.now(UTC) - timedelta(minutes=1)),
_proxy(2, affinity="cian", expires_at=datetime.now(UTC) + timedelta(hours=1)),
_proxy(3, affinity="cian", leased_by=99),
]
)
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
@ -735,9 +763,9 @@ def test_acquire_warns_on_expired_proxy(caplog: pytest.LogCaptureFixture) -> Non
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None and lease.id == 2 # живой узел всё равно выдан
assert any(
"expired" in r.message and "id=1" in r.message for r in caplog.records
), "ожидался WARNING про просроченный proxy id=1"
assert any("expired" in r.message and "id=1" in r.message for r in caplog.records), (
"ожидался WARNING про просроченный proxy id=1"
)
# ── release ──────────────────────────────────────────────────────────────────
@ -1549,9 +1577,7 @@ async def test_probe_proxy_proxy_error_logs_single_line_without_traceback(
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
"http://u:p@h1:8080"
)
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy("http://u:p@h1:8080")
assert ok is False
assert exit_ip is None

View file

@ -31,12 +31,19 @@ tests/test_pdf_real_render.py
tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits
tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number
tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number
tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
tests/test_user_events.py::test_real_record_event_inserts_row
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
@ -146,3 +153,49 @@ tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hun
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
# Жилая площадь и балконы переживают переобход выдачи (#3252) — тот же `_live_session()`.
# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: SERP без полей не стирает то, что
# добыла карточка (COALESCE), и настоящее новое значение всё ещё перезаписывает.
# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip.
tests/test_3252_domclick_card_fields.py::test_serp_rescrape_keeps_detail_living_area_and_balconies
tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites
# Пул прокси (#3299, #3310, #3404) — предикаты выдачи/защиты и атрибуция прогона живут
# в SQL, мок их не проверяет. Live-тесты на схеме из миграций: #3299 и #3310 — в одной
# внешней транзакции с откатом (commit пула = RELEASE savepoint), #3404 — настоящими
# сессиями, строки t3404-* удаляются в finally. В ci-tradein.yml идут по-настоящему
# (postgres-сервис, #2745); на машине без БД и в deploy-tradein.yml — skip.
tests/test_3299_fallback_counts_any_nodes.py::test_dedicated_node_goes_to_other_source_when_any_node_backs_it
tests/test_3299_fallback_counts_any_nodes.py::test_only_dedicated_node_without_any_nodes_stays_protected
tests/test_3299_fallback_counts_any_nodes.py::test_any_node_banned_by_dedicated_source_is_not_a_backup
tests/test_3299_fallback_counts_any_nodes.py::test_unhealthy_any_node_is_not_a_backup
tests/test_3299_fallback_counts_any_nodes.py::test_expired_any_node_is_not_a_backup
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_counts_dedicated_node_reachable_via_any_backup
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_still_protects_when_dedicated_node_has_no_usable_backup
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_unhealthy_backup_does_not_count
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_backup_does_not_count
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_node_does_not_save_the_source
tests/test_3310_curl_ban_keeps_last_node.py::test_three_bans_on_last_node_keep_it_in_the_pool
tests/test_3310_curl_ban_keeps_last_node.py::test_transport_failure_still_counts_against_node_health
tests/test_3404_egress_run_attribution.py::test_resolve_within_run_writes_node_to_scrape_runs
tests/test_3404_egress_run_attribution.py::test_resolve_outside_run_writes_nothing
tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_callers_transaction
# Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent
tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many
# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с
# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на
# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745).
tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head
# Снапшоты «строка на изменение» (#2993, миграция 325): сравнение с последним
# снимком источника и суточная идемпотентность исполняет только Postgres — на
# мок-лэйне деплоя БД нет. В ci-tradein.yml бегут по-настоящему (postgres-сервис).
tests/test_2993_snapshot_change_only.py::test_events_survive_change_only_writes
tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today
tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row

View file

@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl),
at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl),
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
guard above, lines 141-148)."""

View file

@ -0,0 +1,202 @@
"""Упавший хендлер не оставляет строку scrape_runs в 'running' до zombie-reaper'а (#1940).
Прод 17.09 06:58: avito_city_sweep run 7349 через 10 мс после claim BrowserFetcher.__aenter__
бросил NoProxyAvailableError, планировщик записал «crashed run_id=7349» и всё. Строка
осталась 'running' с heartbeat_at == started_at; 21 сутки до этого так ушли в 'zombie'
18 avito-свипов, каждый через 6 ч. Проверяется значение статуса в строке прогона.
Финализатор `runs.mark_crashed`. Его зовут оба запускающих: планировщик и ручные
запуски из админки (они идут мимо `_dispatch`). Здесь боевой `mark_crashed` над
двойником одной строки scrape_runs: `_Row` отвечает на те же SQL, что пишет runs.py,
с гейтом `WHERE status = 'running'`.
"""
from __future__ import annotations
import asyncio
import os
import re
import types
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, CONSECUTIVE_FAILURE_ALERT_THRESHOLD
from scraper_kit.orchestration.scheduler import Handler, SchedulerContext, _dispatch
from scraper_kit.proxy_errors import NoProxyAvailableError
RUN_ID = 7349
class _Row:
"""Сессия над одной строкой scrape_runs (status/ban_kind/error/counters).
История источника для стрик-алерта `CONSECUTIVE_FAILURE_ALERT_THRESHOLD` неудач:
ровно веха лестницы `_streak_alert_due`, на которой дубль и уходил бы в Sentry.
"""
def __init__(self) -> None:
self.status = "running"
self.ban_kind: str | None = None
self.error: str | None = None
self.updates = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
p = params or {}
res = MagicMock()
res.fetchone.return_value = None # гейт _claim_run: «не running»
res.scalar.return_value = True # advisory-lock взят
if "INSERT INTO scrape_runs" in sql:
res.fetchone.return_value = types.SimpleNamespace(id=RUN_ID)
elif "SELECT status FROM scrape_runs WHERE id" in sql:
res.fetchone.return_value = types.SimpleNamespace(status=self.status)
elif "UPDATE scrape_runs" in sql and "SET status" in sql:
hit = self.status == "running"
if hit:
m = re.search(r"SET status = '(\w+)'", sql)
assert m is not None
self.status = m.group(1)
self.ban_kind = p.get("ban_kind")
self.error = p.get("error")
self.updates += 1
res.first.return_value = types.SimpleNamespace(id=RUN_ID) if hit else None
elif "SELECT source FROM scrape_runs" in sql:
res.fetchone.return_value = types.SimpleNamespace(source="avito_city_sweep")
elif "SELECT status, counters FROM scrape_runs" in sql:
res.fetchall.return_value = [
types.SimpleNamespace(status="failed", counters={})
] * CONSECUTIVE_FAILURE_ALERT_THRESHOLD
return res
def commit(self) -> None: ...
def rollback(self) -> None: ...
def close(self) -> None: ...
async def _dispatch_and_wait(job: Any, row: _Row) -> None:
ctx = SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=lambda: row,
)
sched = {
"id": 1,
"source": "avito_city_sweep",
"window_start_hour": 6,
"window_end_hour": 7,
"default_params": {},
}
run_id = await _dispatch(Handler(job, "avito_city_sweep"), row, sched, ctx) # type: ignore[arg-type]
assert run_id == RUN_ID
await asyncio.gather(*ctx._inflight_tasks, return_exceptions=True)
# ── планировщик ─────────────────────────────────────────────────────────────────
async def test_no_proxy_crash_before_try_marks_run_banned_infra() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
raise NoProxyAvailableError("avito")
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
assert row.error is not None and "no proxy available" in row.error
async def test_wrapped_no_proxy_is_still_infra() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
try:
raise NoProxyAvailableError("cian")
except NoProxyAvailableError as exc:
raise RuntimeError("sidecar fetch failed") from exc
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
async def test_other_crash_marks_run_failed() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
raise KeyError("anchors")
row = _Row()
await _dispatch_and_wait(_job, row)
assert row.status == "failed"
assert row.error is not None and "KeyError" in row.error
async def test_handler_own_final_status_survives() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
kit_runs.mark_done(db, run_id, {"done_buckets": ["center"]})
raise RuntimeError("after mark_done")
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.updates) == ("done", 1)
async def test_pipeline_failure_is_alerted_once_not_twice() -> None:
"""Обычный путь пайплайна: mark_failed и raise. Повторная финализация не шлёт дубль.
Стрик после первой финализации стоит на вехе лестницы; no-op mark_failed из
планировщика позвал бы `_alert_on_run_id` ещё раз с тем же стриком.
"""
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
kit_runs.mark_failed(db, run_id, "cian-sweep fatal", {})
raise RuntimeError("cian-sweep fatal")
row = _Row()
sentry = MagicMock()
with patch.object(kit_runs, "sentry_sdk", sentry):
await _dispatch_and_wait(_job, row)
assert (row.status, row.updates) == ("failed", 1)
assert sentry.capture_message.call_count == 1
# ── ручные запуски из админки (мимо _dispatch) ─────────────────────────────────
@pytest.mark.parametrize(
("path", "runner"),
[
("avito-city-sweep", "run_avito_city_sweep"),
("cian-city-sweep", "run_cian_city_sweep"),
("cian-full-load", "run_cian_full_load"),
("yandex-full-load", "run_yandex_full_load"),
("yandex-city-sweep", "run_yandex_city_sweep"),
],
)
def test_admin_manual_launch_crash_is_finalized(path: str, runner: str) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
row = _Row()
app.dependency_overrides[get_db] = lambda: row
with (
patch.object(kit_runs, "sentry_sdk", None),
patch.object(admin_module, "has_running_run", return_value=False),
patch.object(admin_module, "SessionLocal", lambda: row),
patch.object(admin_module, runner, AsyncMock(side_effect=NoProxyAvailableError("avito"))),
):
r = TestClient(app).post(f"/api/v1/admin/scrape/{path}", json={})
assert r.status_code == 200, r.text
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)

View file

@ -0,0 +1,77 @@
"""#1970: калибровочные константы движка, вынесенные из Settings, — значения.
Поля estimate_* жили в Settings и формально переопределялись через ENV. При
переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже
сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и
tradein-tgbot совпали, ENV-оверрайдов нет), а не скопированы из кода.
Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не
проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок),
а поведенческие тесты ловят не все пороги. Поменять число осознанно: здесь,
в estimator.py и с прогоном регрессионного гейта.
"""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import Settings
from app.services import estimator
# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026)
_PROD_VALUES: dict[str, tuple[str, float]] = {
# #2379, #764 квартальный индекс
"QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10),
"QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6),
"QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0),
"QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
# #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config)
"CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10),
"CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40),
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
# #2381, #651/#652 same-building anchor + IMV-blend
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
"FSD_K": ("estimate_fsd_k", 1.65),
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
}
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
_REMOVED_SWITCHES = (
"estimate_dedup_analogs_enabled", # #2378
"estimate_expected_sold_le_asking", # #2380
)
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
def test_constant_keeps_prod_value(const: str) -> None:
assert getattr(estimator, const) == _PROD_VALUES[const][1]
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
def test_settings_field_is_gone(const: str) -> None:
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
assert _PROD_VALUES[const][0] not in Settings.model_fields
@pytest.mark.parametrize("field", _REMOVED_SWITCHES)
def test_switch_is_gone(field: str) -> None:
assert field not in Settings.model_fields

View file

@ -0,0 +1,215 @@
"""#2189 — канон-ключ адреса не теряет дробь в номере дома («Сулимова, 3/2» ≠ «Сулимова, 32»).
Проверяется функция, которую поставили миграции (322), а не её Python-копия, и оба её
потребителя: матч ГАРhouses (gar_flats_loader) и схлопывание дублей (house_dedup_merge).
Без БД тесты пропускаются (записи в tests/skip_allowlist.txt), в CI идут на схеме из data/sql.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from sqlalchemy import text
from app.services import gar_flats_loader as gfl
from app.services import house_dedup_merge as hdm
def _live_session() -> Any | None:
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
needs_db = pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
@needs_db
def test_real_canon_keeps_slash_between_digits() -> None:
db = _live_session()
assert db is not None
def canon(s: str) -> str:
return db.execute(text("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
try:
# живые пары с прода (ревизия DaData 02.07): это разные дома
assert canon("Екатеринбург, ул. Сулимова, 3/2") == "сулимова3/2"
assert canon("Екатеринбург, ул. Сулимова, 32") == "сулимова32"
assert canon("улица Краснолесья, 10/7") == "краснолесья10/7"
assert canon("ул. Краснолесья,107") == "краснолесья107"
assert canon("улица Машиностроителей, 4/1") != canon("ул. Машиностроителей, 41")
# пробелы вокруг дроби и формат ГАР дают тот же ключ, что и слитная запись
assert canon("ул. Сулимова, 3 / 2") == canon(
"р-н Кировский, мкр. Пионерский, ул. Сулимова,3/2"
)
# «/» не между цифрами — как раньше: угловой адрес и литера перед дробью
assert canon(
"Екатеринбург, Втузгородок м-н, улица Гагарина, 27 / Первомайская улица, 81"
) == ("гагарина27первомайская81")
assert canon("ул. 8 Марта,204Г/1") == "арта204г1"
# остальная нормализация не задета
assert canon("ул. Шаумяна,20") == canon("ул. Шаумяна, 20") == "шаумяна20"
assert (
canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == "евгениясавкова17а"
)
finally:
db.rollback()
db.close()
@needs_db
def test_real_gar_match_picks_the_house_with_the_same_number() -> None:
"""Прод: «Сулимова 32» получал ГАР-дом «3/2» (414 квартир) — max(flat_count) в общем ключе."""
db = _live_session()
assert db is not None
try:
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('g2189-3-2', '66', 414, 'г. Екатеринбург, ул. Сулимова2189, 3/2', "
" 'ул. Сулимова2189, 3/2'),"
"('g2189-32', '66', 120, 'г. Екатеринбург, ул. Сулимова2189, 32', "
" 'ул. Сулимова2189, 32')"
)
)
# region_code обязателен: с #2583 H5 матчер даёт guid только ГАР-строке региона дома,
# дом без региона не матчится вовсе.
db.execute(
text(
"INSERT INTO houses (id, source, ext_house_id, url, address, region_code) VALUES "
"(900189, 'avito', 'EXT-2189-32', 'u', 'Екатеринбург, ул. Сулимова2189, 32', 66),"
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3 / 2', 66)"
)
)
gfl.match_houses_to_gar(db, region_code="66")
got = db.execute(
text(
"SELECT id, gar_house_guid, gar_flat_count, gar_match_method FROM houses "
"WHERE id IN (900189, 900190) ORDER BY id"
)
).all()
assert [tuple(r) for r in got] == [
(900189, "g2189-32", 120, "canon_addr"),
(900190, "g2189-3-2", 414, "canon_addr"),
]
finally:
db.rollback()
db.close()
@needs_db
def test_real_dedup_does_not_merge_slash_and_plain_number() -> None:
"""Дома «3/2» и «32» без ФИАС в одной точке — разные здания, в один кластер не попадают."""
db = _live_session()
assert db is not None
try:
db.execute(
text(
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
"(900189, 'avito', 'EXT-2189-32', 'u', 'ул. Сулимова2189, 32', 56.84, 60.6),"
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3/2', 56.84009, 60.6)"
)
)
db.commit()
hdm.merge_duplicate_houses(db, dry_run=False)
ids = {r.id for r in db.execute(text("SELECT id FROM houses WHERE id IN (900189, 900190)"))}
assert ids == {900189, 900190}
finally:
db.rollback()
for sql in (
"DELETE FROM house_sources WHERE house_id IN (900189, 900190)",
"DELETE FROM house_address_aliases WHERE house_id IN (900189, 900190)",
"DELETE FROM house_merge_log WHERE loser_id IN (900189, 900190)",
"DELETE FROM houses WHERE id IN (900189, 900190)",
):
db.execute(text(sql))
db.commit()
db.close()
_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "data"
/ "sql"
/ "322_canon_keep_slash_in_house_number.sql"
)
def test_migration_322_resets_only_matches_held_by_the_lost_slash() -> None:
"""Матчер пары не снимает: «4/1», пришитый к ГАР «41», без миграции так и остался бы с ним.
Прогон настоящего файла миграции; houses и gar_house_flats затенены временными таблицами
(pg_temp первым в search_path), реальные таблицы не трогаются.
"""
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
pytest.skip("нет доступной Postgres test-БД")
import psycopg
with psycopg.connect(
dsn.replace("postgresql+psycopg://", "postgresql://"), autocommit=True
) as c:
c.execute(
"CREATE TEMP TABLE gar_house_flats (house_guid text PRIMARY KEY, region_code text,"
" flat_count int, norm_address text)"
)
c.execute(
"CREATE TEMP TABLE houses (id int PRIMARY KEY, short_address text, full_address text,"
" address text, gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text)"
)
c.execute(
"INSERT INTO gar_house_flats VALUES "
"('g-3-2', '66', 414, 'ул. Сулимова, 3/2'),"
"('g-41', '66', 92, 'ул. Машиностроителей, 41'),"
"('g-58', '66', 249, 'ул. Абрикосовая, 58')"
)
c.execute(
"INSERT INTO houses (id, short_address, gar_house_guid, gar_flat_count, gar_matched_at,"
" gar_match_method) VALUES "
# держится только на потерянной дроби → сброс
"(1, 'ул. Сулимова, 32', 'g-3-2', 414, now(), 'canon_addr'),"
"(2, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'canon_addr'),"
# верная пара с дробью
"(3, 'Сулимова ул.,3/2', 'g-3-2', 414, now(), 'canon_addr'),"
# не канон-метод
"(4, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'dadata_fias_revision'),"
# ключ разошёлся не из-за дроби (адрес сменился после матча)
"(5, 'жилой комплекс Совушки, Абрикосовая ул.,58', 'g-58', 249, now(), 'canon_addr')"
)
c.execute(_MIGRATION.read_text(encoding="utf-8"))
rows = c.execute(
"SELECT id, gar_house_guid, gar_flat_count, gar_matched_at IS NOT NULL,"
" gar_match_method FROM houses ORDER BY id"
).fetchall()
assert rows == [
(1, None, None, False, None),
(2, None, None, False, None),
(3, "g-3-2", 414, True, "canon_addr"),
(4, "g-41", 92, True, "dadata_fias_revision"),
(5, "g-58", 249, True, "canon_addr"),
]

View file

@ -0,0 +1,259 @@
"""Крайние случаи city-свипов kit'а, не покрытые после удаления legacy-пайплайна (#2406).
Уже покрыто: avito таймаут якоря и дрейн после первого якоря (test_3319); дрейн ДО
первого якоря у yandex/cian/newbuilding (test_3333); отмена у domclick (test_3369).
Здесь остаток:
(а) таймаут якоря у cian и yandex; у domclick снятая/упавшая фаза не отмечает
корзины, строки которых не сохранены;
(б) SIGTERM-дрейн ПОСЛЕ первого якоря у cian и yandex;
(в) пользовательская отмена (runs.is_cancelled=True) после первого якоря у avito,
cian и yandex.
Проверяется строка прогона, а не вызовы: _FakeDb мержит counters всех записей по
порядку (как `counters || :counters` в runs.py) и запоминает финальный статус.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from scraper_kit.orchestration import pipeline as pl
ANCHOR_A = (56.83, 60.60, "ekb-center")
ANCHOR_B = (56.79, 60.63, "ekb-south")
class _FakeDb:
def __init__(self) -> None:
self.counters: dict[str, Any] = {}
self.status = "running"
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
if params and "counters" in params:
self.counters.update(json.loads(params["counters"]))
for st in ("done", "failed", "banned"):
if f"SET status = '{st}'" in sql and self.status == "running":
self.status = st
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
class _Scraper:
"""Двойник Cian/Yandex/AvitoScraper: помнит якоря, роняет заданный TimeoutError'ом."""
visited: list[float] = [] # noqa: RUF012
timeout_on: float | None = None
gate_fetch_attempts = state_extraction_attempts = 1
gate_fetch_failures = state_extraction_failures = 0
request_delay_sec = 0.0
def __init__(self, *_a: Any, **_kw: Any) -> None:
self._browser = None
self._cffi = None
async def __aenter__(self) -> _Scraper:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
def _visit(self, lat: float) -> None:
_Scraper.visited.append(lat)
if lat == _Scraper.timeout_on:
raise TimeoutError
async def fetch_around_multi_room(self, lat: float, *_a: Any, **kw: Any) -> list[Any]:
self._visit(lat)
if kw.get("on_combo") is not None: # yandex: единица чекпоинта — combo
kw["on_combo"](f"combo@{lat}", [])
return []
return [types.SimpleNamespace(listing_segment="vtorichka", house_ext_id=None)]
async def fetch_around(self, lat: float, *_a: Any, **_kw: Any) -> list[Any]:
self._visit(lat)
return []
def _config() -> types.SimpleNamespace:
return types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
avito_serp_ok_not_banned=True,
)
async def _sweep(
kind: str,
*,
timeout_on: float | None = None,
drain_after_first: bool = False,
cancel_after_first: bool = False,
) -> _FakeDb:
_Scraper.visited = []
_Scraper.timeout_on = timeout_on
db = _FakeDb()
common: dict[str, Any] = {
"run_id": 2406,
"config": _config(),
"matcher": MagicMock(),
"anchors": [ANCHOR_A, ANCHOR_B],
"request_delay_sec": 0.0,
"shutdown_requested": lambda: drain_after_first and bool(_Scraper.visited),
}
with (
patch.object(pl, "CianScraper", _Scraper),
patch.object(pl, "YandexRealtyScraper", _Scraper),
patch.object(pl, "AvitoScraper", _Scraper),
patch.object(pl, "AsyncSession", _Scraper),
patch.object(pl, "save_listings", lambda *_a, **_kw: (1, 0)),
patch.object(
pl.runs, "is_cancelled", lambda *_a: cancel_after_first and bool(_Scraper.visited)
),
):
if kind == "cian":
await pl.run_cian_city_sweep(
db, # type: ignore[arg-type]
enrich_houses=False,
detail_top_n=0,
**common,
)
elif kind == "yandex":
enrichment = MagicMock()
enrichment.record_yandex_price_history.return_value = 0
await pl.run_yandex_city_sweep(
db, # type: ignore[arg-type]
enrichment=enrichment,
enrich_address=False,
**common,
)
else:
await pl.run_avito_city_sweep(
db, # type: ignore[arg-type]
enrichment=MagicMock(),
enrich_houses=False,
enrich_imv=False,
detail_top_n=0,
**common,
)
return db
def _done_key(kind: str, anchor: tuple[float, float, str]) -> str:
return f"combo@{anchor[0]}" if kind == "yandex" else anchor[2]
# ── (а) таймаут якоря: якорь не засчитан, следующий обработан ──────────────────
@pytest.mark.parametrize("kind", ["cian", "yandex"])
async def test_anchor_timeout_skips_anchor_and_continues(kind: str) -> None:
db = await _sweep(kind, timeout_on=ANCHOR_A[0])
assert _Scraper.visited == [ANCHOR_A[0], ANCHOR_B[0]]
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_B)]
assert db.counters["errors_count"] >= 1
@pytest.mark.parametrize("broken", ["fetch_timeout", "save_failed"])
async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> None:
"""Корзина без сохранённых строк не попадает в чекпоинт.
С инкрементальным сохранением (fix/domclick-incremental-save) save_listings зовётся из
колбэка on_bucket ПОСЛЕ каждой корзины, а чекпоинт пишется там же и только
ПОСЛЕ успешного save. Поэтому корзина, чей save упал, в done_buckets не попадает,
хотя скрейпер её фетч прошёл (_s.completed_buckets её содержит). Снятая
watchdog'ом фаза — тот же инвариант: до on_bucket она не дошла.
Отметить такую корзину пройденной значило бы, что следующий прогон пропустит её
через skip_buckets навсегда (миграция 308).
"""
class _Dc(_Scraper):
blocked = False
geo_filtered = fetch_errors = bucket_start_index = 0
buckets_completed, buckets_total = 1, 6
completed_buckets = ["st"] # noqa: RUF012
async def fetch_city(self, **kw: Any) -> list[Any]:
if broken == "fetch_timeout":
raise TimeoutError
# Заглушка обязана ВЫЗВАТЬ колбэк: путь сохранения переехал внутрь цикла
# по корзинам (serp.py fetch_city), и стаб, который просто возвращает лоты,
# проверял бы мёртвую ветку — save_listings не был бы вызван вовсе.
on_bucket = kw.get("on_bucket")
lots = [object()]
if on_bucket is not None:
on_bucket("st", lots)
return lots
saved: list[int] = []
def _save(_db: Any, lots: list[Any], **_kw: Any) -> tuple[int, int]:
if broken == "save_failed":
raise RuntimeError("save_listings упал")
saved.append(len(lots))
return len(lots), 0
db = _FakeDb()
with (
patch.object(pl, "DomClickScraper", _Dc),
patch.object(pl, "save_listings", _save),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
counters = await pl.run_domclick_city_sweep(
db, # type: ignore[arg-type]
run_id=2406,
config=types.SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
pages=1,
request_delay_sec=0.0,
)
assert saved == [], "тест не о том: строки сохранились"
assert counters.errors_count >= 1
assert db.counters["done_buckets"] == [], (
f"корзины без единой сохранённой строки в чекпоинте: {db.counters['done_buckets']}"
)
assert db.status == "failed"
# ── (б) дрейн после первого якоря: interrupted=1, в чекпоинте ровно первый ──────
@pytest.mark.parametrize("kind", ["cian", "yandex"])
async def test_drain_after_first_anchor_is_partial_and_interrupted(kind: str) -> None:
db = await _sweep(kind, drain_after_first=True)
assert _Scraper.visited == [ANCHOR_A[0]]
assert db.counters["interrupted"] == 1
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
assert db.status == "done"
# ── (в) пользовательская отмена после первого якоря ────────────────────────────
@pytest.mark.parametrize("kind", ["avito", "cian", "yandex"])
async def test_user_cancel_after_first_anchor_stops_without_finalizing(kind: str) -> None:
db = await _sweep(kind, cancel_after_first=True)
assert _Scraper.visited == [ANCHOR_A[0]]
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
assert "interrupted" not in db.counters
# Строку финализирует сам mark_cancelled (UI); свип только останавливается.
assert db.status == "running"

View file

@ -186,6 +186,12 @@ class _FakeFetcher:
def report_ban(self, reason: str) -> None:
return None
def request_context_reset(self) -> None:
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
# не поведение свипа, а собственную неполноту.
return None
@pytest.fixture
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -103,7 +103,7 @@ async def test_403_bans_the_node_for_cian_only() -> None:
with pytest.raises(CianBlockedError):
await _fetch(403, spy)
assert spy.mark_banned_calls == [(1, "cian")]
assert spy.mark_health_calls == [(1, False)]
assert spy.mark_health_calls == [] # #3310: отказ площадки не копит consecutive_fails
assert spy.release_calls == [1] # lease не течёт даже на бане

View file

@ -179,7 +179,7 @@ async def test_price_history_403_bans_the_node_for_cian() -> None:
pool = _SpyPool()
result = await _run_price_history(pool, status_code=403)
assert pool.mark_banned_calls == [(9, "cian")]
assert pool.mark_health_calls == [(9, False)]
assert pool.mark_health_calls == [] # #3310: бан вместо mark_health(False)
assert result.errors == 1 # прогон честен: отказ посчитан
@ -335,7 +335,7 @@ async def test_zhk_resolve_403_reaches_the_pool() -> None:
with pytest.raises(CianBlockedError):
await _resolve(403, spy)
assert spy.mark_banned_calls == [(9, "cian")]
assert spy.mark_health_calls == [(9, False)]
assert spy.mark_health_calls == [] # #3310: бан вместо mark_health(False)
assert spy.release_calls == [9]

View file

@ -0,0 +1,151 @@
"""#2898: окно срока экспозиции (p25p75) вместо одного числа и его персист.
Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не
сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых
объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET.
Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция
(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией.
"""
from __future__ import annotations
import contextlib
import os
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
EXPOSURE_WINDOW_MIN_N,
_exposure_window,
_fetch_exposure_days,
)
# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское
# округление дало бы 16); p75 на 21.75 → 23.75 → 24.
_DAYS_30 = list(range(2, 32))
def test_quartiles_match_percentile_cont() -> None:
w = _exposure_window(_DAYS_30)
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30)
def test_order_of_input_does_not_matter() -> None:
w = _exposure_window(list(reversed(_DAYS_30)))
assert w is not None
assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24)
def test_skewed_sample_window() -> None:
"""Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост."""
days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400]
w = _exposure_window(days)
assert w is not None
# позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62
assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62)
def test_below_threshold_no_window() -> None:
assert EXPOSURE_WINDOW_MIN_N == 30
assert _exposure_window(_DAYS_30[:-1]) is None
assert _exposure_window([]) is None
def test_fetch_failure_is_graceful() -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
def test_fetch_without_coords_does_not_query() -> None:
db = MagicMock()
assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
assert not db.execute.called
# ── POST: окно уходит и в ответ, и в INSERT ───────────────────────────────────
def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]:
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
db = MagicMock()
payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
geo = GeocodeResult(
lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim"
)
fetch = MagicMock(return_value=exposure_days)
patches = (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None)
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch("app.services.estimator._fetch_exposure_days", new=fetch),
)
async def _run() -> Any:
with contextlib.ExitStack() as stack:
for cm in patches:
stack.enter_context(cm)
return await estimate_quality(payload, db)
result = anyio.run(_run)
# Выборка берётся по координатам и параметрам оцениваемой квартиры.
kwargs = fetch.call_args.kwargs
assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == (
56.838,
60.595,
1,
40.0,
)
for call in db.execute.call_args_list:
sql = str(getattr(call.args[0], "text", call.args[0]))
if "INSERT INTO trade_in_estimates" in sql:
return result, call.args[1]
raise AssertionError("INSERT INTO trade_in_estimates не вызван")
def test_post_persists_window_and_returns_it() -> None:
result, params = _run_estimate(_DAYS_30)
assert (
params["est_days_p25"],
params["est_days_p50"],
params["est_days_p75"],
params["est_days_n"],
) == (9, 17, 24, 30)
assert result.exposure_window is not None
assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24)
# Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений.
assert result.est_days_on_market == 17
def test_post_small_sample_persists_nulls() -> None:
result, params = _run_estimate(_DAYS_30[:-1])
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
None,
None,
None,
None,
]
assert result.exposure_window is None
assert result.est_days_on_market is None

View file

@ -0,0 +1,246 @@
"""#2993: listing_source_snapshots получает строку только при изменении источника.
Проверки идут по ЗНАЧЕНИЯМ в живом Postgres: какие источники получили строку за
сегодня, с какими значениями, и какие события записал event-diff поверх них.
Без реальной БД файл self-skip'ается, как соседние live-тесты (в CI есть
postgres, см. ci-tradein.yml). Вся синтетика живёт в одной транзакции, очистка
rollback.
"""
from __future__ import annotations
import os
import uuid
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import text
from app.tasks import listing_source_snapshot as snap_mod
def _live_session() -> Any | None:
"""Тот же контракт, что у test_revisit_floor_lateral_lookup.py."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
pytestmark = pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
_PRICE = 5_000_000
def _source(db: Any, tag: str, *, price: int, payload: str, seen_days_ago: int) -> int:
ext_id = f"zzz2993-{tag}-{uuid.uuid4().hex[:8]}"
listing_id = db.execute(
text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, price_rub,
is_active, scraped_at, last_seen_at)
VALUES ('cian', :url, :ext_id, :ext_id, :price, true, now(), now())
RETURNING id
"""
),
{"url": f"https://example.test/2993/{ext_id}", "ext_id": ext_id, "price": price},
).scalar_one()
return int(
db.execute(
text(
"""
INSERT INTO listing_sources
(listing_id, ext_source, ext_id, confidence, matched_method,
price_rub, raw_payload, last_seen_at)
VALUES (:listing_id, 'cian', :ext_id, 1.0, 'test',
:price, CAST(:payload AS jsonb),
now() - make_interval(days => :seen))
RETURNING id
"""
),
{
"listing_id": listing_id,
"ext_id": ext_id,
"price": price,
"payload": payload,
"seen": seen_days_ago,
},
).scalar_one()
)
def _snapshot(
db: Any,
lsid: int,
*,
days_ago: int,
price: int,
payload: str,
seen_days_ago: int,
is_active: bool = True,
) -> None:
# now() постоянен внутри транзакции — last_seen_at снимка совпадает с
# listing_sources.last_seen_at до микросекунды, если seen_days_ago тот же.
db.execute(
text(
"""
INSERT INTO listing_source_snapshots
(listing_source_id, snapshot_date, price_rub, is_active,
last_seen_at, payload_hash)
VALUES (:lsid, CURRENT_DATE - :days_ago, :price, :is_active,
now() - make_interval(days => :seen),
md5(CAST(:payload AS jsonb)::text))
"""
),
{
"lsid": lsid,
"days_ago": days_ago,
"price": price,
"is_active": is_active,
"seen": seen_days_ago,
"payload": payload,
},
)
def _run_writer(db: Any) -> None:
db.execute(
snap_mod._SNAPSHOT_SQL,
{"freshness_days": snap_mod.FRESHNESS_WINDOW_DAYS, "run_id": None},
)
def _today(db: Any, ids: list[int]) -> dict[int, tuple[int, bool]]:
rows = db.execute(
text(
"""
SELECT listing_source_id, price_rub, is_active
FROM listing_source_snapshots
WHERE snapshot_date = CURRENT_DATE AND listing_source_id = ANY(:ids)
"""
),
{"ids": ids},
).fetchall()
return {int(r[0]): (int(r[1]), bool(r[2])) for r in rows}
def _seed(db: Any) -> dict[str, int]:
"""Семь источников: по одному на каждую ветку условия записи."""
p = '{"v": 1}'
ids = {
"same": _source(db, "same", price=_PRICE, payload=p, seen_days_ago=1),
"same_gap": _source(db, "same_gap", price=_PRICE, payload=p, seen_days_ago=1),
"price": _source(db, "price", price=_PRICE, payload=p, seen_days_ago=1),
"payload": _source(db, "payload", price=_PRICE, payload=p, seen_days_ago=1),
"seen": _source(db, "seen", price=_PRICE, payload=p, seen_days_ago=1),
"active": _source(db, "active", price=_PRICE, payload=p, seen_days_ago=10),
"new": _source(db, "new", price=_PRICE, payload=p, seen_days_ago=1),
}
_snapshot(db, ids["same"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=1)
# Последний снимок пятидневной давности совпадает, а более старый — нет:
# сравнивать обязаны с ПОСЛЕДНИМ, а не с любым из прошлых.
_snapshot(db, ids["same_gap"], days_ago=9, price=_PRICE - 1, payload=p, seen_days_ago=9)
_snapshot(db, ids["same_gap"], days_ago=5, price=_PRICE, payload=p, seen_days_ago=1)
_snapshot(db, ids["price"], days_ago=1, price=4_900_000, payload=p, seen_days_ago=1)
_snapshot(db, ids["payload"], days_ago=1, price=_PRICE, payload='{"v": 0}', seen_days_ago=1)
_snapshot(db, ids["seen"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=3)
# last_seen_at тот же, но 10 суток > окна свежести: вчера снимок был активным.
_snapshot(db, ids["active"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=10)
return ids
def test_only_changed_or_new_sources_get_a_row_today() -> None:
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
today = _today(db, list(ids.values()))
by_tag = {tag: today.get(lsid) for tag, lsid in ids.items()}
assert by_tag == {
"same": None,
"same_gap": None,
"price": (_PRICE, True),
"payload": (_PRICE, True),
"seen": (_PRICE, True),
"active": (_PRICE, False),
"new": (_PRICE, True),
}
finally:
db.rollback()
db.close()
def test_events_survive_change_only_writes() -> None:
"""event-diff видит только записанные сегодня строки — и не теряет ни одного события."""
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
db.execute(snap_mod._EVENT_DIFF_SQL).fetchall()
rows = db.execute(
text(
"""
SELECT listing_source_id, event_type, diff_percent
FROM listing_source_events
WHERE listing_source_id = ANY(:ids)
"""
),
{"ids": list(ids.values())},
).fetchall()
tag_of = {lsid: tag for tag, lsid in ids.items()}
events = {(tag_of[int(r[0])], r[1], None if r[2] is None else float(r[2])) for r in rows}
assert events == {
("price", "price_change", 2.0408), # round((5.0-4.9)/4.9*100, 4)
("payload", "edited", None),
("new", "first_seen", None),
}
finally:
db.rollback()
db.close()
def test_rerun_same_day_compares_with_todays_row() -> None:
"""Второй прогон в те же сутки сравнивает с уже записанной сегодня строкой.
price вернулся к вчерашней цене сегодняшняя строка обязана перезаписаться
(иначе на сегодня осталась бы цена, которой у источника больше нет); same
изменился после первого прогона строка появляется; seen не менялся после
первого прогона строка остаётся как была.
"""
db = _live_session()
try:
ids = _seed(db)
_run_writer(db)
db.execute(
text("UPDATE listing_sources SET price_rub = 4900000 WHERE id = :id"),
{"id": ids["price"]},
)
db.execute(
text("UPDATE listing_sources SET price_rub = 5100000 WHERE id = :id"),
{"id": ids["same"]},
)
_run_writer(db)
today = _today(db, list(ids.values()))
assert today.get(ids["price"]) == (4_900_000, True)
assert today.get(ids["same"]) == (5_100_000, True)
assert today.get(ids["seen"]) == (_PRICE, True)
assert ids["same_gap"] not in today
finally:
db.rollback()
db.close()

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