Compare commits

..

57 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
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
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
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
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
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
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
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
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
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
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
81 changed files with 6011 additions and 755 deletions

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

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

@ -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

@ -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.
#

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

@ -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

@ -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

@ -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

@ -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

@ -161,6 +161,12 @@ tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project
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,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

@ -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

@ -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: |
@ -442,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

@ -170,3 +170,85 @@ tests:
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

@ -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}
@ -843,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,

View file

@ -230,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
@ -292,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) →

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,
@ -63,6 +64,7 @@ from app.schemas.trade_in import (
AvitoImvSummary,
CianValuationSummary,
DkpCorridor,
ExposureWindow,
PriceTrendPoint,
TradeInEstimateInput,
)
@ -2115,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"
)
@ -2131,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).
@ -5502,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 = (
@ -5531,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
@ -5554,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,
@ -5614,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),
@ -5784,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,
@ -5976,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:

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

@ -544,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

@ -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,
cur.price_rub,
cur.is_active,
cur.last_seen_at,
cur.payload_hash,
now(),
CAST(:run_id AS bigint)
FROM (
SELECT
id,
price_rub,
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
last_seen_at,
md5(raw_payload::text),
now(),
CAST(:run_id AS bigint)
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
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
# всем). Повторный недогруз карточки, уже недогружавшейся
# раньше, — сведения о ней, а не о площадке: он брейкер не
# двигает, иначе вечные карточки, стоящие в очереди подряд,
# рвали бы прогон при каждом повторе (#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,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,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,
}
@ -2550,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,
@ -2616,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:

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

@ -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
@ -180,3 +187,15 @@ tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_call
# снятия подписи батча, сужения окна до ×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

@ -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

@ -173,10 +173,14 @@ async def test_anchor_timeout_skips_anchor_and_continues(kind: str) -> None:
async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> None:
"""Корзина без сохранённых строк не попадает в чекпоинт.
Лоты Домклика копятся в памяти и пишутся ОДНИМ save_listings после всех корзин.
Фаза, снятая watchdog'ом (или упавшая на save), не сохранила ничего — даже из
корзины 'st', которую скрейпер успел пройти. Отметить её пройденной значило бы,
что следующий прогон пропустит её через skip_buckets навсегда (миграция 308).
С инкрементальным сохранением (fix/domclick-incremental-save) save_listings зовётся из
колбэка on_bucket ПОСЛЕ каждой корзины, а чекпоинт пишется там же и только
ПОСЛЕ успешного save. Поэтому корзина, чей save упал, в done_buckets не попадает,
хотя скрейпер её фетч прошёл (_s.completed_buckets её содержит). Снятая
watchdog'ом фаза — тот же инвариант: до on_bucket она не дошла.
Отметить такую корзину пройденной значило бы, что следующий прогон пропустит её
через skip_buckets навсегда (миграция 308).
"""
class _Dc(_Scraper):
@ -185,10 +189,17 @@ async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> Non
buckets_completed, buckets_total = 1, 6
completed_buckets = ["st"] # noqa: RUF012
async def fetch_city(self, **_kw: Any) -> list[Any]:
async def fetch_city(self, **kw: Any) -> list[Any]:
if broken == "fetch_timeout":
raise TimeoutError
return [object()]
# Заглушка обязана ВЫЗВАТЬ колбэк: путь сохранения переехал внутрь цикла
# по корзинам (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] = []

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

@ -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()

View file

@ -0,0 +1,132 @@
"""Свип ДомКлика ходит в тёплый переиспользуемый браузер-контекст сайдкара (#3118).
Замер (см. `backend/app/tasks/domclick_detail_backfill.py:394-401`): 26 подряд
холодных фетчей = 100% QRATOR-блок, те же карточки в тёплом контексте 5/5
примерно по 2с. Причина `browser.new_page()` создаёт НОВЫЙ изолированный
context сайдкара на каждый `/fetch`, из-за чего живой `qrator_jsid2` (куки,
которые сайт ротирует через Set-Cookie) никогда не доживает до следующего
запроса, а якорная вкладка не выживает между фетчами (`goto(origin)` валился
таймаутом 60с, убивая всю корзину).
Три проверки:
1. `build_browser_fetcher(..., reuse_context=True)` включает флаг на фетчере,
дефолт (без параметра) выключен (не ломаем прочие call-site'ы).
2. `DomClickScraper.fetch_city` строит фетчер именно с `reuse_context=True`.
3. На QRATOR-блоке `fetcher.request_context_reset()` вызывается ДО
`fetcher.report_ban()` иначе сожжённый блоком тёплый контекст травит
остаток прогона тем же `qrator_jsid2`.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import types
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _fetcher_config() -> types.SimpleNamespace:
return types.SimpleNamespace(
browser_http_endpoint="http://sidecar:8080",
use_proxy_pool_browser=False,
environment="test",
)
def test_build_browser_fetcher_reuse_context_default_is_off() -> None:
"""Без явного параметра поведение прежнее — прочие call-site'ы не меняются."""
from scraper_kit.providers._base import build_browser_fetcher
fetcher = build_browser_fetcher(_fetcher_config(), "domclick")
assert fetcher._reuse_context is False
def test_build_browser_fetcher_reuse_context_true_sets_flag() -> None:
from scraper_kit.providers._base import build_browser_fetcher
fetcher = build_browser_fetcher(_fetcher_config(), "domclick", reuse_context=True)
assert fetcher._reuse_context is True
class _FakeFetcher:
"""Двойник BrowserFetcher: фиксирует порядок reset/ban."""
def __init__(self) -> None:
self.calls: list[Any] = []
def request_context_reset(self) -> None:
self.calls.append("reset")
def report_ban(self, reason: str) -> None:
self.calls.append(("ban", reason))
class _FakeFetcherCM:
def __init__(self, fetcher: _FakeFetcher) -> None:
self._fetcher = fetcher
async def __aenter__(self) -> _FakeFetcher:
return self._fetcher
async def __aexit__(self, *_exc: Any) -> None:
return None
@pytest.mark.asyncio
async def test_domclick_sweep_builds_fetcher_with_reuse_context() -> None:
"""`fetch_city` зовёт фабрику фетчера с `reuse_context=True` (не дефолтом)."""
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.providers.domclick.serp import DomClickScraper
fetcher = _FakeFetcher()
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
scraper = DomClickScraper(_fetcher_config())
# Обрываем на первом же бакете — детали сбора корзины здесь не проверяются.
with (
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
patch.object(
scraper, "_sweep_bucket", AsyncMock(side_effect=DomClickBlockedError("qrator"))
),
):
await scraper.fetch_city(city_id=66, pages=1)
assert mock_build.call_args.kwargs.get("reuse_context") is True, (
f"свип построил фетчер без reuse_context=True: {mock_build.call_args}"
)
@pytest.mark.asyncio
async def test_domclick_sweep_resets_context_after_failed_bucket() -> None:
"""Упавшая корзина сбрасывает тёплый контекст, чтобы он не переполз в следующую.
Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд легли за две
минуты каждая на таймауте goto(origin) якорная вкладка не поднималась в том же
сгоревшем контексте. Без сброса reuse_context=True превращает одну неудачу в
цепочку.
"""
from scraper_kit.providers.domclick.serp import DomClickScraper
fetcher = _FakeFetcher()
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
scraper = DomClickScraper(_fetcher_config())
with (
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
patch.object(scraper, "_sweep_bucket", AsyncMock(side_effect=RuntimeError("boom"))),
):
await scraper.fetch_city(city_id=66, pages=1)
assert fetcher.calls.count("reset") == 6, (
f"сброс контекста ожидался на каждой из 6 упавших корзин: {fetcher.calls!r}"
)
assert not any(c != "reset" for c in fetcher.calls), (
f"report_ban не должен вызываться на не-QRATOR ошибке: {fetcher.calls!r}"
)

View file

@ -0,0 +1,230 @@
"""Вечно недогружаемые карточки Яндекса не держат очередь добора (#3191).
Прод 13.09 21:14 16.09 18:52: пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Пять недогрузов подряд выбивали брейкер
consecutive_none=5: 16 прогонов закончились ровно attempted=5 incomplete=5, а
«успешные» обрывались, едва дойдя до них. За ними 20 тыс. необогащённых объявлений.
Проверки ПО ЗНАЧЕНИЮ:
* мок-часть какие карточки запрошены и какие счётчики вышли;
* живой Postgres (в CI есть, локально skip) что НАСТОЯЩИЙ снапшот-SELECT после
недогруза не берёт карточку сутки, берёт её после паузы и выбрасывает после трёх.
"""
from __future__ import annotations
import os
import sys
import uuid
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from sqlalchemy import text # noqa: E402
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill # noqa: E402
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# Оболочка как на проде: 381 КБ, контактов нет. Полная — 3,9 МБ с маркером.
SHELL_HTML = "<html>" + "x" * 381_157 + "</html>"
FULL_HTML = "<html>" + "x" * 3_920_119 + '"encryptedPhones":["a"]</html>'
PERPETUAL = 5
NORMAL = 3
def _resp(html: str) -> MagicMock:
resp = MagicMock()
resp.status_code = 200
resp.text = html
return resp
async def _run(db: Any, perpetual_urls: set[str]) -> tuple[Any, list[str]]:
"""Один прогон; оболочку отдаём по URL, всё остальное — полная карточка."""
requested: list[str] = []
async def get(url: str, **_kw: Any) -> MagicMock:
requested.append(url)
return _resp(SHELL_HTML if url in perpetual_urls else FULL_HTML)
session = AsyncMock()
session.get = AsyncMock(side_effect=get)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
with (
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
patch(_PARSE, return_value=MagicMock()),
patch(_SAVE, MagicMock(return_value=True)),
patch(_RUNS, MagicMock()),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
counters = await run_yandex_detail_backfill(
db, run_id=3191, params={"batch_size": PERPETUAL + NORMAL, "budget_sec": 3600}
)
return counters, requested
# ── 1. Брейкер: повтор известной карточки не рвёт прогон, первый недогруз — рвёт ──
def _mock_db(prior_incomplete: int) -> tuple[MagicMock, set[str], list[str]]:
rows = [
{
"id": i + 1,
"source_url": f"https://realty.yandex.ru/offer/{i + 1}/",
"detail_incomplete_count": prior_incomplete if i < PERPETUAL else 0,
}
for i in range(PERPETUAL + NORMAL)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = rows
sel.one.return_value = MagicMock(url_from_offer_id=0, unenrichable_pending=0)
sel.scalar_one.return_value = 0
db.execute.return_value = sel
urls = [r["source_url"] for r in rows]
return db, set(urls[:PERPETUAL]), urls[PERPETUAL:]
@pytest.mark.asyncio
async def test_retry_of_known_underloaded_cards_does_not_abort_run() -> None:
"""Пять карточек, уже недогружавшихся раньше, в голове снапшота — прогон идёт дальше."""
db, perpetual, normal = _mock_db(prior_incomplete=1)
counters, requested = await _run(db, perpetual)
assert set(normal) <= set(requested), (
f"запрошено {len(requested)} из {PERPETUAL + NORMAL}: повторный недогруз известных "
"карточек оборвал прогон раньше, чем очередь дошла до следующих"
)
assert (counters.attempted, counters.enriched, counters.incomplete) == (8, 3, 5)
@pytest.mark.asyncio
async def test_first_underload_series_still_aborts_run() -> None:
"""Защита от системного недогруза жива: пять ПЕРВЫХ недогрузов подряд — обрыв."""
db, perpetual, normal = _mock_db(prior_incomplete=0)
counters, requested = await _run(db, perpetual)
assert not set(normal) & set(requested), (
"пять первых недогрузов подряд не оборвали прогон — брейкер системного "
"недогруза больше не срабатывает"
)
assert (counters.attempted, counters.enriched, counters.incomplete) == (5, 0, 5)
# ── 2. Очередь на живом Postgres ─────────────────────────────────────────────
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)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
@pytest.mark.asyncio
async def test_perpetual_underloaded_cards_leave_queue_head() -> None:
db = _live_session()
tag = f"t3191-{uuid.uuid4().hex[:8]}"
try:
ids: list[int] = []
urls: list[str] = []
for i in range(PERPETUAL + NORMAL):
url = f"https://realty.yandex.ru/offer/3191{uuid.uuid4().int % 10**12}/"
# Вечные — самые свежие (голова очереди), как 12.09 15:02 на проде.
# Дата в будущем: чужие необогащённые строки тестовой БД не встанут впереди.
row_id = db.execute(
text(
"""
INSERT INTO listings (source, source_url, source_id, dedup_hash,
price_rub, is_active, scraped_at)
VALUES ('yandex', :url, :sid, :sid, 5000000, true,
TIMESTAMPTZ '2100-01-01' - make_interval(mins => :i))
RETURNING id
"""
),
{"url": url, "sid": f"{tag}-{i}", "i": i},
).scalar_one()
ids.append(row_id)
urls.append(url)
db.commit()
perpetual = set(urls[:PERPETUAL])
normal = urls[PERPETUAL:]
# Прогон 1: карточки ещё не недогружались — первый недогруз, обрыв на пятой.
_c1, req1 = await _run(db, perpetual)
assert set(req1) == perpetual, req1
# Прогон 2 сразу следом: голова очереди больше не держит — берутся следующие.
c2, req2 = await _run(db, perpetual)
assert not set(req2) & perpetual, (
"недогруженные час назад карточки снова в снапшоте — прогон опять "
"упрётся в них, как 16 прогонов 13.0916.09"
)
assert set(normal) <= set(req2)
assert c2.enriched == NORMAL
# Сутки спустя: повтор (разовый недогруз мог пройти) и прогон не обрывается.
def age(hours: int) -> None:
db.execute(
text(
"UPDATE listings SET detail_incomplete_at = now() - make_interval(hours => :h) "
"WHERE id = ANY(:ids)"
),
{"h": hours, "ids": ids[:PERPETUAL]},
)
db.commit()
age(25)
c3, req3 = await _run(db, perpetual)
assert perpetual <= set(req3), "после суток паузы карточки обязаны вернуться в очередь"
assert set(normal) <= set(req3), "повтор известных карточек оборвал прогон"
assert c3.incomplete == PERPETUAL
age(25)
await _run(db, perpetual) # третья попытка
age(25)
c5, req5 = await _run(db, perpetual)
assert not set(req5) & perpetual, "после трёх недогрузов карточка всё ещё в очереди"
assert c5.incomplete_given_up >= PERPETUAL, (
f"incomplete_given_up={c5.incomplete_given_up}: выбывшие из очереди не посчитаны"
)
counts = db.execute(
text("SELECT detail_incomplete_count FROM listings WHERE id = ANY(:ids) ORDER BY id"),
{"ids": ids},
).scalars()
assert list(counts) == [3] * PERPETUAL + [0] * NORMAL
finally:
db.rollback()
db.execute(text("DELETE FROM listings WHERE source_id LIKE :p"), {"p": f"{tag}-%"})
db.commit()
db.close()

View file

@ -153,7 +153,15 @@ class _CleanSweepScraper:
async def __aexit__(self, *_e: Any) -> None:
return None
async def fetch_city(self, **_kw: Any) -> list[Any]:
async def fetch_city(self, **kw: Any) -> list[Any]:
# Чекпоинт с fix/domclick-incremental-save набирается ИЗ колбэка on_bucket, а не
# мержится в конце из completed_buckets — стаб обязан его вызвать, иначе
# проверялась бы мёртвая ветка. Лотов нет: корзина пройдена, но пустая —
# это законный случай, save_listings для неё не зовётся, чекпоинт пишется.
on_bucket = kw.get("on_bucket")
if on_bucket is not None:
for bucket in self.completed_buckets:
on_bucket(bucket, [])
return []

View file

@ -0,0 +1,24 @@
"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox.
Без `init: true` PID 1 контейнера `python server.py`; дочерние процессы закрытого
браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч).
Проверка по значению: разобранный compose, сервис с образом tradein-browser.
"""
from pathlib import Path
import yaml
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
def test_browser_service_runs_under_init() -> None:
services = yaml.safe_load(_COMPOSE.read_text())["services"]
browser = {
name: svc
for name, svc in services.items()
if "gendesign-tradein-browser" in svc.get("image", "")
}
# Контроль разбора: пустой словарь дал бы зелёный тест ни о чём.
assert set(browser) == {"browser"}, sorted(browser)
assert browser["browser"].get("init") is True

View file

@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None:
def test_house_id_resolution_as_json_shape() -> None:
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2}
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3)
assert res.as_json() == {
"resolved": 4,
"total": 10,
"imv_reachable": 2,
"house_type_from_houses": 3,
}
def test_house_id_resolution_defaults_zero() -> None:
res = bt._HouseIdResolution()
assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0}
assert res.as_json() == {
"resolved": 0,
"total": 0,
"imv_reachable": 0,
"house_type_from_houses": 0,
}
# --------------------------------------------------------------------------- #
@ -1006,3 +1016,108 @@ def test_predict_full_spine_passes_resolved_city_to_corridor(
assert captured.get("called") is True
assert captured.get("city") == "нижний тагил"
assert pred is not None
# --------------------------------------------------------------------------- #
# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality.
# --------------------------------------------------------------------------- #
def _run_full_with_house_facts(
monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool
) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]:
from types import SimpleNamespace
from app.services import estimator as est_mod
def _deal(deal_id: int, house_type: str | None) -> object:
return bt.DealSample(
id=deal_id,
lon=60.6,
lat=56.8,
rooms=2,
sold_ppm2=100_000.0,
deal_date=None,
area_m2=50.0,
address="Екатеринбург, ул. Ленина, 5",
floor=3,
year_built=None,
house_type=house_type,
)
# Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть.
monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")])
monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77)
lookups: list[dict[str, object]] = []
def _fake_lookup(_db: object, **kw: object) -> object:
lookups.append(kw)
return est_mod._HouseFacts(
house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None
)
seen: dict[int, set[object]] = {}
current: dict[str, int] = {}
def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]:
seen.setdefault(current["id"], set()).add(kw["house_type"])
return [], False, "W"
def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace:
return SimpleNamespace(
median_price=5_000_000.0,
median_ppm2=100_000.0,
confidence="low",
anchor_tier=None,
expected_sold_per_m2=95_000.0,
expected_sold_price=4_750_000.0,
expected_sold_range_low=4_000_000.0,
expected_sold_range_high=5_500_000.0,
)
real_predict = bt._predict_full_spine
def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object:
current["id"] = deal.id # type: ignore[attr-defined]
return real_predict(db, deal, est, **kw) # type: ignore[arg-type]
monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup)
monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs)
monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None)
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict)
metrics = bt.run_backtest_full(
None, # type: ignore[arg-type]
sample=2,
since="2025-06-01",
resolve_house_id=resolve_house_id,
)
return seen, lookups, metrics
def test_full_backtest_takes_house_type_from_houses_like_prod(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True)
# Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов.
assert seen[1] == {"panel"}
# Свой тип сделки главнее справочника, как форма главнее houses на проде.
assert seen[2] == {"brick"}
assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}]
assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index]
def test_full_backtest_without_flag_keeps_house_type_untouched(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False)
assert seen[1] == {None}
assert lookups == []
assert "house_id_resolution" not in metrics

View file

@ -0,0 +1,234 @@
"""fix/domclick-incremental-save: большая выдача (Москва ≈23 690 лотов против ЕКБ
6 300) теряла ВСЁ собранное при снятии свипа по watchdog сохранение было ОДНО,
в самом конце `run_domclick_city_sweep`. Прод run 7344 (`domclick_city_sweep_moskva`):
за 2ч watchdog'а (11 100с) пройдено 2 бакета из 6.
Три дефекта, три слоя тестов ниже:
1. Инкрементальное сохранение по бакетам (`fetch_city.on_bucket`) секция 1.
2. Чекпоинт врал ("пройдено" без "сохранено") секция 1, тест
`test_on_bucket_exception_interrupts_bucket_loop` документирует нюанс, на
котором строится фикс в pipeline.py: scraper.completed_buckets отмечает бакет
как "фетч прошёл" ДО вызова on_bucket, поэтому пайплайн больше не берёт
чекпоинт оттуда только из факта успешного on_bucket (см. комментарий у
"Перенести счётчики" в run_domclick_city_sweep).
3. Watchdog не знал о размере выдачи секция 2, `watchdog_sec` override.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import (
ROOM_BUCKETS,
DomClickBlockedError,
DomClickScraper,
)
PFX = "scraper_kit.orchestration.pipeline"
# ── Секция 1: DomClickScraper.fetch_city(on_bucket=...) ──────────────────────
def _scraper() -> DomClickScraper:
return DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
class _FakeFetcherCtx:
async def __aenter__(self) -> SimpleNamespace:
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
return SimpleNamespace(
report_ban=lambda *_a, **_k: None,
request_context_reset=lambda *_a, **_k: None,
)
async def __aexit__(self, *_exc: Any) -> None:
return None
async def _run_fetch_city(
scraper: DomClickScraper,
*,
sweep_bucket: Any,
on_bucket: Any = None,
start: int = 0,
skip: set[str] | None = None,
) -> list[str]:
with (
patch.object(scraper, "_sweep_bucket", sweep_bucket),
patch(
"scraper_kit.providers._base.build_browser_fetcher",
lambda *_a, **_k: _FakeFetcherCtx(),
),
):
return await scraper.fetch_city(
city_id=1,
pages=1,
start_bucket_index=start,
skip_buckets=skip,
on_bucket=on_bucket,
)
async def test_on_bucket_called_once_per_bucket_with_only_its_own_lots() -> None:
"""on_bucket зовётся по разу на каждый успешный бакет с лотами ИМЕННО его,
а не накопленным out_lots (главная регрессия #1 issue — было "одно сохранение
в конце")."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-a")
out_lots.append(f"{rooms}-b")
calls: list[tuple[str, list[str]]] = []
s = _scraper()
result = await _run_fetch_city(
s, sweep_bucket=_fake_sweep, on_bucket=lambda n, lots: calls.append((n, list(lots)))
)
assert [c[0] for c in calls] == list(ROOM_BUCKETS)
for bucket, lots in calls:
assert lots == [f"{bucket}-a", f"{bucket}-b"], (bucket, lots, "получил чужие лоты")
assert len(result) == len(ROOM_BUCKETS) * 2
async def test_on_bucket_skips_failed_and_blocked_buckets() -> None:
"""Битый бакет (generic Exception) и бакет с QRATOR-блоком не отдают лоты в
on_bucket там ничего не собрано/не гарантированно собрано."""
async def _fake_sweep_fail_middle(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
if rooms == "1":
raise ValueError("boom")
calls_a: list[str] = []
s_a = _scraper()
await _run_fetch_city(
s_a, sweep_bucket=_fake_sweep_fail_middle, on_bucket=lambda n, _lots: calls_a.append(n)
)
assert "1" not in calls_a
# continue идёт дальше — остальные бакеты всё равно получают on_bucket.
assert calls_a == [b for b in ROOM_BUCKETS if b != "1"], calls_a
async def _fake_sweep_block(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
if rooms == "1":
raise DomClickBlockedError("QRATOR")
calls_b: list[str] = []
s_b = _scraper()
await _run_fetch_city(
s_b, sweep_bucket=_fake_sweep_block, on_bucket=lambda n, _lots: calls_b.append(n)
)
# break останавливает обход целиком — после блока ни один бакет не пробуется.
assert calls_b == ["st"], calls_b
async def test_on_bucket_exception_interrupts_bucket_loop() -> None:
"""Исключение из on_bucket (канал кооперативной отмены) прерывает цикл по
ROOM_BUCKETS целиком остальные бакеты не идут."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-x")
visited: list[str] = []
def _on_bucket(name: str, lots: list[str]) -> None:
visited.append(name)
if name == "1":
raise RuntimeError("cancelled")
s = _scraper()
with pytest.raises(RuntimeError, match="cancelled"):
await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=_on_bucket)
assert visited == ["st", "1"], visited
# Скрейпер-уровень успел зафетчить оба бакета ДО того, как on_bucket поднял
# исключение — на этом нюансе строится фикс чекпоинта в pipeline.py: pipeline
# больше не берёт done_buckets из scraper.completed_buckets, только из факта
# успешного on_bucket (см. run_domclick_city_sweep._on_bucket/_checkpoint).
assert s.completed_buckets == ["st", "1"], s.completed_buckets
async def test_on_bucket_none_preserves_return_value() -> None:
"""on_bucket=None → поведение прежнее, байт-в-байт: лоты возвращаются из
fetch_city как раньше, никаких промежуточных вызовов."""
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
out_lots.append(f"{rooms}-only")
s = _scraper()
result = await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=None)
assert result == [f"{b}-only" for b in ROOM_BUCKETS]
assert s.completed_buckets == list(ROOM_BUCKETS)
# ── Секция 2: run_domclick_city_sweep(watchdog_sec=...) ──────────────────────
class _NoOpRuns:
"""Достаточно методов, чтобы SERP-фаза дошла до asyncio.wait_for и честно
финализировалась после симулированного TimeoutError значения не важны,
важен ТОЛЬКО timeout, с которым позвали wait_for."""
def is_cancelled(self, db: Any, run_id: int) -> bool:
return False
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
return None
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
return None
async def _drive_and_capture_timeout(**kwargs: Any) -> float:
captured: dict[str, float] = {}
async def _fake_wait_for(coro: Any, timeout: float) -> None:
captured["timeout"] = timeout
coro.close()
raise TimeoutError()
with (
patch(f"{PFX}.asyncio.wait_for", _fake_wait_for),
patch(f"{PFX}.runs", _NoOpRuns()),
):
await run_domclick_city_sweep(
object(), # type: ignore[arg-type]
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=object(),
run_id=1,
city_id=4,
**kwargs,
)
return captured["timeout"]
async def test_watchdog_sec_default_formula_matches_prod_11100() -> None:
"""watchdog_sec=None (дефолт) → прежняя формула байт-в-байт. pages=100,
delay=6.0 те же параметры, что дали 11 100с в run 7344 (Москва)."""
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0)
assert timeout == 11100
async def test_watchdog_sec_override_bypasses_formula() -> None:
"""watchdog_sec задан явно → формула не считается вовсе, идёт ровно override
даже с теми же pages/delay, что в тесте формулы выше."""
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0, watchdog_sec=777)
assert timeout == 777

View file

@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
# #2899: колонка есть у всех строк после миграции 267; NULL у старых
# (бэкфилла нет — позицию по сохранённому top-10 не восстановить).
market_percentile=63,
# #2898: окно срока экспозиции (миграция 324); NULL у строк до неё.
est_days_p25=31,
est_days_p50=58,
est_days_p75=121,
est_days_n=44,
analogs=[],
actual_deals=[],
sources_used=["avito"],
@ -801,3 +806,36 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None:
)
assert resp.status_code == 200
assert resp.json()["market_percentile"] is None
def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None:
"""#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке.
До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST.
"""
row = _make_estimate_row(created_by="kopylov")
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44}
assert body["est_days_on_market"] == 58
def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None:
"""Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null."""
row = _make_estimate_row(created_by="kopylov")
row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["exposure_window"] is None
assert resp.json()["est_days_on_market"] is None

View file

@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None:
src = inspect.getsource(estimator._empty_estimate)
assert "relaxations, reliability" in src
assert "'very_low'" in src
def test_revival_persists_exposure_window(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий
GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал."""
from app.schemas.trade_in import ExposureWindow
window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44)
async def _fake_estimate_quality(payload, db, **kwargs):
return _fake_revived_result(exposure_window=window, est_days_on_market=58)
_estimator_stub.estimate_quality = _fake_estimate_quality
db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID))
resp = _client_with(trade_in_app, db).get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1]
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
31,
58,
121,
44,
]

View file

@ -0,0 +1,115 @@
"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора.
Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки
(Реж, Карпинск, Кушва, Лесной, Тавда). `_resolve_target_city` возвращал None,
`_fetch_dkp_corridor` собирал сделки по одной улице во всей области «Ленина» там
в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга.
Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по
всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.;
Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не
содержит этих имён отдельным словом ложного городского фильтра на ЕКБ-адресах нет.
"""
from __future__ import annotations
import os
from datetime import date
from typing import Any
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city
_REGION_66_SWEEP_CITIES = sorted(
name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva"
)
@pytest.mark.parametrize(
("address", "city"),
[
("Реж, Ленина, 12", "реж"),
("Карпинск, Ленина, 5", "карпинск"),
("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"),
("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"),
],
)
def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None:
assert _resolve_target_city(address) == city
@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES)
def test_every_region_66_sweep_city_is_resolved(name: str) -> None:
"""Новый город развёртки не должен снова выпасть в «коридор по всей области»."""
assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е")
def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None:
"""Москва — город развёртки, но другого региона: для неё прежний None."""
assert _resolve_target_city("Москва, ул. Тверская, 1") is None
def test_ekb_district_named_like_a_city_still_not_a_city() -> None:
"""Прежний гард «мкр Заречный» не сломан расширением словаря."""
assert _resolve_target_city("рВерх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None
# ── коридор: значение, а не параметр ─────────────────────────────────────────
_EKB_PPM2 = 150_000
_REZH_PPM2 = 55_000
def _deals_db() -> Any:
"""Двойник сессии, исполняющий городской фильтр так же, как SQL коридора.
Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без
`LOWER(d.city) = :target_city` запрос отдаёт обе, с ним только свой город.
"""
rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [
{"city": "реж", "ppm2": _REZH_PPM2}
] * 4
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
result = MagicMock()
sql = str(query)
picked: list[dict[str, Any]] = []
if "FROM deals d" in sql:
city = (params or {}).get("target_city")
filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql
picked = [
{"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)}
for r in rows
if not filtered or r["city"] == city
]
result.mappings.return_value.all.return_value = picked
return result
db = MagicMock()
db.execute.side_effect = _execute
return db
def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None:
"""«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга.
На main город не распознан фильтра нет 10 екатеринбургских сделок из 14
и медиана 150 000 /м².
"""
address = "Реж, Ленина, 12"
raw = _fetch_dkp_corridor(
_deals_db(),
address=address,
rooms=2,
area=45.0,
city=_resolve_target_city(address),
)
assert raw is not None
assert raw["count"] == 4
assert raw["median_ppm2"] == _REZH_PPM2

View file

@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
assert "tradein_canon_addr(norm_address) AS canon" in flat
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat
# #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются.
assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
houses_side = (
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
@ -341,6 +342,8 @@ def test_canon_addr_street_type_and_punct_collapse() -> None:
def test_canon_addr_strips_rayon_and_korpus_slash() -> None:
# «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен).
# Так было в зеркалируемой 144; с мигр. 322 дробь в ключе остаётся (#2189,
# tests/test_2189_canon_keeps_slash.py проверяет саму функцию).
assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"
@ -512,6 +515,7 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
@ -522,8 +526,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
db.execute(
text(
"INSERT INTO houses (short_address) VALUES "
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')"
"INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)"
)
)
@ -613,6 +617,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
@ -633,7 +638,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
)
)
db.execute(
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')")
text(
"INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Ленина,5', 50), ('ул. Мира,1', 50)"
)
)
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
@ -652,3 +660,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
finally:
db.rollback()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_house_gets_gar_guid_only_from_its_own_region() -> None:
"""#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются.
Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid
региона 50 и 340 региона 77. Канон не несёт ни город, ни регион, поэтому
екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» один ключ.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
raw = db.connection()
raw.exec_driver_sql(_NORMALIZER_FN)
raw.exec_driver_sql(_CANON_FN)
db.execute(
text(
"CREATE TEMP TABLE gar_house_flats ("
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
" flat_count int, full_address text, norm_address text, street_name text,"
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')"
)
)
# ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после
# прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены.
db.execute(
text(
"INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, "
"gar_match_method) VALUES "
"('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), "
"('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), "
"('ул. Ленина,5', 50, NULL, NULL, NULL), "
"('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')"
)
)
def _state() -> set[tuple[Any, ...]]:
rows = db.execute(
text(
"SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses"
)
).all()
return {tuple(r) for r in rows}
assert gfl.match_houses_to_gar(db, region_code="66") == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного
("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен
("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50
("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо
}
assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ
("ул. Мира,7", 66, None, None),
("ул. Ленина,5", 50, "g50-lenina", 400),
("ул. Мира,7", None, "g50-mira", 250),
}
finally:
db.rollback()
db.close()

View file

@ -121,7 +121,7 @@ def test_keeper_listing_count_puts_nulls_last() -> None:
assert "listing_cnt DESC NULLS LAST" in order
# Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а),
# берут одну и ту же константу — иначе ранг и keeper разъедутся построчно.
mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False))
mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR))
assert mapping.count("listing_cnt DESC NULLS LAST") >= 2
@ -274,56 +274,31 @@ def test_fias_pass_clusters_by_house_fias_id() -> None:
assert "'addr:'" not in flat
def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None:
"""#2187: ФИАС identity strictly outranks geo-proximity, so the fias-pass mapping must NOT
carry the 250 m distance guard (same-fias rows merge even with NULL geom on a side or >250 m
apart), while the canon-pass mapping MUST keep it (cross-town over-merge protection)."""
fias = _flat(_FIAS_MAPPING_SQL)
canon = _flat(_MAPPING_SQL)
# canon pass keeps the full geo guard (distance + NULL-geom safety on both sides).
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon
assert "keeper_geom IS NOT NULL" in canon
assert "loser_geom IS NOT NULL" in canon
# fias pass drops the distance guard AND the NULL-geom exclusions entirely.
#
# Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also
# MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes.
# Measuring is the opposite of guarding — the fias pass is precisely where nothing else records
# how far apart the merged rows were — so the name alone can no longer stand in for the guard.
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
def test_both_passes_carry_the_geo_guard() -> None:
"""#2690: the fias pass carries the same 250 m guard as the canon pass (#2187 is reverted).
house_fias_id is DaData's answer to our own address string, so it does not tell towns apart
any better than the canon does prod 2026-09-05 the unguarded fias pass merged 53 pairs
more than 3 km apart.
The value-level proof is test_real_fias_pass_keeps_geo_guard; this pins the rendered SQL.
"""
guard = (
"AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL "
"AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
)
assert guard in canon
assert guard not in fias
# the cross-fias anti-over-merge guard is untouched in the canon pass.
assert "lower(loser_fias) <> lower(keeper_fias)" in canon
def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None:
"""_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias
guard is emitted regardless, and the default is True (canon-safe)."""
guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250"
with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True))
without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False))
assert guard in with_guard
assert guard not in without_guard
# default = True (the canon pass must never lose its guard by omission).
assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR))
# ...while the journal's distance MEASUREMENT is emitted either way (#2690).
assert "AS distance_m" in with_guard and "AS distance_m" in without_guard
# cross-fias guard present in BOTH renderings (independent of the geo guard).
assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard
assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard
assert guard in _flat(_MAPPING_SQL)
assert guard in _flat(_FIAS_MAPPING_SQL)
for sql in (_MAPPING_SQL, _FIAS_MAPPING_SQL):
# the journal's distance MEASUREMENT and the cross-fias guard stay on both passes.
assert "AS distance_m" in sql
assert "lower(loser_fias) <> lower(keeper_fias)" in _flat(sql)
def test_both_passes_share_one_pipeline_no_copy_paste() -> None:
"""The two mappings differ ONLY in the cluster key + the (canon-only) geo guard — no copy-paste.
"""The two mappings differ ONLY in the cluster key — no copy-paste.
#2187: the 250 m distance guard is emitted for the canon pass ONLY; the fias pass drops it (a
shared ГАР UUID IS the building identity and outranks proximity). Everything else the CTE
skeleton, keeper rule, per-partition geom/fias exposure and the cross-fias guard is identical.
The CTE skeleton, keeper rule, per-partition geom/fias exposure, the cross-fias guard and (since
#2690) the 250 m geo guard are identical.
"""
canon = _flat(_MAPPING_SQL)
fias = _flat(_FIAS_MAPPING_SQL)
@ -338,19 +313,16 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None:
"lower(loser_fias) <> lower(keeper_fias)",
):
assert token in canon and token in fias
# the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity.
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon
assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias
# ...but the journal's distance MEASUREMENT is on both — measuring is not guarding.
assert "AS distance_m" in canon and "AS distance_m" in fias
# the rendered SQL is the same text once the cluster key is swapped back.
assert fias.replace(_flat(hdm._FIAS_KEY_EXPR), _flat(hdm._CANON_KEY_EXPR)) == canon
def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None:
"""Canon pass must NOT merge two rows that BOTH carry a non-null but DIFFERENT house_fias_id.
This is the critical anti-over-merge fix for the canon slash-collapse class: «Сулимова, 32»
and «Сулимова, 3/2» share the canon «сулимова32» but are different buildings their distinct
fias UUIDs veto the merge.
and «Сулимова, 3/2» shared the canon «сулимова32» (until migration 322, #2189) but are different
buildings their distinct fias UUIDs veto the merge.
"""
flat = _flat(_MAPPING_SQL)
assert "h.house_fias_id AS loser_fias" in flat
@ -1022,14 +994,133 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_canon_strips_district_glued_to_house_number() -> None:
"""#1772: a district glued to the house number («17Ар-н Академический») is canon noise.
Prod 2026-09-17: 44 houses carry such an address (the «29р-н» slip of #1773); the canon kept
the tail («евгениясавкова17арнакадемический») or even ate the letter («44Бр-н» «44н»),
so the twin «ул. Евгения Савкова,17А» never shared a cluster and the merge could not see it.
Asserted on the function the migrations installed (311), not on a Python mirror of it.
"""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
def canon(s: str) -> str:
return db.execute(_t("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
try:
assert canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == (
"евгениясавкова17а"
)
assert canon("ул. Евгения Савкова,17А") == "евгениясавкова17а"
assert canon("Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический") == (
"евгениясавкова44б"
)
assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная8/1"
# the letter is a different building: «17А» never collapses into «17»
assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17"
# the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2»)
assert canon("Мкр-н Кутузовский, 2") == "нкутузовский2"
# #2189: дробь в номере дома не теряется
assert canon("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга7/6"
# End to end: the glued row and its clean twin (same point) merge; «17» without the
# letter, at the same point, stays a separate building.
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
"(900040,'avito','EXT-1772-G','u',"
" 'Екатеринбург, ул. Савкова1772, 17Ар-н Академический',56.84,60.6),"
"(900041,'cian','EXT-1772-T','u','ул. Савкова1772,17А',56.84,60.6),"
"(900042,'cian','EXT-1772-N','u','ул. Савкова1772, 17',56.84,60.6)"
)
)
db.execute(
_t(
"INSERT INTO listings "
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
"(910040,'avito','http://t/1772/g','L-1772-G','dh-1772-g',7000000,900040)"
)
)
db.commit()
out = hdm.merge_duplicate_houses(db, dry_run=False)
ids = {
r.id for r in db.execute(_t("SELECT id FROM houses WHERE id BETWEEN 900040 AND 900042"))
}
assert ids == {900040, 900042}, sorted(ids)
assert out["losers_deleted"] == 1
journal = db.execute(
_t(
"SELECT merge_pass, cluster_key, loser_id, keeper_id FROM house_merge_log "
"WHERE loser_id BETWEEN 900040 AND 900042"
)
).all()
assert [tuple(j) for j in journal] == [("canon", "addr:савкова177217а", 900041, 900040)]
finally:
db.rollback()
db.execute(_t("DELETE FROM listings WHERE id = 910040"))
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900040 AND 900042"))
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900040 AND 900042"))
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900040 AND 900042"))
db.commit()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing() -> None:
"""Migration 311 under a running GAR load fails in ~5 s and rolls back whole (#2752).
Its REINDEX needs SHARE on gar_house_flats; the loader's INSERT holds ROW EXCLUSIVE. Without
lock_timeout the deploy waits for the load and every query on the table queues behind it.
The file is run the way the deploy runner runs it (autocommit, BEGIN/COMMIT inside); the
blocker lets go after 12 s, so a missing timeout shows up as a migration that succeeds.
"""
import threading
import time
import psycopg
db = _live_session()
assert db is not None
dsn = db.get_bind().url.set(drivername="postgresql").render_as_string(hide_password=False)
db.close()
sql = (_SQL_DIR / "311_canon_strip_district_glued_to_house_number.sql").read_text("utf-8")
body_version = "SELECT xmin::text FROM pg_proc WHERE proname = 'tradein_canon_addr'"
with psycopg.connect(dsn, autocommit=True) as deploy, psycopg.connect(dsn) as loader:
before = deploy.execute(body_version).fetchone()
loader.execute("LOCK TABLE gar_house_flats IN ROW EXCLUSIVE MODE")
release = threading.Timer(12, loader.rollback)
release.start()
started = time.monotonic()
try:
with pytest.raises(psycopg.errors.LockNotAvailable):
deploy.execute(sql)
finally:
release.cancel()
release.join()
loader.rollback()
assert time.monotonic() - started < 10
deploy.execute("ROLLBACK")
# CREATE OR REPLACE rewrites the pg_proc row: an unchanged xmin = the body was rolled back
assert deploy.execute(body_version).fetchone() == before
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
"""End-to-end on a real DB for the #1772 follow-up (fias pass + cross-fias guard + carry-over):
A. same house_fias_id, DIFFERENT canon (different streets) MERGED by the FIAS pass;
the loser's listing re-points onto the single survivor.
B. same canon (slash-collapse «32»/«3/2»), BOTH fias filled and DIFFERENT NOT merged
(cross-fias guard vetoes provably different buildings), both survive.
B. same canon («ул.»/«улица», same number), BOTH fias filled and DIFFERENT NOT merged
(cross-fias guard vetoes provably different buildings), both survive. Until #2189 this
pair was «32»/«3/2»; migration 322 gave them different canons, so the guard went unused.
C. same canon, fias only on the LOSER MERGED by the canon pass; the loser's
house_fias_id + gar_house_guid CARRY OVER onto the fias-less keeper.
D. a second full run is idempotent (nothing left to merge).
@ -1051,10 +1142,10 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
" 'F-SAME-1772',NULL,NULL),"
"(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000,"
" 'F-SAME-1772',NULL,NULL),"
# B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS
"(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000,"
# B — same canon, DIFFERENT fias → cross-fias guard BLOCKS
"(900022,'avito','EXT-B-1','u','ул. Клара1772, 32',56.84000,60.60000,"
" 'F-B1-1772',NULL,NULL),"
"(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000,"
"(900023,'cian', 'EXT-B-2','u','улица Клара1772, 32',56.84009,60.60000,"
" 'F-B2-1772',NULL,NULL),"
# C — same canon, fias only on the loser → canon-pass merge + carry-over
"(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL),"
@ -1117,7 +1208,8 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
db.execute(
_t(
"DELETE FROM house_address_aliases WHERE normalized_address IN "
"('фиасодин1772, 10','совсемдругая1772, 77','клара1772, 32','клара1772, 3/2',"
"('фиасодин1772, 10','совсемдругая1772, 77','ул. клара1772, 32',"
"'улица клара1772, 32',"
"'донбасс1772, 8')"
)
)
@ -1130,19 +1222,17 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_fias_pass_ignores_geo_guard() -> None:
"""End-to-end on a real DB for #2187 — the FIAS pass drops the 250 m geo guard:
def test_real_fias_pass_keeps_geo_guard() -> None:
"""End-to-end on a real DB for #2690 — the FIAS pass obeys the same 250 m geo guard:
A. same house_fias_id, loser has NULL geom (no lat/lon) MERGED (fias identity outranks
the missing coordinate; the geom-first keeper survives + the loser's listing re-points).
B. same house_fias_id, ~5 km apart (>250 m) MERGED (fias identity outranks
proximity a scattered/broken coordinate no longer blocks the merge).
C. same canon, NO fias, ~5 km apart (>250 m) NOT merged (the canon pass STILL
enforces the geo guard the fix is fias-only, canon behaviour is unchanged).
A. same house_fias_id, loser has NULL geom (no lat/lon) NOT merged (guard cannot judge).
B. same house_fias_id, ~5 km apart (>250 m) NOT merged (prod 2026-09-05: the
unguarded pass merged «р-н Синарский, улица Кирова, 4» into Екатеринбург, 362 km).
C. same house_fias_id, DIFFERENT canon, ~10 m apart MERGED (the pass still works
without C, «nothing merged» would pass even if the fias pass were dead).
A/B use distinct synthetic streets so ONLY the shared fias groups them (the canon never does);
C shares one address so only the canon pass can group it. Streets «*2187» avoid prod aliases.
Latitude delta 0.045° at ~56.84° 5 km (>250 m).
Distinct synthetic streets «*2690» so ONLY the shared fias groups each pair (never the canon).
Latitude delta 0.045° at ~56.84° 5 km; 0.00009° 10 m.
"""
from sqlalchemy import text as _t
@ -1153,49 +1243,53 @@ def test_real_fias_pass_ignores_geo_guard() -> None:
_t(
"INSERT INTO houses "
"(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES "
# A — same fias, loser NULL geom → fias pass merges despite the missing coordinate
"(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187'),"
"(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187'),"
# B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance
"(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187'),"
"(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187'),"
# C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged)
"(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL),"
"(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)"
"(900030,'avito','EXT-2690-A-K','u','ФиасГеоA2690, 1',56.84,60.6,'F-A-2690'),"
"(900031,'cian', 'EXT-2690-A-L','u','ФиасГеоAL2690, 2',NULL,NULL,'F-A-2690'),"
"(900032,'avito','EXT-2690-B-K','u','ФиасГеоB2690, 3',56.84,60.6,'F-B-2690'),"
"(900033,'cian', 'EXT-2690-B-L','u','ФиасГеоBL2690, 4',56.885,60.6,'F-B-2690'),"
"(900034,'avito','EXT-2690-C-K','u','ФиасГеоC2690, 5',56.84,60.6,'F-C-2690'),"
"(900035,'cian', 'EXT-2690-C-L','u','ФиасГеоCL2690, 6',56.84009,60.6,'F-C-2690')"
)
)
# A loser gets a listing so we prove the re-point still fires with a NULL-geom loser.
db.execute(
_t(
"INSERT INTO listings "
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
"(910031,'cian','http://t/2187/al','L-2187-AL','dh-2187-al',6000000,900031)"
"(910031,'cian','http://t/2690/al','L-2690-AL','dh-2690-al',6000000,900031),"
"(910035,'cian','http://t/2690/cl','L-2690-CL','dh-2690-cl',6000000,900035)"
)
)
db.commit()
out = hdm.merge_duplicate_houses(db, dry_run=False)
assert out["losers_deleted"] >= 2 # A + B both merged by the fias pass
ids = [
ids = {
r.id
for r in db.execute(
_t("SELECT id FROM houses WHERE id BETWEEN 900030 AND 900035 ORDER BY id")
_t("SELECT id FROM houses WHERE id BETWEEN 900030 AND 900035")
).all()
}
assert ids == {900030, 900031, 900032, 900033, 900035}, sorted(ids)
assert out["losers_deleted"] == 1
# A: the NULL-geom row keeps its own listing; C: the row WITH the listing is the keeper.
lst = dict(
db.execute(
_t("SELECT id, house_id_fk FROM listings WHERE id IN (910031, 910035)")
).all()
)
assert lst == {910031: 900031, 910035: 900035}
journal = db.execute(
_t(
"SELECT merge_pass, geo_guard, loser_id, keeper_id, round(distance_m) AS d "
"FROM house_merge_log WHERE loser_id BETWEEN 900030 AND 900035"
)
).all()
assert [(j.merge_pass, j.geo_guard, j.loser_id, j.keeper_id, j.d) for j in journal] == [
("fias", True, 900034, 900035, 10)
]
# A: same-fias, loser NULL geom → MERGED (the geo guard would have blocked on NULL geom).
assert 900030 in ids and 900031 not in ids, "same-fias NULL-geom loser must merge"
# B: same-fias, >250 m apart → MERGED (the geo guard would have blocked on distance).
assert 900032 in ids and 900033 not in ids, "same-fias >250 m apart must merge"
# C: same-canon, no fias, >250 m apart → NOT merged (canon geo guard unchanged by #2187).
assert 900034 in ids and 900035 in ids, "canon-only pair >250 m apart must NOT merge"
# A: the NULL-geom loser's listing re-pointed onto the surviving geom-first keeper.
repointed = db.execute(_t("SELECT house_id_fk FROM listings WHERE id = 910031")).scalar()
assert repointed == 900030
finally:
db.rollback()
db.execute(_t("DELETE FROM listings WHERE id = 910031"))
db.execute(_t("DELETE FROM listings WHERE id IN (910031, 910035)"))
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035"))
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035"))
# journal rows have no FK and are never cascaded away — sweep them explicitly,

View file

@ -73,7 +73,11 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
proxy_provider: object | None = None,
use_pool: bool = False,
environment: str = "dev",
reuse_context: bool = False,
) -> None:
# reuse_context (#3118) обязан быть в сигнатуре двойника: фабрика
# build_browser_fetcher передаёт его ВСЕГДА, и двойник без него падал бы
# TypeError на каждом вызывающем, а не проверял то, ради чего написан.
calls.append(
{
"source": source,
@ -81,6 +85,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
"proxy_provider": proxy_provider,
"use_pool": use_pool,
"environment": environment,
"reuse_context": reuse_context,
}
)

View file

@ -261,6 +261,7 @@ _PAID_FIELDS = {
"actual_deals",
"expected_sold_price_rub",
"est_days_on_market",
"exposure_window",
"price_trend",
}

View file

@ -411,8 +411,19 @@ async def _drive_domclick(
recorder = _RunsRecorder()
db = MagicMock()
lots = [MagicMock() for _ in range(lots_n)]
async def _fetch_city(**kw: Any) -> list[Any]:
# fix/domclick-incremental-save: save_listings переехал внутрь цикла по корзинам и
# зовётся из колбэка on_bucket. Стаб, который просто возвращает лоты, не
# вызвал бы сохранение вовсе — фикстура проверяла бы мёртвую ветку.
# Одна корзина со всеми лотами: ровно один save_listings, как и было.
on_bucket = kw.get("on_bucket")
if on_bucket is not None and lots:
on_bucket(ROOM_BUCKETS[0], lots)
return lots
scraper = _ctx_scraper(
fetch_city=AsyncMock(return_value=lots),
fetch_city=_fetch_city,
blocked=blocked,
geo_filtered=0,
fetch_errors=fetch_errors,

View file

@ -34,6 +34,9 @@ Per-provider модель (#1793):
Переменные окружения:
BROWSER_PORT TCP-порт HTTP-сервера (default: 3000)
BROWSER_MAX_INSTANCES потолок одновременно живых camoufox на весь сайдкар
(default: 3, #3412). Перед запуском нового инстанса
закрывается простаивающий чужой; занятый никогда.
BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный
дефолт для провайдеров без код-дефолта (default: 15).
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
@ -141,6 +144,7 @@ Per-provider модель (#1793):
import asyncio
import base64
import gzip
import itertools
import logging
import os
import random
@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
IS_PROD: bool = ENVIRONMENT == "production"
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда —
# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m.
# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53),
# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ
# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед
# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс
# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances).
BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3")))
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {}
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
_launched_proxy: dict[str, str | None] = {}
# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для
# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы
# свободное место.
_launching: set[str] = set()
# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не
# часы: два обращения в один тик часов неразличимы.
_last_used: dict[str, int] = {}
_use_seq = itertools.count(1)
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
proxy is not None,
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
)
# #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
# запуска (первый запрос, смена прокси, recycle/краш, фоновый retry).
await _trim_instances(provider)
_launching.add(provider)
try:
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
browser = await cm.__aenter__()
finally:
_launching.discard(provider)
_browser_cms[provider] = cm
_browsers[provider] = browser
_page_counters[provider] = 0
_launched_proxy[provider] = proxy_url
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
logger.info(
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
provider,
@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None:
async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
await _close_reusable_context(provider)
cm = _browser_cms.get(provider)
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой
запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого
поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и
поднять свой, а не получить полузакрытый браузер.
_last_goto_at НЕ трогает: пейсинг ритм обращений к площадке, он переживает
инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser,
смена прокси в _ensure_browser), ровно как было до #3412.
"""
cm = _browser_cms.pop(provider, None)
_browsers.pop(provider, None)
_page_counters[provider] = 0
_launched_proxy.pop(provider, None)
await _close_reusable_context(provider) # context/якорь снимает до своего await
if cm is not None:
try:
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None:
provider,
type(exc).__name__,
)
_browsers.pop(provider, None)
_browser_cms.pop(provider, None)
_page_counters[provider] = 0
_last_goto_at.pop(provider, None)
_launched_proxy.pop(provider, None)
def _pick_victim(provider: str) -> str | None:
"""Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
Кандидаты живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа
поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут
инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса.
С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи —
4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри
класса давнее всех использованный. None закрыть некого.
"""
idle = [
p
for p in _browsers
if p != provider and not (p in _locks and _locks[p].locked())
]
if not idle:
return None
return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0)))
async def _trim_instances(provider: str) -> None:
"""Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412).
ponytail: потолок мягкий если все прочие инстансы заняты, поднимаемся сверх него
с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на
слот если предупреждение станет регулярным. Цена мягкости и места проверки: запуск,
который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс.
"""
while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES:
victim = _pick_victim(provider)
if victim is None:
logger.warning(
"tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — "
"запуск сверх потолка",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
)
return
logger.info(
"tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем "
"простаивающий %s",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
victim,
)
await _close_browser(victim)
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
@ -1083,6 +1169,7 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) ->
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _launch_browser(provider, proxy_override=effective)
@ -1137,6 +1224,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
if _browsers.get(provider) is not None:
if _launched_proxy.get(provider) == desired_proxy:
_last_used[provider] = next(_use_seq)
return True
# Реальная смена прокси → пересобрать инстанс с новым прокси.
logger.info(
@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
proxy_override is not None,
)
await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _try_launch_browser(provider, proxy_override=proxy_override)
if _browsers.get(provider) is not None:
@ -1211,6 +1300,7 @@ async def _on_startup(app: web.Application) -> None:
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
_warn_retired_env()

View file

@ -0,0 +1,209 @@
"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
relaunch) или сам потолок; проверка по тому, какие инстансы ОСТАЛИСЬ живыми.
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_instance_limit.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
class _FakeCM:
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
launch_delay = 0.0
closed: list[str] = []
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
visible_while_closing: dict[str, bool] = {}
def __init__(self, **kwargs: object) -> None:
pass
async def __aenter__(self) -> object:
await asyncio.sleep(_FakeCM.launch_delay)
return object()
async def __aexit__(self, *args: object) -> None:
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
_FakeCM.visible_while_closing[provider] = provider in server._browsers
await asyncio.sleep(0)
_FakeCM.closed.append(provider)
_cms_by_provider: dict[str, _FakeCM] = {}
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
"_launched_proxy", "_last_used"):
monkeypatch.setattr(server, name, {})
monkeypatch.setattr(server, "_launching", set())
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
_FakeCM.launch_delay = 0.0
_FakeCM.closed = []
_FakeCM.visible_while_closing = {}
_cms_by_provider.clear()
fake_mod = types.ModuleType("camoufox.async_api")
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
async def _launch(provider: str) -> None:
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
_cms_by_provider[provider] = server._browser_cms[provider]
def _live() -> set[str]:
return set(server._browsers)
def test_launch_at_cap_closes_idle_instance() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _live() == {"yandex", "generic"}
assert _FakeCM.closed == ["cian"]
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
async def _run() -> None:
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
server._anchor_pages["avito"] = object()
await _launch("cian")
await _launch("yandex")
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
assert _FakeCM.closed == ["cian"]
async def _noop() -> None:
return None
def test_eviction_keeps_victim_pacing() -> None:
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
async def _run() -> None:
await _launch("cian")
server._last_goto_at["cian"] = 12345.0
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert "cian" not in _live()
assert server._last_goto_at.get("cian") == 12345.0
def test_background_retry_path_respects_cap() -> None:
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
async def _run() -> bool:
await _launch("cian")
await _launch("yandex")
return await server._try_launch_browser("avito")
assert asyncio.run(_run()) is True
assert len(_live()) == 2
assert "avito" in _live()
def test_fresh_instance_is_not_the_next_victim() -> None:
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
async def _run() -> None:
await _launch("cian")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("yandex") # свежий, после последнего обращения к cian
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
asyncio.run(_run())
assert _FakeCM.closed == ["cian"]
def test_use_refreshes_lru_order() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == ["yandex"]
def test_busy_instance_is_never_closed() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
for provider in ("cian", "yandex"):
await (await server._lock_for(provider)).acquire()
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == []
assert _live() == {"cian", "yandex", "generic"}
def test_parallel_launches_reserve_their_slot() -> None:
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
async def _run() -> None:
await _launch("cian")
_FakeCM.launch_delay = 0.01
await asyncio.gather(_launch("avito"), _launch("yandex"))
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
def test_evicted_instance_disappears_before_close_awaits() -> None:
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.visible_while_closing == {"cian": False}

View file

@ -82,6 +82,12 @@ services:
# (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m
memswap_limit: 3g
# init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не
# собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver,
# Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026
# за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида),
# pids.current 2722 при трёх живых браузерах.
init: true
# stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие

View file

@ -178,10 +178,14 @@ LIMIT 20;
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
закрыта сужением по городу, поведение byte-for-byte прежнее.
- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона
(`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже
проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог
пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
BY flat_count DESC, house_guid)`.
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon
ORDER BY flat_count DESC, house_guid)`.
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.

View file

@ -74,7 +74,7 @@ const TARIFFS: readonly {
},
{
name: "Полный отчёт",
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж",
note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж",
price: `${SERVICE_PRICE_RUB}`,
},
{

View file

@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
sources_used: ["avito", "cian", "rosreestr"],
data_freshness_minutes: 35,
last_scraped_at: "2026-05-30T12:00:00Z",
exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 },
est_days_on_market: 48,
address_precision: "house",
area_m2: 55.3,

View file

@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const houseType = estimate.house_type ?? input.house_type;
const repairState = estimate.repair_state ?? input.repair_state;
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
const estDaysOnMarket = estimate.est_days_on_market;
// #2898: окно p25p75, а не одно число. est_days_on_market не показываем:
// у старых оценок это возраст висящих объявлений, а не срок до снятия.
const exposure = estimate.exposure_window ?? null;
function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault();
@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<span className="v mono">{estimate.n_analogs}</span>
</div>
<div className="meta-row">
<span className="k">Срок продажи</span>
{estDaysOnMarket !== null ? (
<span className="v mono">{estDaysOnMarket} дн.</span>
<span className="k">До снятия объявления</span>
{exposure ? (
<span
className="v mono"
title={`Средняя половина из ${exposure.n} похожих объявлений рядом висела столько дней до снятия. Снятие объявления не значит продажу.`}
>
{exposure.p25_days}{exposure.p75_days} дн.
</span>
) : (
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
)}

View file

@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
<HouseAnalyticsKpiRow kpi={data.kpi} />
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
{data.price_history.length >= 2 && (
<PriceHistoryChart points={data.price_history} />
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
)}
{data.recent_sold.length > 0 && (
<RecentSoldList items={data.recent_sold} />

View file

@ -13,7 +13,9 @@ import {
import type { PriceHistoryYearPoint } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry";
type Props = { points: PriceHistoryYearPoint[] };
// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние
// в этом радиусе (M2 audit #2583).
type Props = { points: PriceHistoryYearPoint[]; radiusM: number };
const AVITO_LABEL = sourcePublicLabel("avito_imv");
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
@ -28,7 +30,7 @@ interface PivotRow {
n_yandex?: number;
}
export function PriceHistoryChart({ points }: Props) {
export function PriceHistoryChart({ points, radiusM }: Props) {
const pivoted = useMemo<PivotRow[]>(() => {
const byYear: Record<number, PivotRow> = {};
for (const p of points) {
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
<article className="card" style={{ marginTop: 12, padding: 16 }}>
<header style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
История цен в этом доме
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
Медиана /м² по годам ·{" "}
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
</small>
</header>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}

View file

@ -0,0 +1,46 @@
/**
* #2898 в hero окно срока до снятия объявления (p25p75), а не одно число.
* Старое est_days_on_market без окна не показываем: у оценок до #2898 это возраст
* висящих объявлений, и «48 дн.» читалось бы как срок продажи.
*/
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { HeroSummary } from "../HeroSummary";
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate } from "@/types/trade-in";
vi.mock("@/lib/useBrand", () => ({
useActiveBrandSlug: () => null,
useBrand: () => ({ data: null }),
}));
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
function heroText(estimate: AggregatedEstimate): string {
const { container } = render(
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
);
return container.textContent ?? "";
}
describe("#2898 окно срока в v1 HeroSummary", () => {
it("показывает края окна", () => {
const text = heroText({
...FIXTURE_ESTIMATE,
exposure_window: { p25_days: 29, p50_days: 61, p75_days: 146, n: 44 },
est_days_on_market: 61,
});
expect(text).toContain("До снятия объявления29146 дн.");
expect(text).not.toContain("Срок продажи");
});
it("без окна — «нет данных», старое число не всплывает", () => {
const text = heroText({
...FIXTURE_ESTIMATE,
exposure_window: null,
est_days_on_market: 48,
});
expect(text).toContain("До снятия объявлениянет данных");
expect(text).not.toContain("48 дн.");
});
});

View file

@ -357,7 +357,7 @@ export default function AnalyticsView({
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
{data.priceHistory.title}
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
@ -401,9 +401,9 @@ export default function AnalyticsView({
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
>
<title>История цен в этом доме медиана /м² по годам</title>
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
{PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} />

View file

@ -0,0 +1,75 @@
// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была
// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до
// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом
// стояло «в радиусе 300 м» — две взаимоисключающие подписи.
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PriceHistoryChart } from "../../PriceHistoryChart";
import AnalyticsView from "../AnalyticsView";
import { mapAnalytics } from "../mappers";
import type {
HouseAnalyticsResponse,
PriceHistoryYearPoint,
} from "@/types/trade-in";
const points: PriceHistoryYearPoint[] = [
{ year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 },
{ year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 },
];
function analytics(radius_m: number): HouseAnalyticsResponse {
return {
house_ids: [1, 2, 3],
radius_m,
price_history: points,
recent_sold: [],
kpi: {
total_lots: 7,
sold_count: 0,
sold_rate_pct: 0,
median_exposure_days: null,
median_bargain_pct: null,
},
};
}
describe("v2 история цен: подпись следует radius_m", () => {
it("выборка расширена соседями — не «только этот дом»", () => {
const { priceHistory } = mapAnalytics(analytics(300), null, null);
expect(priceHistory.title).toBe("История цен: дом и соседние");
expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м");
expect(priceHistory.note).not.toContain("только этот дом");
});
it("экран v2 печатает подпись из данных, а не константу", () => {
render(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
expect(screen.getByText("История цен: дом и соседние")).toBeTruthy();
expect(screen.queryByText(/в этом доме/)).toBeNull();
});
it("выборка ровно по дому — прежняя подпись", () => {
const { priceHistory } = mapAnalytics(analytics(0), null, null);
expect(priceHistory.title).toBe("История цен в этом доме");
expect(priceHistory.note).toContain("только этот дом");
});
});
describe("старый экран: PriceHistoryChart", () => {
it("radiusM>0 — заголовок и подпись про соседей", () => {
// recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
render(<PriceHistoryChart points={points} radiusM={300} />);
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
expect(screen.queryByText(/только этот дом/)).toBeNull();
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
});
});

View file

@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number {
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
// M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних
// домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране
// стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому.
const radiusM = a?.radius_m ?? 0;
const title =
radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме";
const scope =
radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом";
const empty: PriceHistory = {
title,
note: "Нет данных по истории цен",
years: [],
avito: "",
@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;
@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
});
return {
title,
note,
years: yearsNum.map(String),
avito: series("avito_imv"),

View file

@ -264,6 +264,8 @@ export interface SellTimeTier {
}
export interface PriceHistory {
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
title: string;
note: string;
years: string[];
/** SVG polyline points string (viewBox 0 0 900 220). */

View file

@ -128,6 +128,15 @@ export interface PriceTrendPoint {
ppm2: number; // ₽/м²
}
// #2898: сколько дней похожие квартиры рядом висели до СНЯТИЯ объявления
// (не подтверждённой продажи) — квартили house_placement_history.exposure_days.
export interface ExposureWindow {
p25_days: number;
p50_days: number;
p75_days: number;
n: number; // снятых объявлений в выборке
}
export interface CianValuationSummary {
sale_price_rub: number | null;
rent_price_rub: number | null;
@ -234,7 +243,10 @@ export interface AggregatedEstimate {
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
data_freshness_minutes: number | null; // «обновлено N минут назад»
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
est_days_on_market: number | null; // прогноз срока продажи
// #2898: окно p25p75; null — выборка < 30 или оценка старее бэкенда.
exposure_window?: ExposureWindow | null;
// Устарело: = exposure_window.p50_days. Показывать окно, не это число.
est_days_on_market: number | null;
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).

View file

@ -4868,6 +4868,7 @@ async def run_domclick_city_sweep(
region_code: int = DEFAULT_REGION_CODE,
resume_run_id: int | None = None,
cookies: dict[str, str] | None = None,
watchdog_sec: int | None = None,
) -> DomClickCitySweepCounters:
"""DomClick citywide sweep через BFF JSON API.
@ -4896,6 +4897,15 @@ async def run_domclick_city_sweep(
зависли на challenge). None (дефолт, сессии в БД нет/протухла) прежнее
поведение, без инъекции.
watchdog_sec (incremental-save): явный override расчётной формулы watchdog'а. Формула
ниже (buckets × pages × per_fetch + budget) не знает про бисекцию по цене
(ДомКлик режет offset на 2000, каждый лист пагинируется отдельно отдельным
деревом сплитов) и на больших городах (Москва 23 690 лотов против ЕКБ
6 300) занижена в разы прод run 7344: за 2ч watchdog'а (11 100с) пройдено
2 бакета из 6. С инкрементальным сохранением (on_bucket, см. ниже) ранний
снос по watchdog больше не теряет собранное, поэтому вместо более точной
оценки простой override: None (дефолт) прежняя формула байт-в-байт.
Возвращает DomClickCitySweepCounters.
"""
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
@ -4909,11 +4919,17 @@ async def run_domclick_city_sweep(
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters()
# Watchdog: 6 buckets × pages × per_fetch + budget.
# Watchdog: 6 buckets × pages × per_fetch + budget. watchdog_sec (incremental-save) — явный
# override для больших городов, где формула занижена (см. докстринг выше).
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
if watchdog_sec is not None:
_sweep_timeout = int(watchdog_sec)
else:
_sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
int(
_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S
),
)
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
@ -4991,14 +5007,68 @@ async def run_domclick_city_sweep(
_sweep_timeout,
)
lots: list[ScrapedLot] = []
# #2406: собранное сохраняется ОДНИМ save_listings после всех корзин, поэтому
# корзина считается пройденной только если фаза дошла до конца (см. чекпоинт ниже).
_saved = False
# incremental-save: сохранение инкрементальное — save_listings зовётся из _on_bucket ПОСЛЕ
# КАЖДОГО room-бакета, а не одним save_listings в самом конце. Раньше снятие фазы
# по watchdog'у (asyncio.wait_for TimeoutError) теряло ВСЁ собранное — на большой
# выдаче (Москва ≈23 690 лотов vs ЕКБ ≈6 300) формула watchdog'а систематически
# не укладывалась в отведённое время (прод run 7344: 2 бакета из 6 за 2ч).
_cancel_reason: str | None = None
def _on_bucket(bucket_key: str, bucket_lots: list[ScrapedLot]) -> None:
"""Инкрементальный save сразу после того, как room-бакет отфетчился.
fetch_city (serp.py) зовёт колбэк ВНЕ try/except конкретного бакета
исключение отсюда прерывает обход ROOM_BUCKETS целиком (канал кооперативной
отмены/SIGTERM-дрейна), а не проглатывается generic except'ом бакета.
Sentinel-приём (RuntimeError("cancelled")/("shutdown")) тот же, что в
run_cian_full_load._on_bucket (#1182 Phase 3a).
"""
nonlocal _checkpoint
if runs.is_cancelled(db, run_id):
logger.info(
"domclick-sweep run_id=%d: cancel detected in on_bucket (%s)",
run_id,
bucket_key,
)
raise RuntimeError("cancelled")
elif shutdown_requested():
logger.info(
"domclick-sweep run_id=%d: SIGTERM-drain — stopping at bucket %s",
run_id,
bucket_key,
)
raise RuntimeError("shutdown")
if bucket_lots:
# Имя города — из профиля региона, а не из сравнения с vestigial
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# У области (50) city_name=None — одного города нет, угадывать нечего.
inserted, updated = save_listings(
db,
bucket_lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_geo_profile.city_name,
)
counters.lots_fetched += len(bucket_lots)
counters.lots_inserted += inserted
counters.lots_updated += updated
# #3118, теперь на уровне бакета: done_buckets означает "собрано И
# сохранено" — чекпоинт пишем ТОЛЬКО пройдя cancel/shutdown-гейт выше и
# save_listings этого бакета, не из scraper.completed_buckets (см.
# комментарий у "Перенести счётчики" ниже — там раньше был баг #2 issue).
_checkpoint = sorted(set(_checkpoint) | {bucket_key})
runs.update_heartbeat(db, run_id, _payload())
logger.info(
"domclick-sweep run_id=%d: bucket %s saved lots=%d total=%d",
run_id,
bucket_key,
len(bucket_lots),
counters.lots_fetched,
)
async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots, _saved
"""Единственная citywide-фаза: fetch_city с инкрементальным save по бакетам."""
async with DomClickScraper(
config,
proxy_provider=proxy_provider,
@ -5021,36 +5091,21 @@ async def run_domclick_city_sweep(
# источники и растёт неравномерно, — но за 30 суток каждая корзина
# получает порядка пяти стартов, чего достаточно для критерия приёмки
# «объявления с rooms >= 2 появились».
lots = await _scraper.fetch_city(
await _scraper.fetch_city(
city_id=city_id,
rooms=rooms,
pages=pages,
start_bucket_index=run_id % len(ROOM_BUCKETS),
skip_buckets=skip_buckets or None,
on_bucket=_on_bucket,
)
counters.lots_fetched += len(lots)
if lots:
# Имя города — из профиля региона, а не из сравнения с vestigial
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# У области (50) city_name=None — одного города нет, угадывать нечего.
_dc_city = _geo_profile.city_name
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=region_code,
run_id=run_id,
city=_dc_city,
)
counters.lots_inserted += inserted
counters.lots_updated += updated
_saved = True
try:
await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout)
except TimeoutError:
logger.warning(
"domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results",
"domclick-sweep run_id=%d: SERP phase timed out after %ds — partial results "
"(incremental-save: уже сохранены инкрементально, бакет за бакетом)",
run_id,
_sweep_timeout,
)
@ -5080,6 +5135,16 @@ async def run_domclick_city_sweep(
ban_kind=ban_kind_of_exception(exc),
)
return counters
except RuntimeError as exc:
# on_bucket кидает RuntimeError("cancelled") при кооперативной отмене,
# RuntimeError("shutdown") при SIGTERM-дрейне — тот же sentinel-приём, что в
# run_cian_full_load._on_bucket (#1182 Phase 3a). Прочие RuntimeError —
# обычная поломка фазы, ведём себя как под generic except ниже.
if str(exc) in ("cancelled", "shutdown"):
_cancel_reason = str(exc)
else:
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
counters.errors_count += 1
except Exception:
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
counters.errors_count += 1
@ -5095,15 +5160,13 @@ async def run_domclick_city_sweep(
# скрейпер живая, а его счётчик показывает, докуда прогон дошёл.
counters.buckets_completed = _s.buckets_completed
counters.buckets_total = _s.buckets_total
# #3118: чекпоинт = унаследованное завершённое в этом прогоне.
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
# затирают, и оборванный болезнью финализации прогон его не теряет.
# #2406: только если лоты сохранены. Снятая watchdog'ом или упавшая фаза
# не дошла до save_listings — у её корзин в БД ноль строк, а отметка
# «пройдена» заставила бы следующий прогон пропустить их навсегда
# (механизм разобран в миграции 308, из-за него выключены свипы 77/50).
if _saved:
_checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
# incremental-save: _checkpoint СЮДА больше не мержится из _s.completed_buckets — он
# пишется инкрементально внутри _on_bucket, СРАЗУ после save_listings этого
# бакета. _s.completed_buckets на уровне скрейпера отмечает бакет как "фетч
# прошёл" ДО вызова on_bucket (см. serp.py): если on_bucket поймал
# cancel/shutdown ДО save для этого самого бакета, _s.completed_buckets
# включил бы его, а _checkpoint — честно нет (лоты не сохранены). Мердж
# отсюда воспроизвёл бы старый баг — чекпоинт врёт про несохранённые бакеты.
runs.update_heartbeat(db, run_id, _payload())
counters.bucket_start_index = _s.bucket_start_index
@ -5111,6 +5174,32 @@ async def run_domclick_city_sweep(
counters.pages_fetched = _num_fetches
runs.update_heartbeat(db, run_id, _payload())
# incremental-save: кооперативная отмена/SIGTERM-дрейн, пойманные в on_bucket — партиал уже
# сохранён инкрементально, финализируем как run_cian_full_load (mark_done partial,
# не honest-status ниже: обрыв тут known-signal, а не "прогон не доделал сам").
if _cancel_reason == "cancelled":
logger.info(
"domclick-sweep run_id=%d: cancelled — partial results lots=%d (ins=%d/upd=%d)",
run_id,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
)
runs.mark_done(db, run_id, _payload())
return counters
if _cancel_reason == "shutdown":
logger.info(
"domclick-sweep run_id=%d: SIGTERM-drain — partial results lots=%d (ins=%d/upd=%d)",
run_id,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
)
_drain = {**_payload(), "interrupted": 1}
runs.update_heartbeat(db, run_id, _drain)
runs.mark_done(db, run_id, _drain)
return counters
# ── ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657) ───────────────────────────
# Распознанный QRATOR-блок НИКОГДА не даёт done. Домклик тут структурно
# отличается от cian/yandex (#2625/#2642): там независимые anchor'ы, и

View file

@ -1270,6 +1270,9 @@ async def _job_domclick_city_sweep(
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
region_code=_resolve_region_code(params),
resume_run_id=_pick_resume(db, run_id),
watchdog_sec=(
int(params["watchdog_sec"]) if params.get("watchdog_sec") is not None else None
),
)

View file

@ -192,6 +192,7 @@ def build_browser_fetcher(
*,
proxy_provider: ProxyProvider | None = None,
fetch_timeout_s: float | None = None,
reuse_context: bool = False,
) -> BrowserFetcher:
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
@ -221,6 +222,11 @@ def build_browser_fetcher(
(120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s);
`yandex/newbuilding.py` идёт на дефолтных 120s.
`reuse_context=False` (дефолт) сохраняет прежнее поведение всех вызывающих:
холодный контекст на каждый /fetch. `reuse_context=True` (#3118, свип домклика)
держит один сайдкар-контекст на весь прогон вместо нового камуфокса на каждый
фетч.
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод отказ вместо мёртвого
env-прокси. `getattr` с дефолтом "dev" минимальные ScraperConfig-заглушки без поля
@ -234,6 +240,7 @@ def build_browser_fetcher(
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
environment=environment,
reuse_context=reuse_context,
)
return BrowserFetcher(
source=source,
@ -242,6 +249,7 @@ def build_browser_fetcher(
proxy_provider=proxy_provider,
use_pool=config.use_proxy_pool_browser,
environment=environment,
reuse_context=reuse_context,
)

View file

@ -459,6 +459,7 @@ class DomClickScraper(BaseScraper):
pages: int = 100,
start_bucket_index: int = 0,
skip_buckets: set[str] | None = None,
on_bucket: Callable[[str, list[ScrapedLot]], None] | None = None,
) -> list[ScrapedLot]:
"""Citywide sweep через BFF JSON API.
@ -492,6 +493,17 @@ class DomClickScraper(BaseScraper):
buckets_total при этом = числу корзин В ЭТОМ прогоне (без
скипнутых) иначе honest-status читал бы возобновлённый прогон
как вечно-частичный.
on_bucket: колбэк инкрементального сохранения большая выдача теряла
ВСЁ собранное при снятии по watchdog, единственный save был в самом
конце (fix/domclick-incremental-save). Зовётся СИНХРОННО сразу после того, как
бакет отработал успешно (после DomClickBlockedError/generic
Exception НЕ зовётся), аргументы: имя бакета + лоты ИМЕННО
этого бакета (не накопленный out_lots). Вызов стоит ВНЕ
try/except этого бакета: исключение из колбэка (кооперативная
отмена/SIGTERM-дрейн см. run_domclick_city_sweep) обязано
прервать цикл по ROOM_BUCKETS, а не быть проглоченным generic
except'ом. on_bucket=None (дефолт) — поведение прежнее,
байт-в-байт.
Returns:
Дедуплицированный по source_id список ScrapedLot.
@ -510,8 +522,17 @@ class DomClickScraper(BaseScraper):
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
# reuse_context=True (#3118): без него сайдкар поднимает новый камуфокс на
# КАЖДЫЙ /fetch (browser.new_page() создаёт свежий изолированный контекст) —
# разовая инъекция cookie выше никогда не видит живой qrator_jsid2, который
# сайт ротирует через Set-Cookie (TTL ~2.5ч). Замер: 26 подряд холодных
# фетчей = 100% блок, те же карточки в тёплом контексте — 5/5 примерно по 2с
# (см. backend/app/tasks/domclick_detail_backfill.py:394-401). Заодно
# холодный путь не давал якорной вкладке выжить между фетчами — goto(origin)
# валился таймаутом 60с, убивая всю корзину. Тёплый контекст держит один
# сайдкар-контекст на весь прогон вместо этого.
async with build_browser_fetcher(
self._config, "domclick", proxy_provider=self._proxy_provider
self._config, "domclick", proxy_provider=self._proxy_provider, reuse_context=True
) as fetcher:
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
@ -548,6 +569,7 @@ class DomClickScraper(BaseScraper):
city_id,
pages,
)
_bucket_start_len = len(out_lots)
try:
await self._sweep_bucket(
fetcher=fetcher,
@ -569,6 +591,16 @@ class DomClickScraper(BaseScraper):
# прокинут выше) это уже не no-op: узел уходит в
# scrape_proxy_source_bans и следующий acquire("domclick") его не
# выдаст.
# NB: request_context_reset() здесь СОЗНАТЕЛЬНО не зовём. Он лишь
# взводит `_context_reset_pending`, а тот уезжает в сайдкар только
# ключом `reset_context` СЛЕДУЮЩЕГО fetch() (browser_fetcher.py:565);
# `__aexit__` его не сливает. Ниже сразу break — фетчей больше не
# будет, флаг умрёт вместе с объектом. Вызов был бы no-op'ом, который
# читается как защита.
# Дыра остаётся: `_contexts[provider]` в сайдкаре — dict без TTL, так
# что сожжённый блоком контекст достанется СЛЕДУЮЩЕМУ прогону. Закрыть
# можно только отдельной ручкой сброса в сайдкаре (сейчас там только
# /fetch, /fetch-json, /login, /health, /pacing) — отдельной задачей.
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
break
except Exception as exc:
@ -582,6 +614,14 @@ class DomClickScraper(BaseScraper):
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
# watchdog asyncio.wait_for) обязан пройти насквозь.
self.fetch_errors += 1
# #3118: корзина упала — тёплый контекст (reuse_context=True) мог
# сгореть вместе с ней, и тогда он переползёт в следующую корзину.
# Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд
# ('5+', 'st', '1') легли за две минуты каждая на таймауте
# goto(origin) — якорная вкладка не поднималась в том же контексте.
# Сброс стоит РОВНО здесь, а не в ветке DomClickBlockedError: там
# сразу break и прогон заканчивается, сбрасывать уже нечего.
fetcher.request_context_reset()
logger.warning(
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
bucket,
@ -591,6 +631,11 @@ class DomClickScraper(BaseScraper):
continue
self.buckets_completed += 1
self.completed_buckets.append(bucket)
# incremental-save: колбэк ВНЕ try/except этого бакета — исключение (канал
# кооперативной отмены/SIGTERM-дрейна в pipeline.run_domclick_city_sweep)
# обязано прервать цикл, а не попасть в generic except выше.
if on_bucket is not None:
on_bucket(bucket, out_lots[_bucket_start_len:])
logger.info(
"domklik: fetch_city done city_id=%d total=%d buckets=%d/%d "