Compare commits
180 commits
fix/3443-c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b818c2029 | |||
| fd454dec4b | |||
| 7da94f183f | |||
| 9e337855da | |||
| 5b6be30894 | |||
| 71e551d57d | |||
| a6a1b7cdfc | |||
| 9c71349eed | |||
| 4c11a7dab5 | |||
| f9bebdf40f | |||
| e942acaf12 | |||
| 3a8513cd68 | |||
| 77fdeefc50 | |||
| 6eaa31ac66 | |||
| 5af29ee9a4 | |||
| 92af3491ae | |||
| d76411b45f | |||
| c14119a20b | |||
| 084cb79f99 | |||
| 86629dc9f4 | |||
| 372328b726 | |||
| 6375b191e4 | |||
| 2b73961af5 | |||
| cd504c1eb9 | |||
| f9045673e1 | |||
| 4ccf701df6 | |||
| 21fe24419a | |||
| 33182e7df9 | |||
| 89b4a67508 | |||
| 8def34028c | |||
| b74b02ea8b | |||
| be1403289a | |||
| 30d40e5747 | |||
| 3ee5244491 | |||
| 6003ddedad | |||
| bdc169af8b | |||
| 7350287be7 | |||
| 85d455e387 | |||
| e4d44025e5 | |||
| 635381f7b8 | |||
| e3616e3611 | |||
| 3b2f5ed642 | |||
| aefbd993bb | |||
| b9c89641d1 | |||
| a4e97c4b70 | |||
| 3b9742c60d | |||
| 90b93b90b5 | |||
| fc5735fd6d | |||
| a0522b9a86 | |||
| 9f6701d3a5 | |||
| a130303cc9 | |||
| 10c80e9e1d | |||
| 1e99d91afe | |||
| e52057e66d | |||
| a672178bcb | |||
| a9e5322b7a | |||
| 0be4d91d30 | |||
| 6a6fc7031d | |||
| 6fe231c101 | |||
| 99f8525b1c | |||
| 57b03092ea | |||
| 2b3602364a | |||
| 404b583682 | |||
| af5eed19f9 | |||
| d9a5bfe71f | |||
| b2394b0a0f | |||
| 1812a1ed18 | |||
| d087e3076d | |||
| d551cfbab9 | |||
| 452a0368ce | |||
| 29ac3043b4 | |||
| 2639d2a7e7 | |||
| 0327af6fab | |||
| 69e7047398 | |||
| 7cd15aab71 | |||
| 2e4a82dd46 | |||
| f48a510e94 | |||
| 91a075bc81 | |||
| 228c5f58f9 | |||
| bc7fe9b0b9 | |||
| 2cfc2eda08 | |||
| c7a03495e2 | |||
| 3992d7546f | |||
| cbf0f6dec6 | |||
| 45017d2535 | |||
| 8bcb025c0d | |||
| 1a9a612a82 | |||
| 803ea2217b | |||
| 49bcfdacd6 | |||
| 6e8c261ff8 | |||
| 7501fef5c3 | |||
| 9cc23c6698 | |||
| 47a91acf61 | |||
| b2ff92afbb | |||
| 4961128f87 | |||
| e83742ac41 | |||
| ab003877dc | |||
| 611878df4c | |||
| 1a773554db | |||
| 92295971fe | |||
| 4549311720 | |||
| 808a4fb4cb | |||
| d2a31dff08 | |||
| eade88bc66 | |||
| 6be2bf9aaa | |||
| fb02e41d77 | |||
| 6c5f327acd | |||
| a6c86e88ea | |||
| 19b6081242 | |||
| c871f804cf | |||
| 7218c2094c | |||
| facdbb2566 | |||
| e42e370ac9 | |||
| 8e5f097c5e | |||
| ec85ea1861 | |||
| e5db8dd80f | |||
| e2b1a8adee | |||
| bc0ef9536e | |||
| ceb2e5972d | |||
| 31c31f8f82 | |||
| 1fe383f935 | |||
| 26a7f1c163 | |||
| 883c741a63 | |||
| 0ff2703032 | |||
| 45bd47fdd7 | |||
| 08216e4c43 | |||
| 56f0cdbdf7 | |||
| 4dffb2d1fb | |||
| 9f11b8ed69 | |||
| 71d60ff13e | |||
| f1f0d297cf | |||
| 34642e1dd5 | |||
| 251ca98c5c | |||
| 1db6612843 | |||
| 1452e63eec | |||
| 8ebd63780f | |||
| ad239db034 | |||
| 1c039b358c | |||
| c63c48eea2 | |||
| 576dba9b24 | |||
| 31b207be93 | |||
| 4af1b14225 | |||
| 0c6b5a4548 | |||
| 1bd5283aa6 | |||
| 6da7de06a4 | |||
| ce60fc214d | |||
| 39996ef3fd | |||
| f2c18f5216 | |||
| 19418066c2 | |||
| dbf46228fb | |||
| 824de24897 | |||
| 967f2b53ba | |||
| 2d2d9ebf17 | |||
| a83418799c | |||
| d7aaa00326 | |||
| 3d114672fe | |||
| 811b9ecc8f | |||
| d33c97521d | |||
|
|
89d095f486 | ||
| 0af80b4395 | |||
| 94d34e7dcb | |||
| 76a2963edc | |||
| 66a8c39ee4 | |||
| 76bf9f1cf7 | |||
| ed76ad8d43 | |||
| 3218190a89 | |||
| 8f06373e2f | |||
| 5ee208087d | |||
| 6ba04d845e | |||
| 8863781589 | |||
| e6e7a8db1c | |||
| 68495907d5 | |||
| 70bb5a3a8f | |||
| 59482cae85 | |||
| 5d46e59bdc | |||
| a8ba2d5d3c | |||
| a8503d3a58 | |||
| c265e1f769 | |||
| 162e101636 | |||
| 98a582e242 |
266 changed files with 21002 additions and 2872 deletions
|
|
@ -233,6 +233,10 @@ jobs:
|
|||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||
- '.forgejo/workflows/deploy-metrics.yml'
|
||||
- 'docker-compose.metrics.yml'
|
||||
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
||||
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
||||
# экспортёра) без этой строки прошла бы мимо гейта.
|
||||
- 'docker-compose.metrics-agent.yml'
|
||||
# #3443: тот же класс, третий раз. Гейт
|
||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||
|
|
@ -256,10 +260,12 @@ jobs:
|
|||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||
# не бежали ни разу с момента написания.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
||||
# нужен, в отличие от tradein-лэйна.
|
||||
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
||||
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
||||
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
||||
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
||||
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
||||
#
|
||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||
|
|
@ -290,15 +296,14 @@ jobs:
|
|||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
||||
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgres:16
|
||||
postgis/postgis:16-3.4
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 45); do
|
||||
|
|
|
|||
|
|
@ -388,6 +388,15 @@ jobs:
|
|||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||
fi
|
||||
|
||||
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
|
||||
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
|
||||
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
|
||||
# на диск и не вступала в силу до случайного пересоздания.
|
||||
# Пересоздаём только при расхождении инода.
|
||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||
gendesign-metrics docker-compose.metrics.yml \
|
||||
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
|
||||
|
||||
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||
|
|
@ -406,15 +415,6 @@ jobs:
|
|||
fi
|
||||
fi
|
||||
|
||||
# ── Приёмка ────────────────────────────────────────────────────
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||
|
||||
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||
# перечитывает ────────────────────────────────────────────────
|
||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||
|
|
@ -424,17 +424,41 @@ jobs:
|
|||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||
#
|
||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
||||
# после `git reset --hard` подхватывается без пересоздания
|
||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
||||
# никто не звал сам reload.
|
||||
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
||||
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
||||
# файла: после `git reset --hard` за путём в контейнере остаётся
|
||||
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
||||
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
||||
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
||||
# --web.enable-lifecycle включён в compose ради этого шага.
|
||||
#
|
||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
||||
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
||||
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
|
||||
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
||||
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
||||
# контейнером того же образа. `docker exec` в работающий проверял
|
||||
# бы тот самый старый инод, а не то, что сейчас применится.
|
||||
# Синтаксически верное правило может врать по смыслу —
|
||||
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
||||
# отсюда test rules.
|
||||
if docker run --rm --entrypoint sh \
|
||||
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
||||
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
|
||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||
gendesign-metrics docker-compose.metrics.yml \
|
||||
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
|
||||
|
||||
# ── Приёмка ────────────────────────────────────────────────
|
||||
# После возможного пересоздания выше — ждём оба процесса,
|
||||
# которые дальше перечитывают конфиг по HTTP.
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
|
||||
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||
|
||||
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||
|
||||
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||
|
|
@ -456,10 +480,34 @@ jobs:
|
|||
fi
|
||||
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
|
||||
else
|
||||
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
||||
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool (проверка конфига, правил или их юнит-тестов — смотри вывод выше) — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Grafana: датасорсы применяются только при старте (#3467) ────
|
||||
# Каталог provisioning смонтирован целиком, новый datasources.yml
|
||||
# контейнер видит сразу. Дашборды провайдер пересканирует сам
|
||||
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
|
||||
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
|
||||
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
|
||||
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
|
||||
#
|
||||
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
|
||||
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
|
||||
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
|
||||
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
|
||||
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
|
||||
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
|
||||
case "$GRAFANA_RELOAD" in
|
||||
*"Datasources config reloaded"*)
|
||||
echo "Grafana: датасорсы перечитаны."
|
||||
;;
|
||||
*)
|
||||
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||
agent-apps:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -551,6 +599,21 @@ jobs:
|
|||
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||
|
||||
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
|
||||
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
|
||||
# `git reset --hard` пишет правку новым инодом: без пересоздания
|
||||
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
|
||||
# Пересоздаём только при расхождении инода. Под гейтом профиля:
|
||||
# `up` с явным именем сервиса включает его профиль сам.
|
||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
fi
|
||||
|
||||
agent-infra:
|
||||
runs-on: ubuntu-latest
|
||||
needs: server
|
||||
|
|
@ -620,3 +683,10 @@ jobs:
|
|||
|
||||
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||
|
||||
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
|
||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -511,23 +511,36 @@ jobs:
|
|||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
|
||||
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
|
||||
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
|
||||
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
|
||||
# Dockerfile-комментарий). Когда номера появятся — вписать
|
||||
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
|
||||
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
|
||||
# образа (build-time bake, не runtime-правка на проде).
|
||||
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
|
||||
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
|
||||
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
|
||||
# автоматически, на разных — только через письмо-подтверждение
|
||||
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
|
||||
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
|
||||
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
|
||||
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
|
||||
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
|
||||
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
|
||||
# права и снимает их, если тег исчез.
|
||||
# Литералы, а не secret'ы: оба значения и так уходят в разметку
|
||||
# каждой страницы, прятать нечего, а secret'ом их не проверить
|
||||
# глазами в диффе.
|
||||
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
|
||||
# права в Search Console ещё не заведены. Пустая строка =
|
||||
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
|
||||
# тот же Dockerfile-комментарий).
|
||||
# ЛЮБОЕ из этих значений вписывать литералом СЮДА И в retry-блок
|
||||
# ниже — оба обязательны, иначе ретрай без кеша уедет без счётчика.
|
||||
# Требует пересборки образа: build-time bake, не runtime-правка.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
NEXT_PUBLIC_YM_ID=
|
||||
NEXT_PUBLIC_YM_ID=112672927
|
||||
NEXT_PUBLIC_GA_ID=
|
||||
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
|
|
@ -552,9 +565,9 @@ jobs:
|
|||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
NEXT_PUBLIC_YM_ID=
|
||||
NEXT_PUBLIC_YM_ID=112672927
|
||||
NEXT_PUBLIC_GA_ID=
|
||||
NEXT_PUBLIC_YANDEX_VERIFICATION=
|
||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ on:
|
|||
# исполнял бы старую версию — молча и без единого сигнала.
|
||||
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
||||
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
||||
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
|
||||
# backup.sh, restore-drill.sh, restore.sh — точечное
|
||||
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
|
||||
# снова забыли бы (см. как этот самый комментарий выше был точечным про
|
||||
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
||||
|
|
|
|||
|
|
@ -1910,17 +1910,19 @@ def trigger_ekburg_permits(
|
|||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||
|
||||
|
||||
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
||||
_WAF_COOLDOWN_GUARD_MSG = (
|
||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
||||
"передай i_understand_waf_risk=true в теле запроса."
|
||||
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
|
||||
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
|
||||
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
|
||||
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
|
||||
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
|
||||
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
|
||||
_DOMRF_BLOCK_GUARD_MSG = (
|
||||
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
|
||||
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
|
||||
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
|
||||
"не снимает, запуск отсюда даст только отказы. Передавай "
|
||||
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
|
||||
"принят по числу строк (#3307)."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects(
|
|||
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||
|
||||
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
||||
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
||||
объектов до next beat fire).
|
||||
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||
Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||
свежесозданных объектов).
|
||||
|
||||
- max_objects=None → дефолтный лимит таска (300).
|
||||
- max_objects=3 → smoke-тест.
|
||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||
re-trigger без явного подтверждения оператора запрещён.
|
||||
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||
запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||
|
||||
|
|
@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats(
|
|||
- max_flats=3 → smoke-тест.
|
||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||
того же WAF-бана.
|
||||
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||
блокировкой StormWall (условие — #3307).
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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 помещения/парковка фазы.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
|
|||
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
||||
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
||||
|
||||
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
|
||||
_RE_ACT_NUMBER = re.compile(
|
||||
r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||
re.UNICODE | re.IGNORECASE,
|
||||
)
|
||||
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
|
||||
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
|
||||
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
|
||||
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
|
||||
_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])"
|
||||
_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}")
|
||||
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
|
||||
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
|
||||
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$")
|
||||
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
||||
|
||||
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
||||
|
|
@ -233,7 +237,9 @@ def extract_izyatie_records(
|
|||
normalized = _normalize_ocr_text(ocr_text)
|
||||
|
||||
# Реквизиты акта из заголовка или текста.
|
||||
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
||||
act_number = _extract_act_number(doc_title) or _extract_act_number(
|
||||
normalized, require_act_context=True
|
||||
)
|
||||
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||
normalized, require_act_context=True
|
||||
)
|
||||
|
|
@ -289,9 +295,26 @@ def extract_izyatie_records(
|
|||
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_act_number(text: str) -> str | None:
|
||||
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
|
||||
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
|
||||
"""Извлекает номер акта.
|
||||
|
||||
Без require_act_context (заголовок) — первое «№ N» в тексте.
|
||||
|
||||
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
|
||||
выбирает `_extract_act_date`: «№» вплотную после даты, иначе вплотную перед
|
||||
ней. Первое «№» в теле — это «Решение Думы № 60/1» (Генплан) или «Приказ
|
||||
Министерства № 746-П», а не основание изъятия. Номера рядом с датой нет —
|
||||
None, а не номер соседнего документа.
|
||||
"""
|
||||
if not require_act_context:
|
||||
m = _RE_ACT_NUMBER.search(text)
|
||||
else:
|
||||
date_m = _act_date_match(text, require_act_context=True)
|
||||
if date_m is None:
|
||||
return None
|
||||
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
|
||||
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
return re.sub(r"\s+", "", m.group(1))
|
||||
|
|
@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool:
|
|||
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
||||
|
||||
|
||||
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
|
||||
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
|
||||
for m in _RE_ACT_DATE.finditer(text):
|
||||
if require_act_context and not _act_context_matches(text, m.start()):
|
||||
continue
|
||||
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
||||
|
||||
|
|
@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
|
|||
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
||||
актуальные изъятия, ни сверить срок.
|
||||
"""
|
||||
for m in _RE_ACT_DATE.finditer(text):
|
||||
if require_act_context and not _act_context_matches(text, m.start()):
|
||||
continue
|
||||
day, month, year = m.group(1), m.group(2), m.group(3)
|
||||
try:
|
||||
# Валидируем диапазоны.
|
||||
d, mo, y = int(day), int(month), int(year)
|
||||
except ValueError:
|
||||
continue
|
||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||
return f"{y:04d}-{mo:02d}-{d:02d}"
|
||||
m = _act_date_match(text, require_act_context=require_act_context)
|
||||
if m is None:
|
||||
return None
|
||||
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
||||
|
||||
|
||||
def _extract_purpose(text: str) -> str | None:
|
||||
|
|
|
|||
|
|
@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
|
|||
|
||||
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
||||
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
||||
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
|
||||
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
|
||||
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
|
||||
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
|
||||
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
|
||||
_RE_ACT_NUMBER = re.compile(
|
||||
r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||
r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
|
||||
re.UNICODE,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
|
|||
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
||||
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
||||
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
|
||||
--
|
||||
-- #2962: complex → проект Объектива берём из complex_sources (source=
|
||||
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
|
||||
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
|
||||
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
|
||||
-- project_name и не трогает complex_id → под одним complex_id лежат чужие ЖК
|
||||
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
c.id AS complex_id
|
||||
cs.source_id AS project_name,
|
||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||
-- «Парковый» ⊂ «Парковый квартал»)
|
||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||
FROM latest_obj o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
|
||||
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
||||
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
|
|
@ -369,6 +374,20 @@ _COMPETITORS_SQL = text("""
|
|||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
JOIN complex_sources cs
|
||||
ON cs.complex_id = c.id
|
||||
AND cs.source = 'objective'
|
||||
-- #968: только complexes, у которых ЕСТЬ objective-данные →
|
||||
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
||||
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.project_name = cs.source_id
|
||||
)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||
) k
|
||||
WHERE o.comm_name IS NOT NULL
|
||||
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||
|
|
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC
|
||||
) ASC,
|
||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||
-- брал любой, сверка его отвергала, и верный терялся.
|
||||
name_ok DESC,
|
||||
cs.source_id
|
||||
),
|
||||
mapped AS (
|
||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||
|
|
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
|
|||
cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
UNION
|
||||
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
|
||||
-- (у комплекса может быть несколько корпус-project_name → velocity
|
||||
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
|
||||
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
|
||||
-- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена).
|
||||
SELECT nc.obj_id,
|
||||
ol.project_name AS objective_complex_name
|
||||
nc.project_name AS objective_complex_name
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
AND ol.project_name IS NOT NULL
|
||||
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
|
||||
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
|
||||
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
||||
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
||||
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
||||
-- гео-фильтра — 3 с против 0.4 с.
|
||||
WHERE nc.name_ok
|
||||
),
|
||||
velocity AS (
|
||||
SELECT
|
||||
|
|
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
|
|||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
|
||||
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
|
||||
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
|
||||
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
|
||||
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
|
||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
|
|
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
nearest_cx AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
c.id AS complex_id
|
||||
cs.source_id AS project_name,
|
||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
||||
-- «Парковый» ⊂ «Парковый квартал»)
|
||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
||||
FROM domrf_kn_objects o
|
||||
JOIN complexes c
|
||||
ON c.latitude IS NOT NULL
|
||||
AND c.longitude IS NOT NULL
|
||||
AND c.canonical_name IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||
)
|
||||
AND ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||
|
|
@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||
)
|
||||
JOIN complex_sources cs
|
||||
ON cs.complex_id = c.id
|
||||
AND cs.source = 'objective'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM objective_lots ol
|
||||
WHERE ol.project_name = cs.source_id
|
||||
)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||
) k
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
|
|
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||
) ASC
|
||||
) ASC,
|
||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
||||
-- брал любой, сверка его отвергала, и верный терялся.
|
||||
name_ok DESC,
|
||||
cs.source_id
|
||||
),
|
||||
gapfill_price AS (
|
||||
SELECT
|
||||
|
|
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
|||
ol.price_per_m2_rub AS price_per_m2_rub
|
||||
FROM nearest_cx nc
|
||||
JOIN objective_lots ol
|
||||
ON ol.complex_id = nc.complex_id
|
||||
ON ol.project_name = nc.project_name
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
|
||||
WHERE nc.name_ok
|
||||
)
|
||||
SELECT
|
||||
p.obj_id,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"""Загрузчик OSM POI из Overpass API для site-finder.
|
||||
|
||||
Запускается раз в неделю через Celery beat. Поддерживает фильтр
|
||||
"не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
||||
Запускается раз в неделю через Celery beat (регион по умолчанию — ЕКБ, см. DEFAULT_REGION).
|
||||
Поддерживает фильтр "не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
||||
|
||||
Параметризован регионом (REGION_BBOX) — sync_poi_to_db(region=...) может грузить любой
|
||||
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
|
||||
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
|
@ -19,6 +24,31 @@ logger = logging.getLogger(__name__)
|
|||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||
|
||||
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
|
||||
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
|
||||
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
|
||||
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
|
||||
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
|
||||
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
|
||||
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
|
||||
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
|
||||
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
|
||||
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
|
||||
"ekb": EKB_BBOX,
|
||||
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
|
||||
}
|
||||
DEFAULT_REGION = "ekb"
|
||||
|
||||
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
|
||||
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
|
||||
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
|
||||
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
|
||||
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
|
||||
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
|
||||
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
|
||||
# региона не меняется.
|
||||
MAX_TILE_SIDE_DEG = 0.25
|
||||
|
||||
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
||||
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
||||
|
|
@ -54,17 +84,119 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
|||
}
|
||||
|
||||
|
||||
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||
def _bbox_tiles(
|
||||
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
|
||||
|
||||
(south, west, north, east) → список тайлов того же формата. Для bbox, у которого обе
|
||||
стороны уже ≤ max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
|
||||
ОДНОГО тайла, идентичного входному bbox — тайлинг не меняет поведение для ЕКБ.
|
||||
"""
|
||||
south, west, north, east = bbox
|
||||
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
|
||||
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
|
||||
lat_step = (north - south) / rows
|
||||
lon_step = (east - west) / cols
|
||||
tiles = []
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
tiles.append(
|
||||
(
|
||||
south + r * lat_step,
|
||||
west + c * lon_step,
|
||||
south + (r + 1) * lat_step,
|
||||
west + (c + 1) * lon_step,
|
||||
)
|
||||
)
|
||||
return tiles
|
||||
|
||||
|
||||
def _split_bbox_quadrants(
|
||||
bbox: tuple[float, float, float, float],
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
|
||||
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
|
||||
south, west, north, east = bbox
|
||||
mid_lat = (south + north) / 2
|
||||
mid_lon = (west + east) / 2
|
||||
return [
|
||||
(south, west, mid_lat, mid_lon),
|
||||
(south, mid_lon, mid_lat, east),
|
||||
(mid_lat, west, north, mid_lon),
|
||||
(mid_lat, mid_lon, north, east),
|
||||
]
|
||||
|
||||
|
||||
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
|
||||
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
|
||||
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
|
||||
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
|
||||
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
|
||||
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
|
||||
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
|
||||
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
|
||||
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
|
||||
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
|
||||
# дефолтного региона не меняется.
|
||||
RECURSIVE_SPLIT_MAX_DEPTH = 3
|
||||
|
||||
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
|
||||
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
|
||||
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
|
||||
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
|
||||
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
|
||||
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
|
||||
# 4 успеха — ровно этот механизм.
|
||||
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
|
||||
|
||||
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
|
||||
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
|
||||
# прогона с явной ошибкой, а не тихий пропуск категорий.
|
||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
|
||||
|
||||
|
||||
class OverpassUnreachableError(RuntimeError):
|
||||
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
|
||||
|
||||
|
||||
class _RunState:
|
||||
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
|
||||
|
||||
__slots__ = ("consecutive_transport_errors",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.consecutive_transport_errors = 0
|
||||
|
||||
|
||||
def _is_overload(exc: Exception) -> bool:
|
||||
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return True
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code in _SPLIT_WORTHY_STATUS
|
||||
return False
|
||||
|
||||
|
||||
def _is_transport_error(exc: Exception) -> bool:
|
||||
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
|
||||
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
|
||||
|
||||
|
||||
def _build_overpass_query(
|
||||
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
|
||||
) -> str:
|
||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
|
||||
в ОДНОМ тайле bbox (south, west, north, east) — см. _bbox_tiles.
|
||||
|
||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||
быстрые запросы вместо одного 60+ секундного.
|
||||
"""
|
||||
south, west, north, east = EKB_BBOX
|
||||
bbox = f"({south},{west},{north},{east})"
|
||||
south, west, north, east = bbox
|
||||
bbox_str = f"({south},{west},{north},{east})"
|
||||
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
|
||||
|
||||
|
||||
def _classify(tags: dict[str, str]) -> str | None:
|
||||
|
|
@ -80,55 +212,117 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
|||
|
||||
|
||||
async def _fetch_category(
|
||||
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
||||
client: httpx.AsyncClient,
|
||||
tag_filters: tuple[tuple[str, str], ...],
|
||||
category: str,
|
||||
bbox: tuple[float, float, float, float],
|
||||
state: _RunState,
|
||||
depth: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
|
||||
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
|
||||
транзиентной ошибке; если тайл падает оба раза — адаптивно дробится на 4 четверти
|
||||
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
|
||||
тихо потерять весь тайл.
|
||||
|
||||
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
||||
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
||||
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
||||
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
||||
2 попыток на категорию).
|
||||
2 попыток на тайл ДО дробления).
|
||||
"""
|
||||
tag_desc = _tag_filters_desc(tag_filters)
|
||||
query = _build_overpass_query(tag_filters)
|
||||
query = _build_overpass_query(tag_filters, bbox)
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||
r.raise_for_status()
|
||||
elements: list[dict] = r.json().get("elements", [])
|
||||
logger.info(
|
||||
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
||||
"Overpass: %s (%s) bbox=%s depth=%d → %d [attempt %d]",
|
||||
tag_desc,
|
||||
category,
|
||||
bbox,
|
||||
depth,
|
||||
len(elements),
|
||||
attempt,
|
||||
)
|
||||
# Привязываем category именно к тому per-category запросу, под который
|
||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||
state.consecutive_transport_errors = 0
|
||||
for el in elements:
|
||||
el["_gd_category"] = category
|
||||
return elements
|
||||
except Exception as e:
|
||||
if _is_transport_error(e):
|
||||
state.consecutive_transport_errors += 1
|
||||
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
|
||||
raise OverpassUnreachableError(
|
||||
f"Overpass отказывает на уровне соединения "
|
||||
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
|
||||
f"остановлен, чтобы не стучаться в блокирующий хост"
|
||||
) from e
|
||||
logger.warning(
|
||||
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
|
||||
"без дробления: %s",
|
||||
tag_desc,
|
||||
bbox,
|
||||
state.consecutive_transport_errors,
|
||||
e,
|
||||
)
|
||||
return []
|
||||
state.consecutive_transport_errors = 0
|
||||
if attempt == 1:
|
||||
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
|
||||
logger.warning(
|
||||
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
|
||||
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
|
||||
"(depth %d→%d) instead of dropping the tile: %s",
|
||||
tag_desc,
|
||||
bbox,
|
||||
depth,
|
||||
depth + 1,
|
||||
e,
|
||||
)
|
||||
combined: list[dict] = []
|
||||
for quadrant in _split_bbox_quadrants(bbox):
|
||||
combined.extend(
|
||||
await _fetch_category(
|
||||
client, tag_filters, category, quadrant, state, depth + 1
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
return combined
|
||||
logger.warning(
|
||||
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
|
||||
tag_desc,
|
||||
bbox,
|
||||
depth,
|
||||
e,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_overpass() -> list[dict]:
|
||||
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
||||
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
|
||||
|
||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||
policy: max 2 concurrent, лучше 1 req/s).
|
||||
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
|
||||
≤ MAX_TILE_SIDE_DEG (_bbox_tiles) — для "ekb" это ровно один тайл (без
|
||||
изменения поведения), для регионов с большим bbox (напр. "msk") — несколько,
|
||||
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
|
||||
|
||||
Overpass блокирует default `python-httpx/*` User-Agent (406) — поэтому
|
||||
явный UA с контактом проекта.
|
||||
"""
|
||||
bbox = REGION_BBOX[region]
|
||||
tiles = _bbox_tiles(bbox)
|
||||
state = _RunState()
|
||||
headers = {
|
||||
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
||||
"Accept": "application/json",
|
||||
|
|
@ -136,24 +330,34 @@ async def fetch_overpass() -> list[dict]:
|
|||
all_elements: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||
for tag_filters, category in OSM_CATEGORIES.items():
|
||||
elements = await _fetch_category(client, tag_filters, category)
|
||||
for tile in tiles:
|
||||
elements = await _fetch_category(client, tag_filters, category, tile, state)
|
||||
all_elements.extend(elements)
|
||||
await asyncio.sleep(1.0)
|
||||
logger.info(
|
||||
"Overpass: total %d elements across %d category-queries",
|
||||
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
|
||||
region,
|
||||
len(all_elements),
|
||||
len(OSM_CATEGORIES),
|
||||
len(tiles),
|
||||
)
|
||||
return all_elements
|
||||
|
||||
|
||||
def sync_poi_to_db() -> dict[str, int]:
|
||||
"""Синхронизирует POI из Overpass в osm_poi_ekb.
|
||||
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]:
|
||||
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона.
|
||||
|
||||
region — ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
|
||||
weekly-расписания). Имя таблицы osm_poi_ekb — историческое (изначально
|
||||
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
|
||||
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
|
||||
НЕ переименована: переименование потянуло бы миграции в обеих половинах
|
||||
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
|
||||
|
||||
UPSERT по UNIQUE(osm_type, osm_id, category).
|
||||
Returns: counters {fetched, inserted, updated, skipped_old}.
|
||||
"""
|
||||
elements = asyncio.run(fetch_overpass())
|
||||
elements = asyncio.run(fetch_overpass(region))
|
||||
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
|
||||
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
|
||||
two_years_ago = date.today() - timedelta(days=730)
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
|
|||
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
||||
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
||||
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
|
||||
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
|
||||
# territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
|
||||
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
|
||||
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
||||
#
|
||||
|
|
@ -346,11 +346,13 @@ def build_beat_schedule() -> dict:
|
|||
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
||||
# за ~5 недель полного обновления.
|
||||
#
|
||||
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
|
||||
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
|
||||
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
|
||||
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
|
||||
# cooldown 24-48h (проверить через targeted test).
|
||||
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
|
||||
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
|
||||
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
|
||||
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
|
||||
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
|
||||
# Включать только после решения по прокси и принятого по числу строк
|
||||
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
|
||||
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
||||
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
||||
|
|
@ -362,11 +364,11 @@ def build_beat_schedule() -> dict:
|
|||
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
||||
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
||||
#
|
||||
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
|
||||
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
|
||||
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
||||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
||||
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
|
||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||
# напр. четверг 04:00 МСК.
|
||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text(
|
|||
)
|
||||
|
||||
|
||||
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
|
||||
#
|
||||
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
|
||||
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
|
||||
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
|
||||
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
|
||||
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
|
||||
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
|
||||
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
|
||||
_DELETE_STALE_PARSE_SQL = text(
|
||||
"""
|
||||
DELETE FROM land_reservation
|
||||
WHERE cad_num = CAST(:cad_num AS text)
|
||||
AND doc_url = CAST(:doc_url AS text)
|
||||
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
||||
count = 0
|
||||
|
|
@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
|||
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT per-row
|
||||
db.execute(_DELETE_STALE_PARSE_SQL, row)
|
||||
db.execute(upsert_sql, row)
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует
|
|||
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
||||
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
||||
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
|
||||
VPS IP; включается вручную после проверки targeted-тестом.
|
||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
|
||||
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
|
||||
включать после прокси и принятого kn-прогона (#3307).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -116,7 +117,7 @@ def scrape_kn_catalog_flats(
|
|||
|
||||
Concurrency:
|
||||
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
||||
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
|
||||
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне
|
||||
маловероятно. Если случится:
|
||||
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
||||
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Selector logic:
|
|||
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
||||
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
||||
|
||||
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
|
||||
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -17,12 +17,22 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
|
|||
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
||||
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
||||
что их ожидаемые значения резолвера сохраняются.
|
||||
|
||||
Perf-fix (2026-09-12): в конце ``analyze_parcel`` — безусловный best-effort
|
||||
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
|
||||
В песочнице тестов Celery-брокер (Redis) недоступен → ``.delay()`` синхронно ждёт
|
||||
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит —
|
||||
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
|
||||
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
|
||||
тестов каталога (как и с резолвером выше) — тесты самого enqueue
|
||||
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
|
||||
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -37,3 +47,34 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
|
|||
"""
|
||||
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_forecast_enqueue() -> Iterator[None]:
|
||||
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
|
||||
|
||||
``.delay(...)`` в проде — fire-and-forget (best-effort, обёрнут в try/except в
|
||||
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
|
||||
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
|
||||
"""
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
|
||||
|
||||
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
|
||||
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
|
||||
значение). В тестах фетч замокан и геометрия не появится никогда → каждый такой
|
||||
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
|
||||
``test_recent_permits_invalid_cad_no_regression``).
|
||||
|
||||
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
|
||||
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
|
||||
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
|
||||
``test_run_history_and_response_contract.py``) переопределяют это поверх.
|
||||
"""
|
||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
|
||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
||||
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
||||
|
||||
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
||||
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
|
||||
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
|
||||
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
|
||||
smoke-тест/re-trigger не углубил бан.
|
||||
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
|
||||
для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в
|
||||
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор
|
||||
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
|
||||
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
|
|||
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
||||
|
||||
|
||||
def _assert_refusal_names_real_condition(detail: str) -> None:
|
||||
assert "2443" in detail
|
||||
assert "i_understand_waf_risk" in detail
|
||||
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
|
||||
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
|
||||
assert "3307" in detail
|
||||
assert "cooldown" not in detail.lower()
|
||||
|
||||
|
||||
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.id = task_id
|
||||
|
|
@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
|
|||
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
||||
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json()["detail"]
|
||||
assert "2443" in detail
|
||||
assert "i_understand_waf_risk" in detail
|
||||
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
|
|||
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
||||
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json()["detail"]
|
||||
assert "2443" in detail
|
||||
assert "i_understand_waf_risk" in detail
|
||||
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
|
||||
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
|
||||
|
||||
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
||||
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
||||
|
|
@ -16,9 +16,7 @@
|
|||
реально ушло бы в сеть.
|
||||
|
||||
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
||||
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у
|
||||
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
|
||||
настоящие сетевые проверки.
|
||||
бы с оригиналом на первой же правке.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -34,7 +32,6 @@ import pytest
|
|||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SENDERS = {
|
||||
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
||||
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
|
||||
}
|
||||
|
||||
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
|
||||
|
|
@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
|
|||
f'ARGS_DUMP="{_posix(dump)}"',
|
||||
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
||||
'TELEGRAM_CHAT_ID="-1004443088679"',
|
||||
f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
|
||||
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"',
|
||||
]
|
||||
if topic is not None:
|
||||
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
||||
|
|
@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
|
|||
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
||||
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
||||
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
||||
|
||||
|
||||
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
|
||||
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
|
||||
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
|
||||
# откуда следующий, кто заводит хост, узнает правильный адрес.
|
||||
INFRA_TOPIC = "245"
|
||||
BACKUP_ENV_EXAMPLES = [
|
||||
"gendesign-backup.default.example",
|
||||
"gendesign-backup-couchdb.default.example",
|
||||
"gendesign-backup-forgejo.default.example",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
|
||||
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
|
||||
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
|
||||
|
||||
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
|
||||
маркером, а env-файл — образец, заполненный так, как он велит человеку:
|
||||
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
|
||||
|
||||
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
|
||||
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
|
||||
окружении, — тема из другого файла молча не подхватится.
|
||||
"""
|
||||
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
|
||||
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
|
||||
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
|
||||
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
|
||||
env_file = tmp_path / "backup.env"
|
||||
env_file.write_text(filled, encoding="utf-8", newline="\n")
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
fake = bin_dir / "curl"
|
||||
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
|
||||
fake.chmod(0o755)
|
||||
dump = tmp_path / "args.txt"
|
||||
|
||||
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
|
||||
# перебили бы файл, и тест проверял бы не образец.
|
||||
prefix = [
|
||||
f'PATH="{_posix(bin_dir)}:$PATH"',
|
||||
f'ARGS_DUMP="{_posix(dump)}"',
|
||||
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
|
||||
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
|
||||
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
|
||||
]
|
||||
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
" ".join(prefix)
|
||||
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=dict(os.environ),
|
||||
)
|
||||
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
|
||||
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
|
||||
threads = [a for a in args if a.startswith("message_thread_id=")]
|
||||
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
|
||||
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
|
||||
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
|
|||
|
||||
Как это ломало алерты:
|
||||
|
||||
* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый
|
||||
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был
|
||||
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН
|
||||
`curl`, дальше `|| log WARN`. Каждый
|
||||
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
||||
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
||||
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
||||
|
|
@ -51,7 +53,6 @@ import pytest
|
|||
# backend/tests/ops/<этот файл> → корень репозитория
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
UPTIME = "ops/uptime-healthcheck.sh"
|
||||
LIB_BACKUP = "ops/lib-backup.sh"
|
||||
|
||||
# См. подробное обоснование shutil.which в
|
||||
|
|
@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за
|
|||
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
||||
"""Достаёт тело одной функции из скрипта — не весь файл.
|
||||
|
||||
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
|
||||
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
|
||||
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
|
||||
|
||||
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
||||
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
||||
|
|
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
|
|||
return calls, out, err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
||||
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
||||
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
||||
"""Один отказ — алерт всё равно доставляется со второй попытки.
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
|||
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
||||
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
||||
def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
||||
"""Повторы ограничены: три попытки, а не бесконечный цикл.
|
||||
|
||||
|
|
@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
|||
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
|
||||
|
||||
|
||||
def test_uptime_reports_undelivered_alert_loudly() -> None:
|
||||
"""Когда все три попытки провалились — это видно в логе, а не молча."""
|
||||
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
|
||||
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
|
||||
|
||||
|
||||
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
||||
"""Транзиентный таймаут не должен трогать запасной канал.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -72,6 +73,16 @@ GIT_ENV = {
|
|||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
}
|
||||
|
||||
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
|
||||
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
|
||||
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
|
||||
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
|
||||
# помечены и обязаны выполняться локально.
|
||||
posix_only = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
|
||||
)
|
||||
|
||||
|
||||
def _detect_script() -> str:
|
||||
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
||||
|
|
@ -181,6 +192,7 @@ def _run(
|
|||
return _exec(repo, base_sha if before is None else before, event)
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
||||
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
||||
|
|
@ -193,6 +205,7 @@ def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
|||
assert outputs["frontend"] == "false"
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
||||
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
||||
|
|
@ -203,6 +216,7 @@ def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
|||
assert outputs["backend"] == "true"
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
||||
"""База не разрешилась → полный деплой, а не пустой список.
|
||||
|
||||
|
|
@ -223,6 +237,7 @@ def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
|||
assert "деплой полный" in log
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
||||
"""Решение печатается: и список файлов, и итоговые флаги.
|
||||
|
||||
|
|
@ -291,11 +306,13 @@ def _run_guard(repo: Path) -> subprocess.CompletedProcess:
|
|||
)
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
||||
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
||||
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
||||
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
||||
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
||||
|
|
@ -322,6 +339,7 @@ def test_fast_path_takes_the_same_host_lock() -> None:
|
|||
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
||||
|
||||
|
||||
@posix_only
|
||||
@pytest.mark.parametrize(
|
||||
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
||||
)
|
||||
|
|
@ -338,6 +356,7 @@ def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
|
|||
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
||||
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
||||
|
||||
|
|
@ -350,6 +369,7 @@ def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
|||
assert "изменённых файлов: 0" in log
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
||||
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
||||
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
||||
|
|
@ -357,6 +377,7 @@ def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
|||
assert outputs["caddy_only"] == "false", outputs
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
||||
"""Кириллица в пути не должна прятать файл от классификации.
|
||||
|
||||
|
|
@ -370,6 +391,7 @@ def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
|||
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
||||
|
||||
|
||||
@posix_only
|
||||
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
||||
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в
|
|||
|
||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||
в этом же workflow — только для Prometheus починка не пересоздание
|
||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
||||
без даунтайма.
|
||||
в этом же workflow. `POST /-/reload` лечит правила — они подключены
|
||||
КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает
|
||||
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
|
||||
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
|
||||
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
|
||||
поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С
|
||||
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
|
||||
|
||||
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
|
||||
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
||||
подтверждён сменой lastConfigTime.
|
||||
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
|
||||
(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан
|
||||
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
|
||||
Grafana перечитываются, и отказ этого шага роняет деплой.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
|
||||
|
||||
|
||||
def _text() -> str:
|
||||
return WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _guard_start(text: str) -> int:
|
||||
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
|
||||
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
|
||||
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
|
||||
return guard
|
||||
|
||||
|
||||
def test_promtool_checks_config_and_rules() -> None:
|
||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||
text = _text()
|
||||
|
|
@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None:
|
|||
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||
text = _text()
|
||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||
guard_start = _guard_start(text)
|
||||
else_pos = text.index("else", guard_start)
|
||||
reload_pos = text.index("localhost:9090/-/reload")
|
||||
assert guard_start < reload_pos < else_pos, (
|
||||
|
|
@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None:
|
|||
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||
text = _text()
|
||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||
guard_start = _guard_start(text)
|
||||
else_pos = text.index("else", guard_start)
|
||||
fi_pos = text.index("fi", else_pos)
|
||||
else_branch = text[else_pos:fi_pos]
|
||||
|
|
@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
|||
assert "exit 1" in verify_block, (
|
||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
||||
)
|
||||
|
||||
|
||||
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
|
||||
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
|
||||
|
||||
prometheus.yml смонтирован одним файлом: `docker exec … promtool check config`
|
||||
в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку
|
||||
и уехала бы в пересоздание.
|
||||
"""
|
||||
text = _text()
|
||||
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
|
||||
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
|
||||
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
|
||||
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
|
||||
)
|
||||
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
|
||||
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
|
||||
"""Пересоздание по иноду — после проверки promtool и до reload."""
|
||||
text = _text()
|
||||
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
||||
recreate = re.search(
|
||||
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
|
||||
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
|
||||
text,
|
||||
)
|
||||
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
|
||||
else_pos = text.index("else", _guard_start(text))
|
||||
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
|
||||
"пересоздание Prometheus стоит не между успешной проверкой и reload"
|
||||
)
|
||||
|
||||
|
||||
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
|
||||
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
|
||||
|
||||
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
|
||||
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
|
||||
v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go).
|
||||
"""
|
||||
text = _text()
|
||||
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
|
||||
assert call != -1, "деплой не перечитывает датасорсы Grafana"
|
||||
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
|
||||
assert block, "результат перезагрузки датасорсов не разбирается"
|
||||
ok, _, failed = block.group(1).partition(";;")
|
||||
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
|
||||
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"
|
||||
|
|
|
|||
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
|
||||
|
||||
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
|
||||
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
|
||||
POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
|
||||
job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467).
|
||||
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
|
||||
postgres-экспортёров (#3486).
|
||||
|
||||
ЧТО ПРОВЕРЯЕТСЯ.
|
||||
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
|
||||
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
|
||||
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
|
||||
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
|
||||
добавят новый — тест покраснеет, пока путь не появится.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
|
||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||
|
||||
SRC = "ops/metrics/loki/loki-config.yml"
|
||||
DST = "/etc/loki/loki-config.yml"
|
||||
|
||||
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
|
||||
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
|
||||
# код из $FAKE_PS_RC;
|
||||
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
|
||||
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
|
||||
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
|
||||
FAKE_DOCKER = r"""#!/bin/bash
|
||||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
exec)
|
||||
view="$FAKE_VIEW/$1"
|
||||
[ -f "$view" ] || exit 1
|
||||
cat "$view"
|
||||
;;
|
||||
compose)
|
||||
case " $* " in
|
||||
*" ps "*)
|
||||
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
|
||||
cat "$FAKE_CID_FILE"
|
||||
;;
|
||||
*" up "*)
|
||||
printf 'cid-new\n' > "$FAKE_CID_FILE"
|
||||
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
|
||||
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
|
||||
STAT_SHIM = (
|
||||
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
|
||||
'exec /usr/bin/stat "$@"\n'
|
||||
)
|
||||
NO_SLEEP = "#!/bin/sh\nexit 0\n"
|
||||
|
||||
|
||||
def _write_exec(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _gnu_stat() -> bool:
|
||||
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(tmp_path: Path) -> Path:
|
||||
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
|
||||
tree = tmp_path / "opt" / "gendesign"
|
||||
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
|
||||
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
||||
_write_exec(bin_dir / "sleep", NO_SLEEP)
|
||||
if not _gnu_stat():
|
||||
_write_exec(bin_dir / "stat", STAT_SHIM)
|
||||
|
||||
view = tmp_path / "view"
|
||||
view.mkdir()
|
||||
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
|
||||
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
|
||||
(tmp_path / "log").write_text("", encoding="utf-8")
|
||||
return tree
|
||||
|
||||
|
||||
def _ino(tree: Path) -> int:
|
||||
return os.stat(tree / SRC).st_ino
|
||||
|
||||
|
||||
def _stale(tree: Path) -> None:
|
||||
"""Контейнер держит прежний инод — как после `git reset --hard`."""
|
||||
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
||||
root = tree.parent.parent
|
||||
env = {
|
||||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"FAKE_LOG": str(root / "log"),
|
||||
"FAKE_CID_FILE": str(root / "cid"),
|
||||
"FAKE_VIEW": str(root / "view"),
|
||||
**env_extra,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"sh",
|
||||
str(tree / "ops" / "metrics" / SCRIPT.name),
|
||||
"gendesign-metrics",
|
||||
"docker-compose.metrics.yml",
|
||||
"loki",
|
||||
SRC,
|
||||
DST,
|
||||
],
|
||||
cwd="/", # скрипт обязан сам встать в корень дерева
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
||||
return proc.returncode, proc.stdout + proc.stderr, calls
|
||||
|
||||
|
||||
def _recreates(calls: list[str]) -> list[str]:
|
||||
return [c for c in calls if "--force-recreate" in c]
|
||||
|
||||
|
||||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fresh_inode_touches_nothing(host: Path) -> None:
|
||||
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
|
||||
rc, out, calls = _run(host)
|
||||
assert rc == 0, out
|
||||
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
|
||||
|
||||
|
||||
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
|
||||
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
|
||||
_stale(host)
|
||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||
assert rc == 0, out
|
||||
recreates = _recreates(calls)
|
||||
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
|
||||
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
|
||||
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
|
||||
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
|
||||
assert any(c.startswith("exec cid-new ") for c in calls), (
|
||||
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
|
||||
)
|
||||
|
||||
|
||||
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
|
||||
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
|
||||
_stale(host)
|
||||
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
|
||||
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
|
||||
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
|
||||
|
||||
|
||||
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
|
||||
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
|
||||
(host.parent.parent / "view" / "cid-old").unlink()
|
||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||
assert rc == 0, out
|
||||
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
|
||||
|
||||
|
||||
def test_not_running_container_is_left_alone(host: Path) -> None:
|
||||
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
|
||||
(host.parent.parent / "cid").write_text("", encoding="utf-8")
|
||||
rc, out, calls = _run(host)
|
||||
assert rc == 0, out
|
||||
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
|
||||
|
||||
|
||||
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
|
||||
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
|
||||
_stale(host)
|
||||
rc, out, calls = _run(host, FAKE_PS_RC="1")
|
||||
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
|
||||
assert not _recreates(calls), calls
|
||||
|
||||
|
||||
def test_missing_host_file_is_not_success(host: Path) -> None:
|
||||
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
|
||||
(host / SRC).unlink()
|
||||
rc, out, _calls = _run(host)
|
||||
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
|
||||
|
||||
|
||||
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
|
||||
|
||||
# Производные файлы: их рендерит сам деплой, в git их нет.
|
||||
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
|
||||
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
|
||||
RENDERED = {
|
||||
"ops/metrics/alertmanager/alertmanager.yml",
|
||||
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
|
||||
}
|
||||
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
|
||||
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
|
||||
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
|
||||
COMPOSE_FILES = {
|
||||
"docker-compose.metrics.yml": "gendesign-metrics",
|
||||
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
|
||||
}
|
||||
|
||||
|
||||
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
|
||||
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
|
||||
if compose_name == "docker-compose.metrics.yml":
|
||||
return ["server"]
|
||||
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
|
||||
|
||||
|
||||
def _commands(job: str) -> str:
|
||||
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
|
||||
|
||||
Комментарии — потому что разбор дефекта содержит его формулировку дословно.
|
||||
"""
|
||||
spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||||
assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп"
|
||||
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
|
||||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
||||
|
||||
|
||||
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
|
||||
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
|
||||
found = []
|
||||
for service, body in spec["services"].items():
|
||||
for vol in body.get("volumes") or []:
|
||||
m = MOUNT.match(vol) if isinstance(vol, str) else None
|
||||
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
|
||||
continue
|
||||
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
|
||||
return found
|
||||
|
||||
|
||||
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
|
||||
script_call = re.search(
|
||||
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
|
||||
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
|
||||
commands,
|
||||
)
|
||||
forced = re.search(
|
||||
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
|
||||
)
|
||||
return script_call or forced
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
|
||||
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
|
||||
project = COMPOSE_FILES[compose_name]
|
||||
mounts = _file_mounts(compose_name)
|
||||
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
|
||||
missing = []
|
||||
for service, src, dst, profiles in mounts:
|
||||
for job in _jobs_for(compose_name, profiles):
|
||||
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
|
||||
if not hit:
|
||||
missing.append(f"{job}: {service} {src} → {dst}")
|
||||
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
|
||||
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
|
||||
assert not missing, (
|
||||
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
|
||||
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_gate_sees_the_known_mounts() -> None:
|
||||
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
|
||||
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
|
||||
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
|
||||
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
|
||||
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
|
||||
for exporter in ("gendesign", "tradein", "infra"):
|
||||
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
|
||||
|
||||
|
||||
def test_gate_would_notice_a_mount_without_arrival() -> None:
|
||||
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
|
||||
commands = _commands("server")
|
||||
assert not _arrival(
|
||||
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
|
||||
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"
|
||||
|
|
@ -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"
|
||||
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
|
||||
|
||||
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
|
||||
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
|
||||
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
|
||||
|
||||
Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation
|
||||
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF:
|
||||
1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1»
|
||||
(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен.
|
||||
|
||||
Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main
|
||||
неверным значением (None вместо номера), а не ошибкой сигнатуры.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
|
||||
from app.services.scrapers.page_reservation_parser import extract_reservations
|
||||
from tests.services.scrapers.test_2464_act_date_citation import (
|
||||
ДОК_ПЕРЕМЕШАННЫЙ,
|
||||
ДОК_ПЕРЕМЕШАННЫЙ_2,
|
||||
ДОК_ПРЯМОЙ,
|
||||
ДОК_РАЗВЯЗКА,
|
||||
ДОК_ЭНЕРГОСТРОИТЕЛЕЙ,
|
||||
ЗАГОЛОВОК,
|
||||
КАД,
|
||||
)
|
||||
|
||||
ПЯТЬ_ДОКУМЕНТОВ = [
|
||||
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
|
||||
pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
|
||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
|
||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
|
||||
pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"),
|
||||
]
|
||||
|
||||
|
||||
def _запись(текст: str) -> dict:
|
||||
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
|
||||
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
|
||||
return записи[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
|
||||
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
|
||||
чья дата стоит в act_date, — а не Решения Думы или приказа Министерства."""
|
||||
запись = _запись(текст)
|
||||
assert запись["act_number"] == номер
|
||||
assert запись["act_date"] == дата
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None:
|
||||
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
|
||||
|
||||
Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и
|
||||
«№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
|
||||
"""
|
||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
|
||||
|
||||
|
||||
def test_citations_alone_give_no_number() -> None:
|
||||
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
|
||||
текст = (
|
||||
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
|
||||
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
|
||||
"области от 30.12.2021 № 746-П"
|
||||
)
|
||||
assert _запись(текст)["act_number"] is None
|
||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
|
||||
|
||||
|
||||
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
|
||||
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
|
||||
|
||||
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
|
||||
Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413:
|
||||
в строке оказалась бы пара реквизитов от двух разных документов.
|
||||
"""
|
||||
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
|
||||
запись = _запись(текст)
|
||||
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
|
||||
|
||||
|
||||
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
|
||||
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
|
||||
следующего «№ …-ПП» — номера цитируемого постановления Правительства."""
|
||||
текст = (
|
||||
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
|
||||
"В соответствии с постановлением Правительства Свердловской области "
|
||||
f"от 26.02.2020 № 100-ПП\n{КАД}"
|
||||
)
|
||||
assert extract_reservations(текст)[0].act_number == "1234"
|
||||
|
||||
|
||||
def test_regional_suffix_still_extracted() -> None:
|
||||
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
|
||||
assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
|
||||
assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
|
||||
|
||||
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
||||
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
||||
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
|
||||
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
|
||||
медиане в 39 раз.
|
||||
|
||||
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
|
||||
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
|
||||
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
|
||||
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
|
||||
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
|
||||
на машине без базы, и объявлен в skip_allowlist.txt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
||||
return (
|
||||
raw
|
||||
if raw.startswith("postgresql+")
|
||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||
)
|
||||
|
||||
|
||||
def _postgis_reachable() -> tuple[bool, str]:
|
||||
try:
|
||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||
with eng.connect() as c:
|
||||
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
||||
return True, ""
|
||||
return False, "нет расширения postgis"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
_DB_OK, _DB_ERR = _postgis_reachable()
|
||||
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
"""CREATE TEMP TABLE domrf_kn_objects (
|
||||
obj_id bigint, comm_name text, dev_name text, obj_class text,
|
||||
obj_class_fallback text, latitude double precision, longitude double precision,
|
||||
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_complex_mapping (
|
||||
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE complexes (
|
||||
id bigint, canonical_name text, latitude double precision,
|
||||
longitude double precision) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE complex_sources (
|
||||
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_lots (
|
||||
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
|
||||
"""CREATE TEMP TABLE objective_corpus_room_month (
|
||||
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
|
||||
]
|
||||
|
||||
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
|
||||
|
||||
_DATA = [
|
||||
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
|
||||
"""INSERT INTO domrf_kn_objects VALUES
|
||||
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
|
||||
'Строящиеся', CURRENT_DATE),
|
||||
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
|
||||
CURRENT_DATE),
|
||||
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
|
||||
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
|
||||
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
|
||||
"""INSERT INTO complexes VALUES
|
||||
(10, 'ЖК Мичуринский', 56.840, 60.600),
|
||||
(20, 'ЖК VEER PARK', 56.850, 60.620),
|
||||
(30, 'СтудияПарк', 56.830, 60.580),
|
||||
(50, 'Квартал "Татлин"', 56.835, 60.610),
|
||||
(60, 'Парковый', 56.855, 60.590),
|
||||
(70, 'Роща', 56.825, 60.630),
|
||||
(71, 'Роща Парк', 56.8259, 60.630),
|
||||
(80, 'ЖК Сосны', 56.860, 60.650)""",
|
||||
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
||||
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
|
||||
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
|
||||
"""INSERT INTO complex_sources VALUES
|
||||
(10, 'objective', 'Мичуринский'),
|
||||
(20, 'objective', 'Clever Park'),
|
||||
(30, 'objective', 'Студия Парк'),
|
||||
(50, 'objective', 'Квартал Татлин'),
|
||||
(60, 'objective', 'Парковый квартал'),
|
||||
(70, 'objective', 'Роща'),
|
||||
(71, 'objective', 'Роща Парк'),
|
||||
(80, 'objective', 'Клён'),
|
||||
(80, 'objective', 'Сосны')""",
|
||||
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
|
||||
# complex_id; два своих лота новые — complex_id у них NULL.
|
||||
"""INSERT INTO objective_lots VALUES
|
||||
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
|
||||
('Мичуринский', NULL, 120000),
|
||||
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
|
||||
('Clever Park', NULL, 150000),
|
||||
('Студия Парк', NULL, 90000),
|
||||
('Явный', NULL, 200000),
|
||||
('Квартал Татлин', NULL, 95000),
|
||||
('Парковый квартал', NULL, 130000),
|
||||
('Роща Парк', NULL, 105000),
|
||||
('Клён', NULL, 250000),
|
||||
('Сосны', NULL, 115000)""",
|
||||
"""INSERT INTO objective_corpus_room_month VALUES
|
||||
('Мичуринский', date_trunc('month', now())::date, 30),
|
||||
('Малахит', date_trunc('month', now())::date, 300),
|
||||
('Clever Park', date_trunc('month', now())::date, 90),
|
||||
('Студия Парк', date_trunc('month', now())::date, 15),
|
||||
('Явный', date_trunc('month', now())::date, 60),
|
||||
('Квартал Татлин', date_trunc('month', now())::date, 18),
|
||||
('Парковый квартал', date_trunc('month', now())::date, 12),
|
||||
('Роща Парк', date_trunc('month', now())::date, 21),
|
||||
('Клён', date_trunc('month', now())::date, 240),
|
||||
('Сосны', date_trunc('month', now())::date, 27)""",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def result() -> tuple[dict[int, float], dict[int, float]]:
|
||||
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
|
||||
from app.services.site_finder.competitors import (
|
||||
_COMPETITORS_SQL,
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
_VELOCITY_MATCH_RADIUS_M,
|
||||
)
|
||||
|
||||
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||
try:
|
||||
for stmt in _SCHEMA + _DATA:
|
||||
session.execute(text(stmt))
|
||||
vel_rows = session.execute(
|
||||
_COMPETITORS_SQL,
|
||||
{
|
||||
"center_lon": 60.6,
|
||||
"center_lat": 56.84,
|
||||
"radius_m": 5000.0,
|
||||
"time_window_months": 3.0,
|
||||
"window_interval": "3 months",
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
).mappings()
|
||||
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
|
||||
price_rows = session.execute(
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||
{
|
||||
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
|
||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||
},
|
||||
).mappings()
|
||||
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
|
||||
return velocity, price
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
def test_gapfill_velocity_counts_only_own_project(result) -> None:
|
||||
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
|
||||
velocity, _ = result
|
||||
assert velocity[MICH] == pytest.approx(10.0), velocity
|
||||
|
||||
|
||||
def test_gapfill_price_is_median_of_own_lots(result) -> None:
|
||||
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
|
||||
_, price = result
|
||||
assert price[MICH] == pytest.approx(110000.0), price
|
||||
|
||||
|
||||
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
|
||||
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
|
||||
velocity, price = result
|
||||
assert velocity[VEER] == 0.0, velocity
|
||||
assert VEER not in price, price
|
||||
|
||||
|
||||
def test_space_difference_still_matches(result) -> None:
|
||||
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
|
||||
velocity, price = result
|
||||
assert velocity[STUDIO] == pytest.approx(5.0), velocity
|
||||
assert price[STUDIO] == pytest.approx(90000.0), price
|
||||
|
||||
|
||||
def test_explicit_mapping_unchanged(result) -> None:
|
||||
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
|
||||
velocity, price = result
|
||||
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
|
||||
assert price[EXPLICIT] == pytest.approx(200000.0), price
|
||||
|
||||
|
||||
def test_quotes_inside_name_still_match(result) -> None:
|
||||
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
|
||||
|
||||
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
|
||||
друга. 18 сделок / 3 = 6, цена 95 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[TATLIN] == pytest.approx(6.0), velocity
|
||||
assert price[TATLIN] == pytest.approx(95000.0), price
|
||||
|
||||
|
||||
def test_project_name_longer_than_object_name_matches(result) -> None:
|
||||
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
|
||||
velocity, price = result
|
||||
assert velocity[PARK] == pytest.approx(4.0), velocity
|
||||
assert price[PARK] == pytest.approx(130000.0), price
|
||||
|
||||
|
||||
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
|
||||
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
|
||||
|
||||
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[GROVE] == pytest.approx(7.0), velocity
|
||||
assert price[GROVE] == pytest.approx(105000.0), price
|
||||
|
||||
|
||||
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
|
||||
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
|
||||
|
||||
Без этого DISTINCT ON брал любой из двух (здесь — «Клён»), сверка его отвергала,
|
||||
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
|
||||
"""
|
||||
velocity, price = result
|
||||
assert velocity[PINES] == pytest.approx(9.0), velocity
|
||||
assert price[PINES] == pytest.approx(115000.0), price
|
||||
|
|
@ -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 ──────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,23 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
|
|||
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
|
||||
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
|
||||
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
||||
|
||||
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
|
||||
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
|
||||
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
|
||||
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
|
||||
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
|
||||
# только для машины без базы.
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
|
||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
|
||||
|
||||
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||
|
|
@ -176,3 +193,29 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
|
|||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
||||
|
||||
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
|
||||
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
|
||||
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
|
||||
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
|
||||
# локального прогона на Windows; чисто-логические проверки в тех же файлах
|
||||
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
|
||||
# исполняться локально.
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
|
||||
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
|
||||
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
|
||||
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
|
||||
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
|
||||
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
|
||||
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
|
||||
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
|
||||
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct
|
||||
|
|
|
|||
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Починка разбора номера не удваивает land_reservation (#2982).
|
||||
|
||||
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
|
||||
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
|
||||
строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон
|
||||
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
|
||||
|
||||
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
|
||||
формы: боевые данные не читаются и не меняются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
||||
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
|
||||
)
|
||||
return (
|
||||
raw
|
||||
if raw.startswith("postgresql+")
|
||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||
)
|
||||
|
||||
|
||||
def _db_reachable() -> tuple[bool, str]:
|
||||
try:
|
||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||
with eng.connect() as c:
|
||||
c.execute(text("SELECT 1"))
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
_DB_OK, _DB_ERR = _db_reachable()
|
||||
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
|
||||
|
||||
_TABLE = """
|
||||
CREATE TEMP TABLE land_reservation (
|
||||
id bigserial PRIMARY KEY,
|
||||
cad_num text NOT NULL,
|
||||
reservation_kind text NOT NULL,
|
||||
basis_act text NOT NULL,
|
||||
act_number text,
|
||||
act_date date,
|
||||
purpose text,
|
||||
doc_url text,
|
||||
source text NOT NULL DEFAULT 'page_pdf',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
raw_excerpt text,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
|
||||
) ON COMMIT DROP;
|
||||
"""
|
||||
|
||||
КАД = "66:41:0303004:22"
|
||||
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
|
||||
|
||||
|
||||
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
|
||||
return {
|
||||
"cad_num": cad,
|
||||
"reservation_kind": "изъятие",
|
||||
"basis_act": "Сообщение о планируемом изъятии",
|
||||
"act_number": act,
|
||||
"act_date": "2022-05-27",
|
||||
"purpose": None,
|
||||
"doc_url": url,
|
||||
"source": "izyatie_ekb_ocr",
|
||||
"raw_excerpt": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(_dsn())
|
||||
session = sessionmaker(bind=engine)()
|
||||
try:
|
||||
session.execute(text(_TABLE))
|
||||
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
||||
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _rows(db) -> list[tuple[str, str | None, str]]:
|
||||
return [
|
||||
(r[0], r[1], r[2])
|
||||
for r in db.execute(
|
||||
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
|
||||
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
|
||||
_upsert_records(db, [_row(act=None)])
|
||||
assert _rows(db) == [(КАД, None, ДОК)]
|
||||
|
||||
assert _upsert_records(db, [_row(act="1413")]) == 1
|
||||
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
|
||||
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
|
||||
|
||||
|
||||
def test_same_parcel_in_another_document_is_kept(db) -> None:
|
||||
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
|
||||
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
|
||||
_upsert_records(db, [_row(act="259", url=другой)])
|
||||
_upsert_records(db, [_row(act=None)])
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
|
||||
|
||||
|
||||
def test_other_parcels_of_same_document_are_kept(db) -> None:
|
||||
"""Контроль: чистка идёт по участку, а не по всему документу."""
|
||||
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
|
||||
_upsert_records(db, [_row(act="1413")])
|
||||
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]
|
||||
|
|
@ -15,10 +15,20 @@ from __future__ import annotations
|
|||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
|
||||
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
|
||||
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
|
||||
"POSIX-only, покрытие в CI (Linux)",
|
||||
)
|
||||
|
||||
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
||||
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
||||
|
||||
|
|
|
|||
257
backend/tests/test_poi_loader.py
Normal file
257
backend/tests/test_poi_loader.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
|
||||
|
||||
Mock-based / pure — БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
|
||||
к Overpass в тестах недопустимы). Покрывает:
|
||||
- REGION_BBOX / DEFAULT_REGION — дефолт остаётся "ekb", не меняется молча.
|
||||
- _bbox_tiles — для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы — несколько
|
||||
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
|
||||
- _build_overpass_query — bbox теперь параметр, а не глобальная константа.
|
||||
- _fetch_category — retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
|
||||
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
|
||||
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder.poi_loader import (
|
||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
|
||||
DEFAULT_REGION,
|
||||
EKB_BBOX,
|
||||
RECURSIVE_SPLIT_MAX_DEPTH,
|
||||
REGION_BBOX,
|
||||
OverpassUnreachableError,
|
||||
_bbox_tiles,
|
||||
_build_overpass_query,
|
||||
_fetch_category,
|
||||
_RunState,
|
||||
_split_bbox_quadrants,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
|
||||
класс отказа, ради которого тайл вообще осмысленно дробить."""
|
||||
|
||||
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
|
||||
self._ok = ok
|
||||
self._elements = elements or []
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if not self._ok:
|
||||
raise httpx.HTTPStatusError(
|
||||
"simulated Overpass overload",
|
||||
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
|
||||
response=httpx.Response(504),
|
||||
)
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"elements": self._elements}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
|
||||
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
|
||||
уровне рекурсии)."""
|
||||
|
||||
async def _instant(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
|
||||
|
||||
|
||||
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_default_region_is_ekb_unchanged() -> None:
|
||||
assert DEFAULT_REGION == "ekb"
|
||||
assert REGION_BBOX["ekb"] == EKB_BBOX
|
||||
|
||||
|
||||
def test_region_bbox_has_msk_product_core() -> None:
|
||||
assert "msk" in REGION_BBOX
|
||||
south, west, north, east = REGION_BBOX["msk"]
|
||||
assert south < north
|
||||
assert west < east
|
||||
|
||||
|
||||
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
|
||||
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
|
||||
tiles = _bbox_tiles(EKB_BBOX)
|
||||
assert tiles == [EKB_BBOX]
|
||||
|
||||
|
||||
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
|
||||
bbox = REGION_BBOX["msk"]
|
||||
tiles = _bbox_tiles(bbox)
|
||||
assert len(tiles) > 1
|
||||
south, west, north, east = bbox
|
||||
total_area = (north - south) * (east - west)
|
||||
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
|
||||
assert tiles_area == pytest.approx(total_area, rel=1e-9)
|
||||
|
||||
|
||||
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_split_bbox_quadrants_covers_original_area() -> None:
|
||||
bbox = (55.55, 37.30, 55.95, 37.90)
|
||||
quads = _split_bbox_quadrants(bbox)
|
||||
assert len(quads) == 4
|
||||
south, west, north, east = bbox
|
||||
total_area = (north - south) * (east - west)
|
||||
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
|
||||
assert quads_area == pytest.approx(total_area, rel=1e-9)
|
||||
|
||||
|
||||
# ── _build_overpass_query ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
|
||||
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
|
||||
assert "(1.0,2.0,3.0,4.0)" in q
|
||||
assert '["amenity"="pharmacy"]' in q
|
||||
|
||||
|
||||
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
|
||||
|
||||
|
||||
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _FakeResponse(ok=False)
|
||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
result = await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
|
||||
)
|
||||
assert calls["n"] == 2
|
||||
assert len(result) == 1
|
||||
assert result[0]["_gd_category"] == "pharmacy"
|
||||
|
||||
|
||||
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
|
||||
|
||||
|
||||
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
|
||||
instant_sleep: None,
|
||||
) -> None:
|
||||
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
query = data["data"]
|
||||
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
|
||||
return _FakeResponse(ok=False)
|
||||
return _FakeResponse(
|
||||
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
|
||||
)
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
result = await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
||||
)
|
||||
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
|
||||
assert calls["n"] == 2 + 4
|
||||
assert len(result) == 4
|
||||
|
||||
|
||||
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
|
||||
instant_sleep: None,
|
||||
) -> None:
|
||||
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
|
||||
и возвращает пустой список — не зацикливается и не падает."""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
assert data # параметр используется — сигнатура должна совпадать с client.post
|
||||
return _FakeResponse(ok=False)
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
result = await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
||||
)
|
||||
assert result == []
|
||||
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
|
||||
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
|
||||
assert calls["n"] == expected_nodes * 2
|
||||
|
||||
|
||||
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
|
||||
|
||||
|
||||
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
|
||||
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
|
||||
|
||||
Дробление тут превращает один отказ в 4, 16, 64 повторных стука — ровно это
|
||||
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
|
||||
"""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
assert data
|
||||
raise httpx.ConnectError("[Errno 101] Network is unreachable")
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
result = await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
||||
)
|
||||
assert result == []
|
||||
assert calls["n"] == 1 # ни retry, ни четвертей
|
||||
|
||||
|
||||
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
|
||||
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
assert data
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
state = _RunState()
|
||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
|
||||
assert (
|
||||
await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
||||
)
|
||||
== []
|
||||
)
|
||||
with pytest.raises(OverpassUnreachableError):
|
||||
await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
||||
)
|
||||
|
||||
|
||||
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
|
||||
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
assert data
|
||||
if calls["n"] % 2:
|
||||
raise httpx.ConnectError("hiccup")
|
||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
|
||||
|
||||
client = SimpleNamespace(post=fake_post)
|
||||
state = _RunState()
|
||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
|
||||
await _fetch_category(
|
||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
||||
)
|
||||
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS
|
||||
38
backend/tests/workers/test_beat_schedule_domrf_catalog.py
Normal file
38
backend/tests/workers/test_beat_schedule_domrf_catalog.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
|
||||
|
||||
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
|
||||
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
|
||||
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
|
||||
значению: включить запись = осознанно поправить этот тест после #3307.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
_BLOCKED_TASKS = (
|
||||
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
||||
)
|
||||
|
||||
|
||||
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
|
||||
from app.workers import beat_schedule
|
||||
|
||||
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
|
||||
schedule = beat_schedule.build_beat_schedule()
|
||||
|
||||
# Контроль: статическая часть расписания реально построилась, иначе
|
||||
# отсутствие задач ниже было бы зелёным по построению.
|
||||
assert schedule["refresh-quarter-price-index"]["task"] == (
|
||||
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
|
||||
)
|
||||
|
||||
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
|
||||
scheduled = {
|
||||
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
|
||||
}
|
||||
assert scheduled == {}, (
|
||||
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
|
||||
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
|
||||
)
|
||||
|
|
@ -184,10 +184,13 @@ gendsgn.ru {
|
|||
}
|
||||
|
||||
# Static HTML mockups для review (audit alternatives).
|
||||
# Public access — без auth (по запросу 2026-05-17).
|
||||
# Public access — без auth (по запросу 2026-05-17) — это намеренно и
|
||||
# менять НЕ надо. Но каталог не должен листингом отдавать список
|
||||
# файлов случайному посетителю: без `browse` file_server продолжает
|
||||
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
|
||||
handle_path /preview/* {
|
||||
root * /srv/preview
|
||||
file_server browse
|
||||
file_server
|
||||
}
|
||||
|
||||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||||
|
|
@ -400,6 +403,36 @@ www.gendsgn.ru {
|
|||
meraocenka.ru {
|
||||
encode zstd gzip
|
||||
|
||||
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
|
||||
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
|
||||
# применяет директивы в фиксированном порядке (`header` идёт раньше
|
||||
# `reverse_proxy`), не по строке в исходнике.
|
||||
#
|
||||
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
|
||||
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
|
||||
# выставляют ни один из этих заголовков — дублирования нет.
|
||||
header {
|
||||
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
|
||||
# preload — оба флага практически необратимы (браузеры кэшируют
|
||||
# preload-список надолго, includeSubDomains ломает любой будущий
|
||||
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
|
||||
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
|
||||
# осознанное решение, не «улучшение по умолчанию».
|
||||
Strict-Transport-Security "max-age=15552000"
|
||||
# Запрет встраивания во фрейм: X-Frame-Options — для старых
|
||||
# клиентов, которые не понимают CSP; frame-ancestors — современный
|
||||
# механизм. Оба вместе, не один вместо другого.
|
||||
X-Frame-Options "DENY"
|
||||
Content-Security-Policy "frame-ancestors 'none'"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Не даём стеку светиться в ответе. Полноценный CSP
|
||||
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
|
||||
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
|
||||
-Server
|
||||
-X-Powered-By
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/meraocenka.ru.log
|
||||
}
|
||||
|
|
@ -485,7 +518,7 @@ meraocenka.ru {
|
|||
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
||||
# robots.txt существует только в корне app/, не под mera-public/), эта
|
||||
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
|
||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
|
||||
handle @meraPages {
|
||||
rewrite * /trade-in/mera-public{path}
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
|
|
@ -516,7 +549,7 @@ meraocenka.ru {
|
|||
# переносит query как есть. Обёртка `route` обязательна по той же
|
||||
# причине, что у соседей — внутри `handle` порядок директив определяет
|
||||
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
|
||||
handle @meraShortSlash {
|
||||
route {
|
||||
uri strip_suffix /
|
||||
|
|
@ -604,7 +637,7 @@ meraocenka.ru {
|
|||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||
# (бесконечный цикл; поймано на локальном стенде).
|
||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||
handle @meraLongPages {
|
||||
route {
|
||||
uri strip_prefix /trade-in/mera-public
|
||||
|
|
|
|||
|
|
@ -56,6 +56,28 @@ errors.gendsgn.ru {
|
|||
git.gendsgn.ru {
|
||||
encode zstd gzip
|
||||
|
||||
# Генерация архивов закрыта — она съедала диск (16.09.2026).
|
||||
#
|
||||
# Forgejo на КАЖДЫЙ GET /<owner>/<repo>/archive/<ref>.{zip,tar.gz,bundle}
|
||||
# собирает снапшот репозитория заново (20-30 МБ, несколько секунд CPU) и
|
||||
# кладёт результат в кэш repo-archive. Штатная уборка archive_cleanup
|
||||
# работает, но раз в сутки — а внешний обходчик (73 из 78 обращений за 6ч
|
||||
# с одного адреса 216.73.216.17) дёргал архив для каждого коммита и держал
|
||||
# генерацию на ~100 файлах в час. Кэш вырос до 55 ГБ при диске 145 ГБ и ел
|
||||
# 2 ГБ/ч: до нуля оставалось ~12 часов.
|
||||
#
|
||||
# Ограничение скорости тут не лечит: даже один запрос в минуту — это
|
||||
# 30 МБ/мин новых файлов при суточной уборке. Нужен именно отказ.
|
||||
# На `git clone`, `git fetch` и работу CI это НЕ влияет: смарт-протокол
|
||||
# ходит по /<owner>/<repo>/info/refs и /git-upload-pack, другие пути.
|
||||
# Цена: анонимная кнопка «скачать архив» в вебе больше не работает —
|
||||
# сознательный размен, вернуть можно снятием этих двух правил.
|
||||
@repo_archive path_regexp repo_archive ^/[^/]+/[^/]+/archive/.+$
|
||||
respond @repo_archive "Архивы отключены: используйте git clone" 403
|
||||
|
||||
@api_archive path_regexp api_archive ^/api/v1/repos/[^/]+/[^/]+/archive/.+$
|
||||
respond @api_archive "Архивы отключены: используйте git clone" 403
|
||||
|
||||
reverse_proxy forgejo:3000
|
||||
|
||||
log {
|
||||
|
|
|
|||
|
|
@ -275,20 +275,26 @@ services:
|
|||
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
|
||||
# check-keys + что-то ещё для остальных двух чисел.
|
||||
#
|
||||
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
|
||||
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
|
||||
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
|
||||
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
|
||||
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
|
||||
# при расхождении — иначе алерты будут молча ничего не ловить.
|
||||
# ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#3493). Прежняя
|
||||
# запись `0.13.0` + `--queue` не существовала вовсе: такого тега нет на Docker
|
||||
# Hub (`pull` → `not found`), а в `src/cli.py` версии 0.12.2 опция зовётся
|
||||
# `--queues` (список через запятую), `--queue` click отвергает при старте.
|
||||
# Под `set -e` упавший `pull` обрывал ВСЮ джобу `agent-apps` деплоя метрик:
|
||||
# с 12.09 до этой правки на Poincare не доезжало ничего из агента — ни этот
|
||||
# экспортер, ни redis-exporter, ни новый конфиг Alloy, а NoActiveCeleryWorkers
|
||||
# горел по ветке absent() при живом воркере.
|
||||
#
|
||||
# Имена метрик 0.12.2 (src/exporter.py, metric_prefix="celery_"):
|
||||
# celery_worker_up{hostname}, celery_queue_length{queue_name},
|
||||
# celery_task_failed_total — совпадают с ops/metrics/prometheus/rules/infra.yml.
|
||||
celery-exporter:
|
||||
image: danihodovic/celery-exporter:0.13.0
|
||||
image: danihodovic/celery-exporter:0.12.2
|
||||
container_name: gendesign-celery-exporter
|
||||
restart: unless-stopped
|
||||
profiles: ["apps"]
|
||||
command:
|
||||
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
|
||||
- "--queue=celery"
|
||||
- "--queues=celery"
|
||||
expose:
|
||||
- "9808"
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -1919,17 +1919,17 @@ export interface paths {
|
|||
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||
*
|
||||
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
||||
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
||||
* объектов до next beat fire).
|
||||
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||
* Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||
* свежесозданных объектов).
|
||||
*
|
||||
* - max_objects=None → дефолтный лимит таска (300).
|
||||
* - max_objects=3 → smoke-тест.
|
||||
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||
*
|
||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||
* re-trigger без явного подтверждения оператора запрещён.
|
||||
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||
* запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||
*/
|
||||
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
||||
delete?: never;
|
||||
|
|
@ -1960,9 +1960,9 @@ export interface paths {
|
|||
* - max_flats=3 → smoke-тест.
|
||||
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||
*
|
||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||
* того же WAF-бана.
|
||||
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||
* блокировкой StormWall (условие — #3307).
|
||||
*/
|
||||
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
||||
delete?: never;
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
# script says so loudly instead of the silence that let backups break for
|
||||
# weeks undetected before (#71).
|
||||
#
|
||||
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
|
||||
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
|
||||
# Telegram once a backup is already known to be stale.
|
||||
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
|
||||
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
|
||||
#
|
||||
# Usage (cron — one line per sentinel, run more often than the backup itself
|
||||
# so a stale state is caught promptly; hourly is a reasonable default for a
|
||||
|
|
@ -27,9 +26,10 @@
|
|||
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
||||
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||
#
|
||||
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
|
||||
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
|
||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
|
||||
# Alert channel: notify() in ops/lib-backup.sh — creds in
|
||||
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
|
||||
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first
|
||||
# two, logs only.
|
||||
#
|
||||
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
||||
# used as a plain healthcheck by anything that just wants the exit code).
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@
|
|||
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
|
||||
|
||||
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
|
||||
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env
|
||||
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
|
||||
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
|
||||
# этом хосте нет, — алерты молча не уходят никуда.
|
||||
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||
|
||||
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
||||
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
||||
|
|
@ -33,11 +37,11 @@
|
|||
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
||||
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
||||
# чтобы три задачи не дрались за диск и сеть одновременно.
|
||||
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
||||
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
||||
|
||||
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
|
||||
# сутки плюс запас на сдвиг прогона.
|
||||
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||
|
||||
# ── Уборка докера ───────────────────────────────────────────────────────────
|
||||
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
||||
|
|
|
|||
|
|
@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY=
|
|||
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
||||
#TELEGRAM_BOT_TOKEN=
|
||||
#TELEGRAM_CHAT_ID=
|
||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||
#TELEGRAM_TOPIC_ID=245
|
||||
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
||||
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@
|
|||
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
||||
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
||||
|
||||
# --- оповещения (Telegram) ---
|
||||
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
|
||||
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
|
||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||
#TELEGRAM_CHAT_ID=123456789
|
||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||
#TELEGRAM_TOPIC_ID=245
|
||||
|
||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
||||
# the policy's Resource path exactly
|
||||
|
|
|
|||
|
|
@ -24,13 +24,8 @@
|
|||
#
|
||||
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
||||
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
|
||||
# /etc/default/gendesign-uptime — deliberately a separate config so backup
|
||||
# alerting doesn't depend on the uptime watchdog's env file existing).
|
||||
# SAME variable names as ops/gendesign-uptime.default.example — point both
|
||||
# files at the same bot/chat if you want one Telegram destination for
|
||||
# everything, or use different bots/chats if you'd rather split the noise.
|
||||
# Without these two set, ops/check-backup-staleness.sh still logs, just
|
||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
|
||||
# Without the first two set, ops/check-backup-staleness.sh still logs, just
|
||||
# doesn't send a Telegram alert.
|
||||
|
||||
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
||||
|
|
@ -41,9 +36,14 @@
|
|||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
||||
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
||||
|
||||
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
|
||||
# --- missed-run alerting (Telegram) ---
|
||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||
#TELEGRAM_CHAT_ID=123456789
|
||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||
#TELEGRAM_TOPIC_ID=245
|
||||
|
||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
|
||||
#
|
||||
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
|
||||
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
|
||||
# VPS outage):
|
||||
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
|
||||
# sudo chmod 600 /etc/default/gendesign-uptime
|
||||
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
|
||||
#
|
||||
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
|
||||
# it still logs up/down but sends no alert (useful for a dry run first).
|
||||
#
|
||||
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
|
||||
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
|
||||
|
||||
# --- Telegram alerting (both required to enable alerts) ---
|
||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||
#TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||
#BASE_URL=https://gendsgn.ru
|
||||
#STATE_FILE=/var/tmp/gendesign-uptime-state
|
||||
#CURL_TIMEOUT=15
|
||||
#RETRIES=2
|
||||
#RETRY_SLEEP=5
|
||||
# Custom check list (newline-separated "label|path|expected_status"):
|
||||
#CHECKS="health|/health|200
|
||||
#market-pulse|/api/v1/analytics/market-pulse|200"
|
||||
|
|
@ -5,9 +5,7 @@
|
|||
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
||||
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
||||
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
||||
# notify() when Telegram vars are actually set — curl is already a hard
|
||||
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
|
||||
# new dependency).
|
||||
# notify() when Telegram vars are actually set).
|
||||
#
|
||||
# Load with (script computes its own dir so this works regardless of cron's
|
||||
# CWD or which repo subdir the caller lives in):
|
||||
|
|
@ -18,14 +16,8 @@
|
|||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||
|
||||
# --- notify ------------------------------------------------------------
|
||||
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
|
||||
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
|
||||
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
|
||||
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
|
||||
# backup alerting doesn't depend on the uptime watchdog's config being
|
||||
# present, and vice versa. Point both files at the same bot/chat if you want
|
||||
# one Telegram destination for everything — that's an ops choice, not this
|
||||
# script's concern.
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
|
||||
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
|
||||
#
|
||||
# No-op (logs only) when unset — this is the extension point: to wire a
|
||||
# different channel later, edit ONLY this function; every caller in this repo
|
||||
|
|
@ -153,12 +145,9 @@ sentinel_age_hours() {
|
|||
}
|
||||
|
||||
# --- transition-tracked alert state --------------------------------------
|
||||
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
|
||||
# flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||
# A flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
||||
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
||||
# Intentionally a separate, independent implementation (not shared code with
|
||||
# uptime-healthcheck.sh) — that script is out of scope for this change.
|
||||
backup_prev_status() {
|
||||
local state_file="$1" label="$2" v
|
||||
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
||||
|
|
|
|||
|
|
@ -133,6 +133,29 @@
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Конверсия оценка → заявка, скользящие 7 суток",
|
||||
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
|
||||
"legendFormat": "заявки / оценки"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
|
||||
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -149,16 +149,30 @@ groups:
|
|||
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
|
||||
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
|
||||
# выбрасывает серии без лимита ДО деления.
|
||||
#
|
||||
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage − inactive_file,
|
||||
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
|
||||
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
|
||||
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
|
||||
# эпизодов (все 01:09–03:44 UTC) при анонимной памяти не выше 11.4 % лимита и
|
||||
# oom_kill = 0 — текст «дальше OOM-kill» был ложью. Калибровка этим самым
|
||||
# выражением по истории 14 суток: tradein-postgres 5 → 0, tradein-browser
|
||||
# 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие), остальные 0 → 0.
|
||||
# `and` без `on()`: у working_set и rss один и тот же набор меток cAdvisor,
|
||||
# совпадение проверено на проде (913 из 915 серий); слева по-прежнему доля.
|
||||
- alert: ContainerNearMemoryLimit
|
||||
expr: |
|
||||
container_memory_working_set_bytes{name!=""}
|
||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
||||
and
|
||||
(container_memory_rss{name!=""}
|
||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.50)
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Контейнер у своего потолка памяти"
|
||||
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
|
||||
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
|
||||
|
||||
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
|
||||
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
|
||||
|
|
@ -201,6 +215,40 @@ groups:
|
|||
- name: app
|
||||
interval: 60s
|
||||
rules:
|
||||
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
|
||||
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
|
||||
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
|
||||
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
|
||||
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
|
||||
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
|
||||
# молчат по построению.
|
||||
#
|
||||
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
|
||||
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
|
||||
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
|
||||
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
|
||||
# тихо пропала из alloy-apps.alloy.
|
||||
#
|
||||
# for: 5m — по истории up{app="mera"} 26.08–17.09 (61 857 точек): 140
|
||||
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
|
||||
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
|
||||
#
|
||||
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
|
||||
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
|
||||
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
|
||||
# заводится в UI GlitchTip, а не кодом.
|
||||
- alert: MeraBackendDown
|
||||
expr: |
|
||||
up{job="app", app="mera"} == 0
|
||||
or absent(up{job="app", app="mera"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
host: apps
|
||||
annotations:
|
||||
summary: "Бэкенд «Меры» не отвечает"
|
||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||
|
||||
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
||||
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
||||
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
||||
|
|
@ -240,11 +288,9 @@ groups:
|
|||
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
|
||||
# проставляется через external_labels уже на месте.
|
||||
#
|
||||
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
|
||||
# celery_task_failed_total — по документации celery-exporter на момент
|
||||
# написания правил, без проверки на реальном брокере (см. комментарий у
|
||||
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
|
||||
# первого деплоя.
|
||||
# Имена метрик celery_queue_length / celery_worker_up / celery_task_failed_total
|
||||
# сверены с исходником celery-exporter 0.12.2 (src/exporter.py) — см.
|
||||
# комментарий у сервиса в docker-compose.metrics-agent.yml (#3493).
|
||||
- name: redis-celery
|
||||
interval: 60s
|
||||
rules:
|
||||
|
|
@ -257,16 +303,41 @@ groups:
|
|||
summary: "Redis недоступен"
|
||||
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
|
||||
|
||||
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
|
||||
# серии celery_worker_up не будет вообще, а не будет со значением 0.
|
||||
# ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#3493). Прежнее
|
||||
# `count(celery_worker_up == 1) == 0 or absent(celery_worker_up)` смешивало
|
||||
# «воркер мёртв» с «экспортёр не поднят» под текстом первой: с 12.09 по 17.09
|
||||
# оно горело без перерыва по ветке absent() при живом воркере (экспортёр не
|
||||
# стартовал), повторяясь каждые ~3 часа. А первая ветка не сработала бы
|
||||
# НИКОГДА: `count()` от пустого вектора возвращает пустой вектор, а не 0,
|
||||
# поэтому `== 0` сравнивать не с чем — мёртвый воркер она бы пропустила.
|
||||
#
|
||||
# Здесь — только когда экспортёр ЖИВ (`up == 1`), но живых воркеров нет:
|
||||
# либо все серии в 0, либо серий нет (экспортёр вычищает отвалившихся).
|
||||
# Отсутствие самого экспортёра — QueueExporterDown ниже, со своим текстом.
|
||||
- alert: NoActiveCeleryWorkers
|
||||
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
|
||||
expr: |
|
||||
(sum(celery_worker_up) == 0 or absent(celery_worker_up))
|
||||
and on() (up{job="celery"} == 1)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Ни одного живого воркера Celery"
|
||||
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||
|
||||
# Экспортёр очереди не отдаёт метрики. `absent(up{job="X"})` переносит
|
||||
# job в метки результата, поэтому текст знает, какой из двух.
|
||||
- alert: QueueExporterDown
|
||||
expr: |
|
||||
up{job=~"redis|celery"} == 0
|
||||
or absent(up{job="redis"})
|
||||
or absent(up{job="celery"})
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Метрики очереди не собираются"
|
||||
description: "{{ $labels.job }}-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
|
||||
|
||||
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
|
||||
# этой правки метрика не собиралась). beat_schedule.py на момент правки
|
||||
|
|
|
|||
172
ops/metrics/prometheus/tests/infra_test.yml
Normal file
172
ops/metrics/prometheus/tests/infra_test.yml
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Юнит-тесты правил `promtool test rules` (#3493). Исполняются деплоем метрик
|
||||
# перед reload Prometheus (.forgejo/workflows/deploy-metrics.yml): упавший тест
|
||||
# оставляет работающий Prometheus на прежних правилах.
|
||||
#
|
||||
# Каждый случай — ровно тот, что уже случился на проде, а не придуманный.
|
||||
rule_files:
|
||||
- ../rules/infra.yml
|
||||
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# tradein-postgres, ночь 17.09: рабочий набор 98 % от лимита, но почти весь —
|
||||
# кэш страниц; анонимная память ~1 %. OOM не грозит — тревоги быть не должно.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-postgres"}'
|
||||
values: '2940x40'
|
||||
- series: 'container_memory_rss{host="apps",name="tradein-postgres"}'
|
||||
values: '40x40'
|
||||
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-postgres"}'
|
||||
values: '3000x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: ContainerNearMemoryLimit
|
||||
exp_alerts: []
|
||||
|
||||
# tradein-browser, 12.09: у потолка, и это анонимная память незакрытых
|
||||
# инстансов браузера. Тревога обязана прийти, и в тексте — доля, а не байты.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-browser"}'
|
||||
values: '1960x40'
|
||||
- series: 'container_memory_rss{host="apps",name="tradein-browser"}'
|
||||
values: '1900x40'
|
||||
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-browser"}'
|
||||
values: '2000x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: ContainerNearMemoryLimit
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
host: apps
|
||||
name: tradein-browser
|
||||
exp_annotations:
|
||||
summary: "Контейнер у своего потолка памяти"
|
||||
description: "apps / tradein-browser: 98% от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
|
||||
|
||||
# 12.09–17.09: экспортёр Celery не поднят, серий нет вовсе, воркер при этом жив.
|
||||
# Должна гореть QueueExporterDown про celery — и НЕ должна NoActiveCeleryWorkers.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="redis",host="apps"}'
|
||||
values: '1x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: NoActiveCeleryWorkers
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: QueueExporterDown
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
job: celery
|
||||
exp_annotations:
|
||||
summary: "Метрики очереди не собираются"
|
||||
description: "celery-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
|
||||
|
||||
# Нормальная работа: экспортёр жив, воркер шлёт heartbeat — тишина.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="celery",host="apps"}'
|
||||
values: '1x40'
|
||||
- series: 'up{job="redis",host="apps"}'
|
||||
values: '1x40'
|
||||
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
|
||||
values: '1x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: NoActiveCeleryWorkers
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: QueueExporterDown
|
||||
exp_alerts: []
|
||||
|
||||
# Воркер умер: экспортёр жив, серия воркера в 0. Прежнее правило
|
||||
# (`count(x == 1) == 0`) этот случай пропускало — count() от пустого вектора пуст.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="celery",host="apps"}'
|
||||
values: '1x40'
|
||||
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
|
||||
values: '0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: NoActiveCeleryWorkers
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
exp_annotations:
|
||||
summary: "Ни одного живого воркера Celery"
|
||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||
|
||||
# Воркер умер давно: экспортёр вычистил его серию, осталось только `up`.
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'up{job="celery",host="apps"}'
|
||||
values: '1x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: NoActiveCeleryWorkers
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
exp_annotations:
|
||||
summary: "Ни одного живого воркера Celery"
|
||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||
|
||||
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
|
||||
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
|
||||
- interval: 30s
|
||||
input_series:
|
||||
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
||||
values: '1x9 0x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 15m
|
||||
alertname: MeraBackendDown
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
host: apps
|
||||
job: app
|
||||
app: mera
|
||||
instance: tradein-backend:8000
|
||||
exp_annotations:
|
||||
summary: "Бэкенд «Меры» не отвечает"
|
||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||
|
||||
# Окно деплоя: самый длинный провал за 26.08–17.09 — 4 нулевые точки подряд
|
||||
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
|
||||
# поднимать тревогу про «Меру».
|
||||
- interval: 30s
|
||||
input_series:
|
||||
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
||||
values: '1x19 0 0 0 0 1x40'
|
||||
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
||||
values: '0x64'
|
||||
alert_rule_test:
|
||||
- eval_time: 11m
|
||||
alertname: MeraBackendDown
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: MeraBackendDown
|
||||
exp_alerts: []
|
||||
|
||||
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
|
||||
- interval: 30s
|
||||
input_series:
|
||||
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
||||
values: '1x40'
|
||||
alert_rule_test:
|
||||
- eval_time: 10m
|
||||
alertname: MeraBackendDown
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: critical
|
||||
host: apps
|
||||
job: app
|
||||
app: mera
|
||||
exp_annotations:
|
||||
summary: "Бэкенд «Меры» не отвечает"
|
||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||
65
ops/metrics/recreate-stale-mount.sh
Executable file
65
ops/metrics/recreate-stale-mount.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/sh
|
||||
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
|
||||
# (#3467, #3486).
|
||||
#
|
||||
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
|
||||
#
|
||||
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
|
||||
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
|
||||
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
|
||||
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
|
||||
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
|
||||
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
|
||||
# расхождении, чтобы не рвать работу на каждом деплое.
|
||||
#
|
||||
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
|
||||
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
|
||||
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
|
||||
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
|
||||
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
|
||||
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
|
||||
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 5 ]; then
|
||||
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
|
||||
exit 2
|
||||
fi
|
||||
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
|
||||
# «контейнер не запущен» — и зелёный выход со старым конфигом.
|
||||
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
|
||||
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
|
||||
|
||||
id=$(cid)
|
||||
if [ -z "$id" ]; then
|
||||
echo "$service: контейнер не запущен — старый инод $src держать некому."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
want=$(stat -c %i "$src")
|
||||
if [ "$(seen "$id")" = "$want" ]; then
|
||||
echo "$service: $src доехал до контейнера (инод $want)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
|
||||
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
id=$(cid)
|
||||
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
|
||||
echo "$service: пересоздан, $src доехал (инод $want)."
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
|
||||
exit 1
|
||||
|
|
@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
|
|||
#
|
||||
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
||||
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
||||
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
|
||||
# ops/lib-backup.sh (уведомления о бэкапах).
|
||||
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
|
||||
# мы узнали бы о любой другой поломке.
|
||||
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
|
||||
#
|
||||
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
|
||||
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
|
||||
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
|
||||
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
|
||||
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
|
||||
# this covers the case Kuma structurally can't.
|
||||
#
|
||||
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
|
||||
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
|
||||
# (up→down, down→up) — not every run — to avoid Telegram spam.
|
||||
#
|
||||
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
|
||||
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
|
||||
#
|
||||
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# TELEGRAM_CHAT_ID=123456789
|
||||
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
|
||||
# A redacted template lives at ops/gendesign-uptime.default.example.
|
||||
# Without a token set, the script still logs up/down but sends no alert.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- config (env-overridable) ---
|
||||
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
|
||||
# shellcheck source=/dev/null
|
||||
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
|
||||
|
||||
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
|
||||
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
|
||||
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
|
||||
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
|
||||
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||
|
||||
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
|
||||
# Override the whole list via CHECKS env (same newline-separated format).
|
||||
#
|
||||
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
|
||||
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
|
||||
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
|
||||
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
|
||||
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
|
||||
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
|
||||
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
|
||||
CHECKS="${CHECKS:-health|/health|200}"
|
||||
|
||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||
|
||||
# --- telegram (no-op if token/chat unset) ---
|
||||
notify() {
|
||||
local text="$1"
|
||||
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
|
||||
log "NOTIFY (telegram disabled — no token/chat): $text"
|
||||
return 0
|
||||
fi
|
||||
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
|
||||
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
|
||||
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
|
||||
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
|
||||
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
|
||||
#
|
||||
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
|
||||
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
|
||||
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
|
||||
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
|
||||
# тем вероятнее, что о них не сообщат.
|
||||
#
|
||||
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
|
||||
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
|
||||
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
|
||||
# применяется к КАЖДОЙ попытке.
|
||||
#
|
||||
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
|
||||
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
|
||||
# запроса, так что повтор почти никогда не дублирует уже доставленное
|
||||
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
if curl -fsS --max-time "$CURL_TIMEOUT" \
|
||||
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
|
||||
-d "disable_web_page_preview=true" \
|
||||
--data-urlencode "text=${text}" \
|
||||
>/dev/null 2>&1; then
|
||||
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
|
||||
done
|
||||
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
|
||||
}
|
||||
|
||||
# --- state helpers (last status per check) ---
|
||||
prev_status() {
|
||||
local label="$1"
|
||||
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
|
||||
# Line format: "<label> <status>". grep the latest for this label.
|
||||
local v
|
||||
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
|
||||
echo "${v:-unknown}"
|
||||
}
|
||||
|
||||
set_status() {
|
||||
local label="$1" status="$2" tmp
|
||||
tmp="$(mktemp)"
|
||||
# Drop any prior line for this label, then append the fresh one.
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
|
||||
fi
|
||||
echo "${label} ${status}" >> "$tmp"
|
||||
mv "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
|
||||
probe() {
|
||||
local url="$1" expect="$2" attempt code
|
||||
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
|
||||
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
|
||||
# never need a `|| echo` fallback (which previously concatenated → "401000").
|
||||
for attempt in $(seq 1 "$((RETRIES + 1))"); do
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
|
||||
code="${code:-000}"
|
||||
if [[ "$code" == "$expect" ]]; then
|
||||
echo "up"
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
|
||||
done
|
||||
# Report the last code seen for the alert body.
|
||||
echo "down code=${code}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- run ---
|
||||
overall_rc=0
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
label="${line%%|*}"
|
||||
rest="${line#*|}"
|
||||
path="${rest%%|*}"
|
||||
expect="${rest##*|}"
|
||||
url="${BASE_URL}${path}"
|
||||
|
||||
result="$(probe "$url" "$expect")"
|
||||
now="up"
|
||||
[[ "$result" == up ]] || now="down"
|
||||
prev="$(prev_status "$label")"
|
||||
|
||||
if [[ "$now" == "up" ]]; then
|
||||
log "OK $label ($url)"
|
||||
if [[ "$prev" == "down" ]]; then
|
||||
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
|
||||
fi
|
||||
else
|
||||
overall_rc=1
|
||||
log "DOWN $label ($url) — $result"
|
||||
# Alert on transition into down (or first-ever observation that is down).
|
||||
if [[ "$prev" != "down" ]]; then
|
||||
notify "🔴 DOWN: gendesign $label — ${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
fi
|
||||
fi
|
||||
set_status "$label" "$now"
|
||||
done <<< "$CHECKS"
|
||||
|
||||
exit "$overall_rc"
|
||||
|
|
@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
|
|||
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
|
||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -516,16 +517,13 @@ async def cian_auto_login(
|
|||
)
|
||||
|
||||
try:
|
||||
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
|
||||
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
|
||||
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
|
||||
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
|
||||
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
|
||||
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
|
||||
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
|
||||
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
|
||||
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
|
||||
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
|
||||
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
|
||||
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
|
||||
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
|
||||
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
|
||||
async with build_browser_fetcher(
|
||||
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
|
||||
) as fetcher:
|
||||
raw_cookies = await fetcher.login(
|
||||
url=settings.cian_login_url,
|
||||
email=email,
|
||||
|
|
@ -537,6 +535,13 @@ async def cian_auto_login(
|
|||
pre_click_selectors=settings.cian_login_pre_click_selectors,
|
||||
wait_ms=settings.cian_login_wait_ms,
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
|
||||
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("cian auto-login failed: %s", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
|
|
@ -1252,8 +1257,11 @@ async def start_avito_city_sweep(
|
|||
request_delay_sec=payload.request_delay_sec,
|
||||
enrich_imv=payload.enrich_imv,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||
finally:
|
||||
sweep_db.close()
|
||||
|
||||
|
|
@ -1344,8 +1352,11 @@ async def start_cian_city_sweep(
|
|||
detail_top_n=payload.detail_top_n,
|
||||
enrich_houses=payload.enrich_houses,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||
finally:
|
||||
sweep_db.close()
|
||||
|
||||
|
|
@ -1487,8 +1498,11 @@ async def start_cian_full_load(
|
|||
resume_run_id=payload.resume_run_id,
|
||||
secondary_only=payload.secondary_only,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||
runs_mod.mark_crashed(task_db, run_id, exc)
|
||||
finally:
|
||||
task_db.close()
|
||||
|
||||
|
|
@ -1590,8 +1604,11 @@ async def start_yandex_full_load(
|
|||
concurrency=payload.concurrency,
|
||||
resume_run_id=payload.resume_run_id,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
|
||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||
runs_mod.mark_crashed(task_db, run_id, exc)
|
||||
finally:
|
||||
task_db.close()
|
||||
|
||||
|
|
@ -1658,8 +1675,11 @@ async def start_yandex_city_sweep(
|
|||
request_delay_sec=payload.request_delay_sec,
|
||||
enrich_address=payload.enrich_address,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||
finally:
|
||||
sweep_db.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
|
|
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
|
|||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
|
||||
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
|
||||
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
|
||||
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
|
||||
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
|
||||
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
|
||||
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
|
||||
_INTERACTIVE_SEND_DEADLINE_S = 7.0
|
||||
|
||||
|
||||
class GlitchTipAttachment(BaseModel):
|
||||
|
|
@ -234,6 +243,7 @@ async def glitchtip_webhook(
|
|||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||
client = get_telegram_client()
|
||||
try:
|
||||
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
|
|
@ -243,10 +253,11 @@ async def glitchtip_webhook(
|
|||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramError:
|
||||
except (TelegramError, TimeoutError):
|
||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
||||
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
|
||||
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
|
||||
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
|
||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@
|
|||
|
||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||
телефон + явное согласие на обработку персональных данных. Persist в
|
||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
||||
trade_in_leads.
|
||||
|
||||
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
|
||||
уходит сообщением в support-топик Telegram — тот же бот и топик, что у веб-чата
|
||||
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
|
||||
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
|
||||
Telegram не трогает ни ответ, ни лид — остаётся лог и пустой `notified_at` как
|
||||
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
|
||||
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id —
|
||||
телефон оператор берёт из trade_in_leads.
|
||||
|
||||
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||
|
|
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
|
|||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.support import _bot_configured
|
||||
from app.api.v1.trade_in import _assert_estimate_access
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.db import SessionLocal, get_db
|
||||
from app.observability.metrics import LEADS
|
||||
from app.services.tgbot.shared import get_telegram_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
|
|||
return value
|
||||
|
||||
|
||||
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
|
||||
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
|
||||
|
||||
Идёт после ответа клиенту, поэтому ретраи клиента — штатные воркерные, без
|
||||
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
|
||||
сессия запроса к этому моменту уже закрыта. Любой отказ — только в лог.
|
||||
"""
|
||||
message = (
|
||||
"Новая заявка на трейд-ин\n"
|
||||
f"id: {lead_id}\n"
|
||||
f"пользователь: {username or '—'}\n"
|
||||
f"оценка: {estimate_id or 'без привязки'}\n"
|
||||
"Телефон — в trade_in_leads по id."
|
||||
)
|
||||
try:
|
||||
await get_telegram_client().send_message(
|
||||
chat_id=settings.telegram_support_chat_id,
|
||||
text=message,
|
||||
message_thread_id=settings.telegram_support_topic_id or None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": lead_id},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/lead")
|
||||
async def create_trade_in_lead(
|
||||
payload: TradeInLeadInput,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
background_tasks: BackgroundTasks,
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||
|
|
@ -185,6 +232,15 @@ async def create_trade_in_lead(
|
|||
_CONSENT_POLICY_VERSION,
|
||||
)
|
||||
|
||||
if _bot_configured():
|
||||
background_tasks.add_task(
|
||||
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
|
||||
)
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from app.schemas.trade_in import (
|
|||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
ExposureWindow,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
HouseInfoForEstimate,
|
||||
|
|
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
|
|||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
|
|||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
reliability = :reliability,
|
||||
est_days_p25 = :est_days_p25,
|
||||
est_days_p50 = :est_days_p50,
|
||||
est_days_p75 = :est_days_p75,
|
||||
est_days_n = :est_days_n,
|
||||
revival_completed_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
|
|||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"reliability": result.reliability,
|
||||
"est_days_p25": w.p25_days if w else None,
|
||||
"est_days_p50": w.p50_days if w else None,
|
||||
"est_days_p75": w.p75_days if w else None,
|
||||
"est_days_n": w.n if w else None,
|
||||
},
|
||||
)
|
||||
db.execute(
|
||||
|
|
@ -678,7 +688,8 @@ def load_estimate(
|
|||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||
relaxations, reliability
|
||||
relaxations, reliability,
|
||||
est_days_p25, est_days_p50, est_days_p75, est_days_n
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
|
|
@ -718,6 +729,7 @@ def load_estimate(
|
|||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
analog_tier_from_explanation,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
|
|
@ -842,6 +854,19 @@ def load_estimate(
|
|||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||
# ответ AttributeError'ом.
|
||||
market_percentile=getattr(row, "market_percentile", None),
|
||||
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
|
||||
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
|
||||
exposure_window=(
|
||||
ExposureWindow(
|
||||
p25_days=row.est_days_p25,
|
||||
p50_days=row.est_days_p50,
|
||||
p75_days=row.est_days_p75,
|
||||
n=row.est_days_n,
|
||||
)
|
||||
if getattr(row, "est_days_n", None) is not None
|
||||
else None
|
||||
),
|
||||
est_days_on_market=getattr(row, "est_days_p50", None),
|
||||
period_months=12,
|
||||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
|
|
@ -896,6 +921,10 @@ def load_estimate(
|
|||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||
search_radius_m=search_radius_m,
|
||||
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
|
||||
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
|
||||
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
|
||||
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2844,6 +2873,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
|||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
|
||||
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
|
||||
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
|
||||
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
|
||||
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
|
||||
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
|
||||
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
|
||||
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
|
||||
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
|
||||
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
|
||||
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
|
||||
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
|
||||
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
|
||||
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
|
||||
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
|
||||
COVERAGE_MO_CITIES = (
|
||||
"Видное",
|
||||
"Балашиха",
|
||||
"Железнодорожный",
|
||||
"Люберцы",
|
||||
"Электросталь",
|
||||
"Химки",
|
||||
"Домодедово",
|
||||
"Ногинск",
|
||||
"Реутов",
|
||||
"Долгопрудный",
|
||||
"Красногорск",
|
||||
"Одинцово",
|
||||
"Подольск",
|
||||
"Сергиев Посад",
|
||||
"Королёв",
|
||||
"Мытищи",
|
||||
"Орехово-Зуево",
|
||||
"Серпухов",
|
||||
"Раменское",
|
||||
"Наро-Фоминск",
|
||||
"Щёлково",
|
||||
"Коломна",
|
||||
)
|
||||
|
||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||
|
|
@ -3040,6 +3109,7 @@ def _fold_city(name: str) -> str:
|
|||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
|
||||
**{
|
||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||
|
|
@ -3086,6 +3156,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|||
"Первоуральск": (56.9083, 59.9483),
|
||||
"Ревда": (56.7986, 59.9298),
|
||||
"Серов": (59.6047, 60.5772),
|
||||
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
|
||||
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
|
||||
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
|
||||
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
|
||||
# ближайшего центроида без порога, теперь у них есть и собственный порог).
|
||||
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
|
||||
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
|
||||
# координаты, что использует скрейпер (резолв Nominatim).
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Электросталь": (55.7845, 38.4449),
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Домодедово": (55.4368, 37.7681),
|
||||
"Ногинск": (55.8554, 38.4412),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Сергиев Посад": (56.3153, 38.1360),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Орехово-Зуево": (55.8062, 38.9810),
|
||||
"Серпухов": (54.9155, 37.4196),
|
||||
"Раменское": (55.5709, 38.2282),
|
||||
"Наро-Фоминск": (55.3843, 36.7235),
|
||||
"Щёлково": (55.9206, 37.9916),
|
||||
"Коломна": (55.0939, 38.7670),
|
||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||
# с областью и какова остаточная цена — см. большой комментарий над
|
||||
|
|
@ -3127,30 +3227,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|||
"Поварово": (56.0667, 37.0667),
|
||||
"Дедовск": (55.8672, 37.1200),
|
||||
"Нахабино": (55.8500, 37.1833),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Апрелевка": (55.5500, 37.0700),
|
||||
"Немчиновка": (55.7050, 37.3450),
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Лобня": (56.0100, 37.4750),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Котельники": (55.6553, 37.8619),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Дзержинский": (55.6294, 37.8500),
|
||||
"Развилка": (55.5842, 37.7392),
|
||||
"Климовск": (55.3667, 37.5333),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Истра": (55.9167, 36.8667),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Селятино": (55.5081, 36.9825),
|
||||
"Томилино": (55.6528, 37.9472),
|
||||
"Некрасовский": (56.0500, 37.5500),
|
||||
"Барвиха": (55.7333, 37.2333),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from sqlalchemy.exc import ArgumentError
|
|||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import DB_CONNECT_ARGS
|
||||
|
||||
|
||||
class AuthDatabaseNotConfiguredError(RuntimeError):
|
||||
|
|
@ -101,6 +102,18 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
|
|||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||
hide_parameters=True,
|
||||
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
|
||||
# потолок на одном движке и мина на втором это не починка, а половина.
|
||||
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
|
||||
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
|
||||
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
|
||||
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
|
||||
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
|
||||
# --workers) — включая `/health`.
|
||||
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
|
||||
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
|
||||
# же путь, что и любой другой сбой реестра, а не 500.
|
||||
connect_args=DB_CONNECT_ARGS,
|
||||
)
|
||||
except (ArgumentError, ValueError):
|
||||
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
|
|||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
||||
|
||||
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
|
||||
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
|
||||
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
|
||||
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
|
||||
CORRIDOR_CLAMP_MIN_N = 10
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
|
@ -378,36 +384,25 @@ class Settings(BaseSettings):
|
|||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
||||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
||||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
||||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой
|
||||
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и
|
||||
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка,
|
||||
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю
|
||||
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по
|
||||
# 77/50, то есть дефект продукта, а не регионального покрытия.
|
||||
# Флаг делает две вещи, обе откатываются одним переключателем:
|
||||
# 1) общегородской коридор больше не может стать headline (уличный — может:
|
||||
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
|
||||
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
|
||||
# выше);
|
||||
# 2) если headline так и не появился, ответ уходит честной карточкой
|
||||
# «недостаточно данных», а не нулями.
|
||||
# False ⇒ прежнее поведение целиком, без релиза.
|
||||
estimate_require_local_evidence: bool = True
|
||||
|
||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
||||
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
||||
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
||||
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
||||
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
||||
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
||||
estimate_sb_floor_sigma: float = 0.25
|
||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||
|
|
@ -417,6 +412,24 @@ class Settings(BaseSettings):
|
|||
# (проверено: 80.0% coverage на тех же 2366).
|
||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||
# ── региональные множители PI (замер 2026-09-16) ──────────────────────────
|
||||
# Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча
|
||||
# применялись ко всем регионам — прогон `--engine full --sample 2000
|
||||
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id
|
||||
# --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал,
|
||||
# что достигнутое покрытие «80%-й» вилки уезжает по региону:
|
||||
# регион n p10 p50 p90 факт. покрытие
|
||||
# 77 1969 0.448 1.043 1.440 71.2%
|
||||
# 50 1632 0.703 1.117 1.500 74.8%
|
||||
# 66 1360 0.649 1.143 1.584 66.2%
|
||||
# Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455,
|
||||
# 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла.
|
||||
# ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах —
|
||||
# точка expected_sold систематически занижена на 4-14%. Эта правка чинит
|
||||
# только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка
|
||||
# asking→sold на более коротком (свежем) окне — отдельная незакрытая задача.
|
||||
estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649}
|
||||
estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584}
|
||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||
# Диагноз: estimator систематически промахивается по эре дома + размеру —
|
||||
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
|
||||
|
|
@ -430,7 +443,27 @@ class Settings(BaseSettings):
|
|||
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
||||
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
||||
# OFF ⇒ точно старое поведение expected_sold.
|
||||
estimate_hedonic_correction_enabled: bool = True
|
||||
# ВЫКЛЮЧЕНА 2026-09-16. Замер на проде показал, что коррекция превратилась в
|
||||
# чистое занижение цены: она двигает точку вниз и НЕ покупает за это точности.
|
||||
# Контролируемый эксперимент — одна и та же выборка (--engine full --sample 2000
|
||||
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id), меняется
|
||||
# ровно эта переменная:
|
||||
# регион ВКЛ bias/MAPE/покрытие ВЫКЛ bias/MAPE/покрытие
|
||||
# 77 Москва -9.1 / 20.5 / 69.0 -4.1 / 19.7 / 71.2
|
||||
# 50 область -12.1 / 19.4 / 75.4 -10.4 / 19.2 / 74.8
|
||||
# 66 обл. целиком -17.1 / 23.0 / 66.7 -12.4 / 23.2 / 66.2
|
||||
# 66 только ЕКБ -17.4 / 21.2 / 69.8 -14.2 / 21.3 / 69.9
|
||||
# Последняя строка решающая: ЕКБ — ровно та выборка, на которой коэффициенты
|
||||
# перефитили 30.08.2026 (#3248, n=1269). Даже там выключение снимает 3.2 пункта
|
||||
# занижения при неизменных MAPE и покрытии. Механика понятна из самих
|
||||
# коэффициентов: b_larea=0, поэтому фактор = exp(-0.0140 + 0.0769*(year-2000)/20
|
||||
# - 0.0745*[floor==1]), и для дома 1980 года это 0.913, то есть -8.7%. Жилой фонд
|
||||
# в выборке преимущественно старый → фактор systematically < 1 → минус к цене.
|
||||
# Коэффициенты фитили на остатке log(actual_sold/expected_sold) ПРИ ТОЙ базе,
|
||||
# какая была тогда; база с тех пор уехала (area-бакетный ratio #2620, коэффициент
|
||||
# asking->sold пересчитан по регионам 15.09), а хедонику не пересчитывали.
|
||||
# ВКЛЮЧАТЬ ОБРАТНО только вместе с перефитом и замером всех четырёх строк выше.
|
||||
estimate_hedonic_correction_enabled: bool = False
|
||||
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
||||
#
|
||||
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
||||
|
|
@ -464,128 +497,13 @@ class Settings(BaseSettings):
|
|||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||
estimate_hedonic_factor_min: float = 0.75
|
||||
estimate_hedonic_factor_max: float = 1.30
|
||||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||||
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
|
||||
#
|
||||
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
|
||||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||
estimate_corridor_clamp_min_n: int = 10
|
||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
||||
estimate_corridor_clamp_slack: float = 0.40
|
||||
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
|
||||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
||||
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
||||
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
||||
# к point И range (expected_sold_low/high/price) консистентно.
|
||||
# False -> старое поведение без clamp (backward-compat).
|
||||
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
||||
estimate_expected_sold_le_asking: bool = Field(
|
||||
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
||||
)
|
||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
||||
estimate_sb_mad_k_small_n: float = 2.5
|
||||
estimate_sb_small_n_threshold: int = 10
|
||||
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
||||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
||||
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
|
||||
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
|
||||
estimate_outlier_small_n_threshold: int = 15
|
||||
estimate_outlier_tukey_k_small: float = 1.0
|
||||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
||||
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
||||
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||
estimate_price_trend_max_age_months: int = 6
|
||||
|
||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||
|
||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
|
||||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||
estimate_manual_review_elite_ppm2: int = 250000
|
||||
|
||||
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
|
||||
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
|
||||
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
|
||||
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
|
||||
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
|
||||
# building (building_cadastral_number | нормализованный address)
|
||||
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
|
||||
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
|
||||
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
|
||||
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
|
||||
# («лот считается один раз»).
|
||||
#
|
||||
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
|
||||
# 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
|
||||
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
|
||||
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
|
||||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||
estimate_dedup_analogs_enabled: bool = True
|
||||
|
||||
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||||
|
|
@ -654,6 +572,16 @@ class Settings(BaseSettings):
|
|||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||
estimate_unknown_attr_penalty_enabled: bool = False
|
||||
estimate_unknown_attr_penalty_min_n: int = 5
|
||||
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
|
||||
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
|
||||
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
|
||||
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
|
||||
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
|
||||
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
|
||||
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
|
||||
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
|
||||
estimate_tier_h_min_comps: int = 5
|
||||
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
|
||||
#
|
||||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||
|
|
@ -668,20 +596,6 @@ class Settings(BaseSettings):
|
|||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||||
estimate_is_apartments_filter_enabled: bool = False
|
||||
|
||||
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
|
||||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# disclosure в explanation. НЕ трогает point/median/range.
|
||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
|
||||
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
|
||||
estimate_wide_corridor_threshold: float = 1.2
|
||||
|
||||
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||||
|
|
@ -706,26 +620,7 @@ class Settings(BaseSettings):
|
|||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||
avito_imv_use_browser_fetcher: bool = False
|
||||
|
||||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
||||
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
|
||||
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
|
||||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||
estimate_quarter_index_min_n_deals: int = 10
|
||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||
# аналоги уже несут локацию квартала → skip (location in median).
|
||||
estimate_quarter_match_skip_ratio: float = 0.6
|
||||
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
|
||||
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
|
||||
estimate_quarter_index_max_for_small_n: float = 2.0
|
||||
estimate_quarter_index_small_n_threshold: int = 50
|
||||
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
|
||||
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
|
||||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||
estimate_quarter_index_factor_min: float = 0.6
|
||||
estimate_quarter_index_factor_max: float = 1.8
|
||||
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||
|
|
@ -904,6 +799,27 @@ class Settings(BaseSettings):
|
|||
# ENV: ASKING_RATIO_PPM2_MAX.
|
||||
asking_ratio_ppm2_max: int = 1_200_000
|
||||
|
||||
# Time-adjust для SOLD-стороны asking→sold ratio (rollback-флаг, без релиза).
|
||||
# ПРОБЛЕМА: числитель ratio (deals, trailing 12 months) — сырые цены сделок,
|
||||
# знаменатель (listings, LISTINGS_FRESH_DAYS) — сегодняшний рынок. На растущем
|
||||
# рынке медиана сделок системно отстаёт от медианы объявлений, т.е. ratio занижен
|
||||
# ровно на дрейф рынка за половину окна. Прод-замер 2026-09-16 (окно сделок
|
||||
# 2025-10..2026-04, ряд real_estate_deals своего региона, clamp 0.7/1.6):
|
||||
# регион 77 n=68 731 sold_median 323 529 → 360 330 (×1.1137, factor 1.077..1.147)
|
||||
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
||||
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
|
||||
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
||||
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
|
||||
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
||||
# При True каждая сделка домножается на sber-фактор приведения (тот же
|
||||
# ряд/карту, что estimator._SBER_REGION_SERIES, см. app.tasks.asking_to_sold_ratio)
|
||||
# к последнему доступному месяцу серии перед расчётом медианы. False — байт-в-байт
|
||||
# прежнее поведение (для быстрого отката без деплоя, сравнения до/после).
|
||||
# ENV: ASKING_RATIO_TIME_ADJUST_ENABLED.
|
||||
asking_ratio_time_adjust_enabled: bool = True
|
||||
|
||||
# SSRF-защита для admin scrape endpoints (#756).
|
||||
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
|
||||
# Относительные пути (без netloc) проходят без проверки — хост подставляется
|
||||
|
|
@ -1116,14 +1032,6 @@ class Settings(BaseSettings):
|
|||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
||||
|
||||
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
|
||||
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
|
||||
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
|
||||
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
|
||||
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
|
||||
# ENV: YANDEX_COOKIES_FILE.
|
||||
yandex_cookies_file: str | None = None
|
||||
|
||||
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||||
|
|
@ -1143,25 +1051,19 @@ class Settings(BaseSettings):
|
|||
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||
cian_login_wait_ms: int = 4000
|
||||
|
||||
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
||||
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
||||
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
||||
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
|
||||
# 1 коннект/запрос) развязывает прокси-аккаунты.
|
||||
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
|
||||
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
|
||||
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
|
||||
# True = curl_cffi через settings.scraper_proxy_url.
|
||||
#
|
||||
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
||||
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
||||
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
||||
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
||||
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
||||
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
||||
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
||||
# других окружений вслепую.
|
||||
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
|
||||
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
|
||||
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
|
||||
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
|
||||
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
|
||||
# environment'ом сервиса scraper, а tradein-backend (ручка админки
|
||||
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
|
||||
avito_detail_backfill_use_curl: bool = Field(
|
||||
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||
)
|
||||
|
||||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||
|
|
|
|||
|
|
@ -10,10 +10,59 @@ from app.core.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
|
||||
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
|
||||
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
|
||||
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
|
||||
# = длительность самого запроса, и задать её может только сервер.
|
||||
#
|
||||
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
|
||||
# честной работы, но остался конечным:
|
||||
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
|
||||
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
|
||||
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
|
||||
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
|
||||
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
|
||||
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
|
||||
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
|
||||
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
|
||||
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
|
||||
#
|
||||
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
|
||||
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
|
||||
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
|
||||
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
|
||||
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
|
||||
_STATEMENT_TIMEOUT_MS = 30_000
|
||||
|
||||
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
|
||||
# деградировать. 5 с — та же величина, что у миграций проекта
|
||||
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
|
||||
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
|
||||
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
|
||||
# считает, — statement_timeout тут только страховка от «считает вечно».
|
||||
_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
|
||||
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
|
||||
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
|
||||
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
|
||||
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
|
||||
# гарантированно, а не в редком случае.
|
||||
DB_CONNECT_ARGS = {
|
||||
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
|
||||
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
|
||||
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
|
||||
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
|
||||
# `SET LOCAL lock_timeout` и потолком не ограничен.
|
||||
connect_args=DB_CONNECT_ARGS,
|
||||
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
||||
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
||||
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||
|
|
|
|||
|
|
@ -41,17 +41,84 @@ def scrub_query_secrets(text: str) -> str:
|
|||
|
||||
|
||||
class QuerySecretFilter(logging.Filter):
|
||||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
|
||||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
|
||||
|
||||
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности — НЕ схлопывает
|
||||
их в единую строку через `record.getMessage()` с последующим `args = ()`.
|
||||
|
||||
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
|
||||
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
|
||||
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
|
||||
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
|
||||
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
|
||||
с `ValueError: not enough values to unpack (expected 5, got 0)` — сама попытка
|
||||
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
scrubbed = scrub_query_secrets(message)
|
||||
if scrubbed != message:
|
||||
record.msg = scrubbed
|
||||
record.args = ()
|
||||
args = record.args
|
||||
if isinstance(args, tuple) and args:
|
||||
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
|
||||
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
|
||||
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
|
||||
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
|
||||
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
|
||||
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
|
||||
# параметра прямо перед ним).
|
||||
scrubbed_args = tuple(
|
||||
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
|
||||
)
|
||||
if scrubbed_args != args:
|
||||
record.args = scrubbed_args
|
||||
elif isinstance(record.msg, str):
|
||||
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
|
||||
scrubbed_msg = scrub_query_secrets(record.msg)
|
||||
if scrubbed_msg != record.msg:
|
||||
record.msg = scrubbed_msg
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
|
||||
#
|
||||
# DaData на HTTP 403 возвращает диагностику вида:
|
||||
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
|
||||
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
|
||||
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
|
||||
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
|
||||
# она может измениться (см. generic-слой ниже).
|
||||
|
||||
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
|
||||
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
|
||||
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
|
||||
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
|
||||
|
||||
|
||||
def _mask_value(value: str) -> str:
|
||||
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
|
||||
if len(value) <= 4:
|
||||
return "***"
|
||||
return f"{value[:4]}…({len(value)})"
|
||||
|
||||
|
||||
def scrub_body_secrets(text: str | None) -> str:
|
||||
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
|
||||
|
||||
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
|
||||
2) generic — любая hex/base64-подобная последовательность 24+ символов, чтобы защита
|
||||
не зависела от того, как вендор сформулирует сообщение завтра.
|
||||
"""
|
||||
if not text:
|
||||
return text or ""
|
||||
|
||||
def _replace_quoted(m: re.Match[str]) -> str:
|
||||
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
|
||||
|
||||
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
|
||||
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
|
||||
return scrubbed
|
||||
|
||||
|
||||
def install_query_secret_filter(*logger_names: str) -> None:
|
||||
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
|
|||
|
||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
||||
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
|
||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
||||
"""
|
||||
|
||||
|
|
@ -163,9 +163,13 @@ class DkpCorridor(BaseModel):
|
|||
[low,high]×slack — добавляется текстовая пометка.
|
||||
None / count=0 если по улице нет сопоставимых сделок.
|
||||
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
|
||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
|
||||
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
|
||||
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
|
||||
из коридора, а поле обязано быть верным и на GET-rehydrate.
|
||||
"""
|
||||
|
||||
count: int # число ДКП-сделок в выборке
|
||||
|
|
@ -192,10 +196,13 @@ class DkpCorridor(BaseModel):
|
|||
def advisory_only(self) -> bool:
|
||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||
|
||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
||||
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
|
||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
||||
цену — зона n=1..9 на экране была неотличима от работающей.
|
||||
|
||||
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
|
||||
обе страховки выключены якорем, а не порогом (#3466).
|
||||
|
||||
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||
|
|
@ -207,9 +214,9 @@ class DkpCorridor(BaseModel):
|
|||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
||||
вторым числом.
|
||||
"""
|
||||
from app.core.config import settings # локально: schemas остаётся import-light
|
||||
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
|
||||
|
||||
return self.count < settings.estimate_corridor_clamp_min_n
|
||||
return self.count < CORRIDOR_CLAMP_MIN_N
|
||||
|
||||
|
||||
class PriceTrendPoint(BaseModel):
|
||||
|
|
@ -223,6 +230,21 @@ class PriceTrendPoint(BaseModel):
|
|||
ppm2: int # медиана ₽/м² за месяц
|
||||
|
||||
|
||||
class ExposureWindow(BaseModel):
|
||||
"""#2898: сколько дней похожие квартиры рядом висели до снятия объявления.
|
||||
|
||||
Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%,
|
||||
дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ
|
||||
объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы.
|
||||
Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`).
|
||||
"""
|
||||
|
||||
p25_days: int
|
||||
p50_days: int
|
||||
p75_days: int
|
||||
n: int # сколько снятых объявлений в выборке
|
||||
|
||||
|
||||
class AggregatedEstimate(BaseModel):
|
||||
estimate_id: UUID
|
||||
median_price_rub: int
|
||||
|
|
@ -285,7 +307,11 @@ class AggregatedEstimate(BaseModel):
|
|||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
last_scraped_at: datetime | None = None
|
||||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
||||
# #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число.
|
||||
exposure_window: ExposureWindow | None = None
|
||||
# Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше —
|
||||
# медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка).
|
||||
est_days_on_market: int | None = None
|
||||
cian_valuation: CianValuationSummary | None = None
|
||||
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
||||
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
||||
|
|
@ -344,6 +370,9 @@ class AggregatedEstimate(BaseModel):
|
|||
# "city" — radius Tier W: широкий fallback
|
||||
# null — нет данных / оценка не построена
|
||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
|
||||
# восстанавливаются из подписи якорного блока в confidence_explanation
|
||||
# (estimator.analog_tier_from_explanation), радиусные остаются null.
|
||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||
|
|
|
|||
|
|
@ -67,8 +67,12 @@ class BlockRatioBreaker:
|
|||
ratio_threshold: float
|
||||
safety_min: int
|
||||
snapshot_size: int
|
||||
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
|
||||
# площадки, рвут прогон. 0 — критерий выключен.
|
||||
infra_streak_limit: int = 0
|
||||
_window: deque[bool] = field(init=False, repr=False)
|
||||
_consecutive_blocks: int = field(default=0, init=False)
|
||||
_consecutive_infra: int = field(default=0, init=False)
|
||||
_pure_block_run: bool = field(default=True, init=False)
|
||||
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
||||
|
||||
|
|
@ -106,10 +110,16 @@ class BlockRatioBreaker:
|
|||
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
||||
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
||||
не считают.
|
||||
|
||||
Свой предохранитель у такого отказа — серия infra_streak_limit (#3439): без
|
||||
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
|
||||
1600, status='done'). Серию гасит любой ответ площадки — успех, её блок, 404.
|
||||
"""
|
||||
if kind != BAN_KIND_PLATFORM:
|
||||
self._consecutive_infra += 1
|
||||
self.record_failure()
|
||||
return
|
||||
self._consecutive_infra = 0
|
||||
self._consecutive_blocks += 1
|
||||
self._window.append(True)
|
||||
|
||||
|
|
@ -117,6 +127,7 @@ class BlockRatioBreaker:
|
|||
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
||||
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
||||
self._flush_streak()
|
||||
self._consecutive_infra = 0
|
||||
self._pure_block_run = False
|
||||
self._window.append(False)
|
||||
|
||||
|
|
@ -131,8 +142,11 @@ class BlockRatioBreaker:
|
|||
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
||||
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
||||
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
||||
задачи -- своя калибровка, свои ограничения прокси-пула)."""
|
||||
return
|
||||
задачи -- своя калибровка, свои ограничения прокси-пула).
|
||||
|
||||
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
|
||||
значит наш тракт жив."""
|
||||
self._consecutive_infra = 0
|
||||
|
||||
def _flush_streak(self) -> None:
|
||||
if self._consecutive_blocks:
|
||||
|
|
@ -142,9 +156,11 @@ class BlockRatioBreaker:
|
|||
def abort_reason(self) -> str | None:
|
||||
"""Какой критерий требует обрыва прямо сейчас, или None.
|
||||
|
||||
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
|
||||
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
||||
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
|
||||
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
||||
"""
|
||||
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
|
||||
return "infra_streak"
|
||||
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
|
||||
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
||||
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
||||
|
|
@ -176,6 +192,11 @@ class BlockRatioBreaker:
|
|||
should_abort(), так что в логи не попадает.
|
||||
"""
|
||||
reason = self.abort_reason()
|
||||
if reason == "infra_streak":
|
||||
return (
|
||||
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
|
||||
"без единого ответа площадки"
|
||||
)
|
||||
if reason == "ratio":
|
||||
return (
|
||||
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from typing import Any
|
|||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.log_scrub import scrub_body_secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -173,7 +174,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||
return None
|
||||
if status in (401, 403):
|
||||
body_preview = (response.text or "")[:200]
|
||||
body_preview = scrub_body_secrets(response.text)[:200]
|
||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||
|
|
@ -213,7 +214,7 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||
return None
|
||||
if status >= 400:
|
||||
body_preview = (response.text or "")[:200]
|
||||
body_preview = scrub_body_secrets(response.text)[:200]
|
||||
logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
|
||||
return None
|
||||
|
||||
|
|
@ -436,7 +437,7 @@ async def suggest_addresses(
|
|||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||
return []
|
||||
if status >= 400:
|
||||
body_preview = (response.text or "")[:200]
|
||||
body_preview = scrub_body_secrets(response.text)[:200]
|
||||
logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
|
||||
return []
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -46,6 +46,8 @@ from lxml import etree
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.regions import REGIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
|
||||
|
|
@ -520,6 +522,62 @@ def upsert_gar_houses(
|
|||
return upserted
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66).
|
||||
# Регионы принципиально разные по форме коллизии одноимённых улиц:
|
||||
# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был
|
||||
# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities —
|
||||
# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр
|
||||
# нужен и достаточен.
|
||||
# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]).
|
||||
# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников,
|
||||
# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей
|
||||
# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ
|
||||
# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL
|
||||
# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не
|
||||
# угадывается по max(flat_count).
|
||||
# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового
|
||||
# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра
|
||||
# берётся из REGIONS[<code>].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ
|
||||
# покрытия) — не второй раз хардкодится строкой "Екатеринбург".
|
||||
REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66})
|
||||
|
||||
|
||||
class CityFilterAutoType:
|
||||
"""Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "CITY_FILTER_AUTO"
|
||||
|
||||
|
||||
# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»).
|
||||
CITY_FILTER_AUTO = CityFilterAutoType()
|
||||
|
||||
|
||||
def default_city_filter_for_region(region_code: str | None) -> str | None:
|
||||
"""Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER).
|
||||
|
||||
region 66 → "Екатеринбург" (byte-for-byte прежнее поведение, значение из
|
||||
REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион → None
|
||||
(без ограничения). region_code принимает и None, и нечисловую строку — не
|
||||
ошибка, просто «не знаем региона» → без фильтра.
|
||||
"""
|
||||
try:
|
||||
code = int(region_code) if region_code is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if code is None or code not in REGIONS_REQUIRING_CITY_FILTER:
|
||||
return None
|
||||
region = REGIONS.get(code)
|
||||
if region is None:
|
||||
return None
|
||||
return region.city_token.capitalize()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Матчер ГАР → houses
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -529,17 +587,33 @@ def upsert_gar_houses(
|
|||
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
|
||||
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
|
||||
#
|
||||
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
|
||||
# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) —
|
||||
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
|
||||
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
|
||||
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
|
||||
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
|
||||
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
|
||||
# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение
|
||||
# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в
|
||||
# 4 городах region 66 даёт ложные коллизии.
|
||||
#
|
||||
# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города,
|
||||
# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы
|
||||
# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча
|
||||
# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому
|
||||
# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже
|
||||
# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity
|
||||
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
||||
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
|
||||
# повторный прогон не трогает уже совпавшие строки.
|
||||
#
|
||||
# #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_pick AS (
|
||||
SELECT DISTINCT ON (canon)
|
||||
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
|
||||
WITH gar_scope AS (
|
||||
SELECT
|
||||
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) <> ''
|
||||
|
|
@ -551,7 +625,21 @@ _MATCH_SQL = text(
|
|||
CAST(:city AS text) IS NULL
|
||||
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
|
||||
)
|
||||
ORDER BY canon, flat_count DESC, house_guid
|
||||
),
|
||||
gar_ranked AS (
|
||||
SELECT
|
||||
canon, house_guid, flat_count, region_code,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
|
||||
) AS rn,
|
||||
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
|
||||
FROM gar_scope
|
||||
),
|
||||
gar_pick AS (
|
||||
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)
|
||||
)
|
||||
UPDATE houses h
|
||||
SET gar_house_guid = gp.house_guid,
|
||||
|
|
@ -562,29 +650,59 @@ _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,
|
||||
*,
|
||||
region_code: str | None = None,
|
||||
city_filter: str | None = "Екатеринбург",
|
||||
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
||||
) -> int:
|
||||
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
|
||||
|
||||
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE —
|
||||
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
|
||||
за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно
|
||||
(plain UPDATE, IS DISTINCT FROM gate).
|
||||
city_filter: CITY_FILTER_AUTO (умолч.) → резолвится по region_code через
|
||||
default_city_filter_for_region (region 66 → «Екатеринбург», иначе None). Явный
|
||||
None отключает фильтр НЕЗАВИСИМО от региона; явная строка — ILIKE-override
|
||||
(любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта
|
||||
отдельно — см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов.
|
||||
Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
|
||||
"""
|
||||
if isinstance(city_filter, CityFilterAutoType):
|
||||
city_filter = default_city_filter_for_region(region_code)
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ _REGION_66 = _ALL_REGIONS[66]
|
|||
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
|
||||
# без записи здесь получают `marker=None` → cross-check пропускается
|
||||
# (fallback на bbox-only, прежнее поведение).
|
||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
|
||||
# 50 → "московская", а НЕ "москва": Nominatim отдаёт `state="Московская
|
||||
# область"` для области и `state="Москва"` для города. Маркер "московская" не
|
||||
# матчит "Москва" (подстроки нет) — результат внутри города Москвы, который
|
||||
# щедрый `bbox_region` области накрывает целиком, будет честно отвергнут.
|
||||
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва", 50: "московская"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -200,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 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||
|
||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||
|
|
@ -962,7 +967,12 @@ _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
|
|||
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
|
||||
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
|
||||
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
|
||||
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
|
||||
_DADATA_REGION_NAMES: dict[int, str] = {
|
||||
66: SVERDLOVSK_OBLAST_REGION,
|
||||
77: "Москва",
|
||||
# Без типа — DaData хранит `region="Московская"`, `region_type="обл"`.
|
||||
50: "Московская",
|
||||
}
|
||||
|
||||
|
||||
def _dadata_region_name(region_code: int) -> str:
|
||||
|
|
@ -1106,7 +1116,14 @@ async def _nominatim_query_city_aware(
|
|||
)
|
||||
if city_specified:
|
||||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||
# Город неизвестен — dual-query с суффиксом главного города региона
|
||||
# Город неизвестен. У региона БЕЗ города-ядра (`has_city_core=False`,
|
||||
# реестр регионов; на сегодня это 50 — Московская область) суффикс главного
|
||||
# города подставлять НЕЛЬЗЯ: «Луговая» есть и в Красногорске, и в Сабурово,
|
||||
# и суффикс уверенно притянет чужой город — ровно ловушка #2576, только
|
||||
# уровнем выше. Такому региону остаётся честный bare-запрос по viewbox.
|
||||
if not _ALL_REGIONS[region_code].has_city_core:
|
||||
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
|
||||
# Дальше — регион с ядром: dual-query с суффиксом главного города
|
||||
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
|
||||
default_city = _region_default_city(region_code)
|
||||
city_data = await _nominatim_query_multi(
|
||||
|
|
|
|||
|
|
@ -25,14 +25,17 @@ 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 cluster→keeper→re-point→carry-identity→delete
|
||||
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
|
||||
|
|
@ -45,10 +48,8 @@ TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-
|
|||
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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel):
|
|||
sample_size: int
|
||||
radius_m: int
|
||||
nearby_poi: list[NearbyPoi]
|
||||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена)
|
||||
poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена
|
||||
# ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице)
|
||||
|
||||
|
||||
def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||
|
|
@ -207,9 +208,27 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
|||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||
#
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||
# явно ограничил владелец продукта.
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона,
|
||||
# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию
|
||||
# location_index явно ограничил владелец продукта.
|
||||
#
|
||||
# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический
|
||||
# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 /
|
||||
# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77
|
||||
# лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана
|
||||
# 458 208) — примесь заниженная городскую медиану до 422 222 (−7.9%). У областных строк
|
||||
# `city IS NULL`, фильтровать по имени города нельзя — только по region_code.
|
||||
#
|
||||
# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за
|
||||
# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active,
|
||||
# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк
|
||||
# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них
|
||||
# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен —
|
||||
# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в
|
||||
# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не
|
||||
# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу
|
||||
# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская
|
||||
# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить.
|
||||
_MEDIAN_PPM2_LOCAL_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
|
|
@ -223,6 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
|||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND region_code = CAST(:region_code AS integer)
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -248,6 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
|||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND region_code = CAST(:region_code AS integer)
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -255,6 +276,17 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
|||
"""
|
||||
)
|
||||
|
||||
_POI_COUNT_IN_REGION_SQL = text(
|
||||
"""
|
||||
SELECT count(*) AS n
|
||||
FROM osm_poi_ekb_local
|
||||
WHERE lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
AND CAST(:bbox_east AS double precision)
|
||||
"""
|
||||
)
|
||||
|
||||
_NEAREST_POI_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
|
|
@ -293,6 +325,7 @@ def _local_median_ppm2(
|
|||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"region_code": region.code,
|
||||
"bbox_south": lat_min,
|
||||
"bbox_north": lat_max,
|
||||
"bbox_west": lon_min,
|
||||
|
|
@ -317,6 +350,7 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
|||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"region_code": region.code,
|
||||
"bbox_south": lat_min,
|
||||
"bbox_north": lat_max,
|
||||
"bbox_west": lon_min,
|
||||
|
|
@ -333,18 +367,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float |
|
|||
|
||||
|
||||
def _fetch_nearby_poi(
|
||||
db: Any, lat: float, lon: float, radius_m: int, top_n: int
|
||||
db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region
|
||||
) -> tuple[list[NearbyPoi], str]:
|
||||
"""Top-N POI поблизости — качественная справка «что рядом», не числовой показатель.
|
||||
|
||||
Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении)
|
||||
→ ([], "unavailable") вместо 500 или сфабрикованного списка.
|
||||
Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект
|
||||
(прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850
|
||||
строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус
|
||||
молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0
|
||||
POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core
|
||||
региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная
|
||||
задача; здесь только честная деградация тем же статусом "unavailable" (терминология
|
||||
не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131).
|
||||
"""
|
||||
total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
|
||||
if total == 0:
|
||||
lat_min, lat_max, lon_min, lon_max = region.bbox_product_core
|
||||
in_region = (
|
||||
db.execute(
|
||||
_POI_COUNT_IN_REGION_SQL,
|
||||
{
|
||||
"bbox_south": lat_min,
|
||||
"bbox_north": lat_max,
|
||||
"bbox_west": lon_min,
|
||||
"bbox_east": lon_max,
|
||||
},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
if in_region == 0:
|
||||
logger.warning(
|
||||
"location_index: osm_poi_ekb_local is empty (refresh job not yet run on this "
|
||||
"environment) — nearby_poi unavailable, no fabricated factors"
|
||||
"location_index: osm_poi_ekb_local has no rows in region=%s product core "
|
||||
"(empty or refresh job not yet run for this region) — nearby_poi unavailable, "
|
||||
"no fabricated factors",
|
||||
region.code,
|
||||
)
|
||||
return [], "unavailable"
|
||||
|
||||
|
|
@ -423,7 +477,7 @@ def compute_location_index(
|
|||
poi_status="unavailable",
|
||||
)
|
||||
|
||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n)
|
||||
nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region)
|
||||
|
||||
city_median, city_n = _citywide_median_ppm2(db, region)
|
||||
if city_median is None or city_n < MIN_SAMPLE_SIZE:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ from scraper_kit.orchestration.scheduler import (
|
|||
from scraper_kit.orchestration.scheduler import (
|
||||
_pick_resume as kit_pick_resume,
|
||||
)
|
||||
from scraper_kit.orchestration.scheduler import (
|
||||
_resolve_region_code as kit_resolve_region_code,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scraper_kit.orchestration.scheduler import SchedulerContext
|
||||
|
|
@ -236,6 +239,12 @@ async def _job_deactivate_stale(
|
|||
listing_source: str = params.get("listing_source", "avito")
|
||||
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
||||
segments: list[str] | None = params.get("segments")
|
||||
# Регионы снятия. None (ключа нет в default_params) -> дефолт самой джобы,
|
||||
# DEFAULT_DEACTIVATION_REGION_CODES=(66,): снимать устаревшее можно только там,
|
||||
# где идёт регулярный пересбор -- см. развёрнутый комментарий у константы в
|
||||
# deactivate_stale_avito.py. Расширять список ВМЕСТЕ с запуском сбора по
|
||||
# региону, ключом "region_codes" (jsonb-массив кодов) в строке расписания.
|
||||
region_codes: list[int] | None = params.get("region_codes")
|
||||
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
||||
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||
|
|
@ -275,6 +284,7 @@ async def _job_deactivate_stale(
|
|||
listing_source=listing_source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
region_codes=region_codes,
|
||||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
|
|
@ -325,6 +335,50 @@ async def _job_landing_stats(
|
|||
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
|
||||
|
||||
|
||||
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
|
||||
async def _job_landing_showcase_deals(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
"""Пересчёт витрины сделок публичного лэндинга (#3469).
|
||||
|
||||
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
|
||||
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
|
||||
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
|
||||
и решается так же: done/failed ставим здесь.
|
||||
|
||||
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
|
||||
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
|
||||
"""
|
||||
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
|
||||
|
||||
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
counters = await loop.run_in_executor(
|
||||
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
||||
)
|
||||
# #3511: пустая витрина — не успех. Результатного ключа кита
|
||||
# (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных
|
||||
# судит её по статусу, и `done` с written=0 гасил часы свежести так же,
|
||||
# как удачный прогон, — а страница тем временем теряла таблицу целиком.
|
||||
if not counters.get("written"):
|
||||
logger.error(
|
||||
"landing_showcase_deals run_id=%d: витрина пуста (written=0, "
|
||||
"considered=%s, eligible=%s) — прогон неуспешен",
|
||||
run_id,
|
||||
counters.get("considered"),
|
||||
counters.get("eligible"),
|
||||
)
|
||||
ctx.runs.mark_failed(
|
||||
db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters
|
||||
)
|
||||
else:
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception:
|
||||
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
||||
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
||||
|
||||
|
||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||
async def _job_sber_freshness_monitor(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -487,6 +541,7 @@ async def _job_domclick_city_sweep(
|
|||
rooms=params.get("rooms"),
|
||||
pages=int(params.get("pages_per_anchor", 5)),
|
||||
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
|
||||
region_code=kit_resolve_region_code(params),
|
||||
resume_run_id=kit_pick_resume(db, run_id),
|
||||
cookies=cookies,
|
||||
)
|
||||
|
|
@ -911,6 +966,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
|
||||
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
|
||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||
"yandex_newbuilding_sweep": Handler(
|
||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||
|
|
@ -968,6 +1024,12 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
),
|
||||
# Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше.
|
||||
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
||||
# Вне ЕКБ каждый регион получает СВОЙ source ("domclick_city_sweep_moskva"),
|
||||
# потому что scrape_schedules.source UNIQUE. Wildcard обязан стоять и здесь,
|
||||
# а не только в _default_kit_handlers: kit-native тело ходит БЕЗ кук сессии,
|
||||
# и московская строка молча резолвилась бы на версию без инъекции (#3264),
|
||||
# то есть на ту, что упирается в QRATOR. Ключ продукта перекрывает kit-native.
|
||||
"domclick_city_sweep_*": Handler(_job_domclick_city_sweep, "domclick_city_sweep_*"),
|
||||
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
|
||||
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
||||
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ reap_stale_leases) — она рассчитана на долгоживущие
|
|||
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
||||
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
||||
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
||||
и без мутаций.
|
||||
и без мутаций пула. Единственная запись — атрибуция прогона (`scrape_runs.proxy_id`, #3404),
|
||||
своей короткой сессией, см. `resolve_proxy_url`.
|
||||
|
||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
|
||||
|
|
@ -69,7 +70,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.config import settings as _settings
|
||||
from app.core.db import SessionLocal as _SessionLocal
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS, attribute_run_proxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -277,6 +278,21 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
|
|||
_safe_label(candidate.id, candidate.label, candidate.url),
|
||||
candidate.ban_count,
|
||||
)
|
||||
# #3404: прогоны на этом пути (yandex_detail_backfill, yandex_address_backfill,
|
||||
# curl-ветка avito_detail_backfill) lease не берут, а атрибуцию раньше писал только
|
||||
# acquire() — у yandex_detail_backfill proxy_id был NULL в 56 прогонах из 56.
|
||||
# Своя сессия, не db вызывающего: attribute_run_proxy коммитит, а на сбое
|
||||
# откатывает, db же — долгоживущая сессия прогона посреди работы (см.
|
||||
# _pick_candidate). Сбой атрибуции проглатывается внутри — выдачу не роняет.
|
||||
from scraper_kit.orchestration.run_context import current_run_id
|
||||
|
||||
run_id = current_run_id.get()
|
||||
if run_id is not None:
|
||||
attr_db = _SessionLocal()
|
||||
try:
|
||||
attribute_run_proxy(attr_db, run_id, candidate.id)
|
||||
finally:
|
||||
attr_db.close()
|
||||
return candidate.url
|
||||
|
||||
diag = _diagnose_no_candidate(db, source)
|
||||
|
|
|
|||
|
|
@ -275,12 +275,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||
чужой affinity (#2600).
|
||||
|
||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
|
||||
Fallback НЕ трогает последний пригодный узел выделенной (не-'any') affinity: если
|
||||
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
||||
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
||||
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
|
||||
enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
|
||||
affinity целиком.
|
||||
участвует в fallback, только если его affinity='any' ИЛИ у источника этой affinity
|
||||
без него останется ДРУГОЙ кандидат (EXISTS-подзапрос): узел той же affinity или
|
||||
'any', здоровый, с живой арендой порта и не забаненный этим источником (#3299 —
|
||||
до него 'any'-узлы не считались, и единственный выделенный узел не выдавался никому).
|
||||
|
||||
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
||||
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
||||
|
|
@ -401,17 +402,29 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
-- fallback увести последний реально рабочий узел выделенной
|
||||
-- affinity и обрушить её (два domclick-узла, один забанен
|
||||
-- domclick'ом → второй уходит под avito → domclick без прокси).
|
||||
--
|
||||
-- #3299: вопрос «останется ли у источника sp.provider_affinity
|
||||
-- хоть один кандидат без sp», а не «есть ли ВТОРОЙ узел той же
|
||||
-- привязки». 'any'-узлы основной запрос выдаёт выделенному
|
||||
-- источнику наравне, значит и backup'ом они считаются; прежний
|
||||
-- `= sp.provider_affinity` прятал единственный выделенный узел от
|
||||
-- всех. Здоровье и срок аренды — как в основном запросе.
|
||||
-- leased_by НЕ проверяем: аренда вернётся через минуты, а
|
||||
-- отказ из-за чужой аренды прятал бы узел при любом параллельном
|
||||
-- прогоне (тот же довод, что у защиты в mark_banned).
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxies AS other
|
||||
WHERE other.provider_affinity = sp.provider_affinity
|
||||
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
|
||||
AND other.enabled
|
||||
AND other.consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND (other.expires_at IS NULL OR other.expires_at > now())
|
||||
AND other.id <> sp.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b2
|
||||
WHERE b2.proxy_id = other.id
|
||||
AND b2.source = other.provider_affinity
|
||||
AND b2.source = sp.provider_affinity
|
||||
AND b2.banned_until > now()
|
||||
)
|
||||
)
|
||||
|
|
@ -446,9 +459,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
)
|
||||
db.commit()
|
||||
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
|
||||
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
|
||||
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
|
||||
# mid-run) — см. attribute_run_proxy docstring.
|
||||
# #3404: покрывает все пути АРЕНДЫ (curl — acquire на каждый вызов, браузер —
|
||||
# sticky lease на весь прогон, ре-acquire при ротации узла mid-run). Путь без
|
||||
# аренды (proxy_egress.resolve_proxy_url) пишет атрибуцию сам — см. docstring.
|
||||
attribute_run_proxy(db, run_id, proxy_id)
|
||||
if fallback_used:
|
||||
logger.warning(
|
||||
|
|
@ -486,11 +499,12 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
|
||||
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
|
||||
|
||||
Единственный писатель — `acquire()` сразу после выдачи lease'а: покрывает и
|
||||
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
|
||||
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
|
||||
`_LEASE_ROTATE_AFTER_FAILS`) — то есть смена узла ЗА прогон фиксируется сама,
|
||||
без отдельного вызова с чьей-либо стороны.
|
||||
Два писателя. `acquire()` сразу после выдачи lease'а: покрывает и curl-путь
|
||||
(acquire на каждый вызов), и браузерный sticky lease (один acquire на весь прогон),
|
||||
и ре-acquire при ротации узла mid-run (`browser_fetcher`,
|
||||
`_LEASE_ROTATE_AFTER_FAILS`) — то есть смена узла ЗА прогон фиксируется сама.
|
||||
И `proxy_egress.resolve_proxy_url` — egress без аренды (yandex_detail_backfill и
|
||||
др.); до этого у таких прогонов proxy_id оставался NULL (#3404, прод 17.09: 0 из 56).
|
||||
|
||||
`scrape_runs.proxy_id` — ПОСЛЕДНИЙ использованный узел (перезаписывается при
|
||||
каждой новой выдаче); полная цепочка узлов, если она менялась, — в
|
||||
|
|
@ -535,8 +549,7 @@ def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
|
|||
db.commit()
|
||||
if row is None:
|
||||
logger.debug(
|
||||
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
|
||||
"finalized?)",
|
||||
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already finalized?)",
|
||||
run_id,
|
||||
)
|
||||
except Exception:
|
||||
|
|
@ -983,6 +996,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
|
|||
WHERE sp.id <> CAST(:proxy_id AS bigint)
|
||||
AND sp.enabled
|
||||
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
||||
-- Как в acquire(): узел с истёкшей арендой порта остаётся enabled,
|
||||
-- но не выдаётся. Без этой строки он «спасал» источник, и бан уходил
|
||||
-- последнему живому узлу (ревью #3565 к #3299).
|
||||
AND (sp.expires_at IS NULL OR sp.expires_at > now())
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
|
|
@ -999,17 +1016,22 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
|
|||
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
|
||||
-- не считается (иначе защита сочла бы affinity живой, когда она
|
||||
-- уже нет).
|
||||
-- #3299: предикат backup'а — буква в букву как в fallback
|
||||
-- acquire() (там же обоснование): 'any'-узлы в счёт, здоровье
|
||||
-- и срок аренды проверяются.
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxies other
|
||||
WHERE other.provider_affinity = sp.provider_affinity
|
||||
WHERE other.provider_affinity IN (sp.provider_affinity, 'any')
|
||||
AND other.enabled
|
||||
AND other.consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND (other.expires_at IS NULL OR other.expires_at > now())
|
||||
AND other.id <> sp.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b2
|
||||
WHERE b2.proxy_id = other.id
|
||||
AND b2.source = other.provider_affinity
|
||||
AND b2.source = sp.provider_affinity
|
||||
AND b2.banned_until > now()
|
||||
)
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -10,13 +10,23 @@
|
|||
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
|
||||
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
|
||||
|
||||
#3052: `region_for_point` резолвит по НАСТОЯЩЕЙ границе региона (полигон
|
||||
OSM/Nominatim, `region_boundaries/boundaries.geojson.json`), не по
|
||||
прямоугольнику `bbox_region` — прямоугольники Москвы и области пересекались
|
||||
(Химки/Реутов/Котельники/Люберцы уходили в Москву), полигоны нет.
|
||||
|
||||
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
|
||||
geocoder / location_index / matching.normalize, циклы недопустимы).
|
||||
geocoder / location_index / matching.normalize, циклы недопустимы). json/
|
||||
pathlib — стандартная библиотека, листовость не нарушают.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
|
||||
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
|
||||
|
|
@ -61,6 +71,15 @@ class Region:
|
|||
Регион без тира должен деградировать ЯВНО (потребитель
|
||||
спрашивает unsupported_tier_reason и логирует/маркирует),
|
||||
а не молча считать дальше без источника.
|
||||
has_city_core — есть ли у региона ОДИН город-ядро, имя которого допустимо
|
||||
молча подставлять в запрос геокодера, когда город не назван
|
||||
(dual-query `_nominatim_query_city_aware`, ветка «город
|
||||
неизвестен»). True у 66/77 (Екатеринбург / Москва — там это
|
||||
majority-трафик). False у 50: у области 20 сопоставимых
|
||||
городов и ~970 населённых пунктов в сырье, подстановка
|
||||
«Красногорск» к «Сабурово, Луговая» — ровно та же ловушка
|
||||
одноимённых улиц, от которой отказались в #2576.
|
||||
|
||||
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
|
||||
строк, приходящих из источника без надёжного city-поля
|
||||
(Росреестр по Москве отдаёт муниципальный округ/поселение
|
||||
|
|
@ -82,6 +101,7 @@ class Region:
|
|||
cities: frozenset[str]
|
||||
enrichment_tiers: frozenset[str]
|
||||
canonical_city: str | None = None
|
||||
has_city_core: bool = True
|
||||
|
||||
|
||||
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
||||
|
|
@ -90,6 +110,213 @@ def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
|
|||
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|
||||
|
||||
|
||||
# ── #3052: настоящая граница региона (point-in-polygon вместо bbox) ─────────
|
||||
#
|
||||
# Источник — region_boundaries/boundaries.geojson.json: полигоны OSM/Nominatim
|
||||
# (см. поля _source/_license внутри файла, лицензия ODbL требует их хранить).
|
||||
# Парсим один раз на импорте модуля — 27 точек лишний JSON-parse на каждый
|
||||
# вызов region_for_point был бы дороже самого ray casting.
|
||||
#
|
||||
# ТОЧНОСТЬ (правка регрессии первой версии #3052). Полигоны берутся БЕЗ
|
||||
# упрощения (`polygon_threshold=0`, 42 755 вершин), а не с `0.005` (~500 м,
|
||||
# 2 388 вершин), как в первой версии. Полукилометровое упрощение спрямляет
|
||||
# ровно то, ради чего полигоны и заводились — изрезанную границу Москвы с
|
||||
# областью. Сверка резолва против сохранённого region_code по проду (277 582
|
||||
# объявления + 48 143 дома) на упрощённой геометрии дала: 69 точек НИ в одном
|
||||
# полигоне (агломерация → None → вызывающий код `trade_in.py` подставляет
|
||||
# DEFAULT_REGION_CODE, и адрес в Куркино считается по коэффициентам
|
||||
# Екатеринбурга), Митино/Косино-Ухтомский/Можайский вне Москвы (27 домов),
|
||||
# 731 объявление области внутри Москвы. Без упрощения этих спрямлений нет.
|
||||
#
|
||||
# Ring — одно кольцо GeoJSON (lon, lat) точек: внешний контур ИЛИ дыра.
|
||||
# Полигон/мультиполигон региона хранится как ПЛОСКИЙ список всех его колец
|
||||
# (для MultiPolygon 77 — кольца всех 10 частей вперемешку, у Polygon 50 —
|
||||
# внешнее кольцо + 9 дыр). Плоский список работает благодаря even-odd
|
||||
# правилу: точка внутри региона ⟺ она попадает внутрь НЕЧЁТНОГО числа колец
|
||||
# из списка. Это ОДНОВРЕМЕННО корректно обрабатывает дыры (Москва — дыра в
|
||||
# кольцах региона 50: попадание в кольцо-дыру снимает чётность, снятую
|
||||
# внешним кольцом) и непересекающиеся части мультиполигона (попадание ровно
|
||||
# в одно кольцо — нечётность не портится соседними частями, которые точка не
|
||||
# задевает) — без явного разделения "внешний контур минус дыры".
|
||||
Ring = list[tuple[float, float]]
|
||||
|
||||
_BOUNDARIES_PATH = Path(__file__).parent / "region_boundaries" / "boundaries.geojson.json"
|
||||
|
||||
|
||||
def _flatten_rings(geometry: dict[str, object]) -> list[Ring]:
|
||||
"""Все кольца геометрии (Polygon или MultiPolygon) одним плоским списком."""
|
||||
coords = geometry["coordinates"]
|
||||
if geometry["type"] == "Polygon":
|
||||
polygons = [coords]
|
||||
elif geometry["type"] == "MultiPolygon":
|
||||
polygons = coords # type: ignore[assignment]
|
||||
else:
|
||||
raise ValueError(f"неподдержанный тип геометрии: {geometry['type']}")
|
||||
rings: list[Ring] = []
|
||||
for polygon in polygons:
|
||||
for ring in polygon: # type: ignore[union-attr]
|
||||
rings.append([(pt[0], pt[1]) for pt in ring]) # type: ignore[index]
|
||||
return rings
|
||||
|
||||
|
||||
def _load_region_boundaries() -> dict[int, list[Ring]]:
|
||||
"""code региона → плоский список колец его полигона/мультиполигона."""
|
||||
with _BOUNDARIES_PATH.open(encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
return {
|
||||
int(code): _flatten_rings(entry["geometry"]) for code, entry in payload["regions"].items()
|
||||
}
|
||||
|
||||
|
||||
_REGION_BOUNDARIES: dict[int, list[Ring]] = _load_region_boundaries()
|
||||
|
||||
|
||||
class _IndexedRing(NamedTuple):
|
||||
"""Кольцо + предпосчёт, которым точная геометрия остаётся дешёвой.
|
||||
|
||||
Наивный ray casting линеен по числу вершин, а вершин после отказа от
|
||||
упрощения в 18 раз больше (42 755 против 2 388) — прямым перебором это
|
||||
десятки-сотни микросекунд на вызов. Два предпосчёта снимают почти всё:
|
||||
|
||||
lat_min/lat_max/lon_min/lon_max — bbox САМОГО кольца (не региона): у
|
||||
Москвы мультиполигон из 10 частей, у области внешний контур + 9 дыр,
|
||||
и точка почти всегда вне bbox большинства колец — такое кольцо
|
||||
отвергается четырьмя сравнениями вместо обхода вершин.
|
||||
band_h/bands — кольцо нарезано на горизонтальные полосы по широте; в
|
||||
полосе лежат ТОЛЬКО те рёбра, чей диапазон широт её задевает (ребро
|
||||
попадает во все свои полосы, поэтому пересечения не теряются).
|
||||
Луч ray casting горизонтален, значит пересекать его могут лишь рёбра
|
||||
своей полосы — обход сокращается с «все вершины кольца» до «рёбра
|
||||
одной полосы» (для внешнего контура области — 23 511 → максимум 724,
|
||||
типично десятки).
|
||||
points — те же вершины ОДНИМ объектом с _REGION_BOUNDARIES (не копия):
|
||||
нужны fallback'у по расстоянию до границы в region_for_point.
|
||||
"""
|
||||
|
||||
lat_min: float
|
||||
lat_max: float
|
||||
lon_min: float
|
||||
lon_max: float
|
||||
band_h: float
|
||||
bands: tuple[tuple[tuple[float, float, float, float], ...], ...]
|
||||
points: Ring
|
||||
|
||||
|
||||
# Целевая плотность полосы: ~4 вершины кольца на полосу. Мельче — полосы
|
||||
# начинают стоить памяти и промахов кэша больше, чем экономят на обходе;
|
||||
# крупнее — в полосу попадает слишком много рёбер. Потолок числа полос
|
||||
# ограничивает память на самых длинных кольцах (внешний контур области).
|
||||
_RING_BAND_DENSITY = 4
|
||||
_RING_MAX_BANDS = 1024
|
||||
|
||||
|
||||
def _index_ring(points: Ring) -> _IndexedRing:
|
||||
"""Посчитать bbox и полосы кольца (один раз на импорте модуля)."""
|
||||
lat_min = min(p[1] for p in points)
|
||||
lat_max = max(p[1] for p in points)
|
||||
lat_span = lat_max - lat_min
|
||||
n_bands = max(1, min(_RING_MAX_BANDS, len(points) // _RING_BAND_DENSITY))
|
||||
# Вырожденное по широте кольцо (lat_span == 0) в полосы не режется:
|
||||
# одна полоса, band_h произвольный ненулевой — делить на 0 нельзя.
|
||||
band_h = lat_span / n_bands if lat_span > 0.0 else 1.0
|
||||
buckets: list[list[tuple[float, float, float, float]]] = [[] for _ in range(n_bands)]
|
||||
x_prev, y_prev = points[-1]
|
||||
for x, y in points:
|
||||
edge = (x, y, x_prev, y_prev)
|
||||
lo = int((min(y, y_prev) - lat_min) / band_h)
|
||||
hi = int((max(y, y_prev) - lat_min) / band_h)
|
||||
lo = 0 if lo < 0 else min(lo, n_bands - 1)
|
||||
hi = 0 if hi < 0 else min(hi, n_bands - 1)
|
||||
for band in range(lo, hi + 1):
|
||||
buckets[band].append(edge)
|
||||
x_prev, y_prev = x, y
|
||||
return _IndexedRing(
|
||||
lat_min=lat_min,
|
||||
lat_max=lat_max,
|
||||
lon_min=min(p[0] for p in points),
|
||||
lon_max=max(p[0] for p in points),
|
||||
band_h=band_h,
|
||||
bands=tuple(tuple(b) for b in buckets),
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
_REGION_RING_INDEX: dict[int, tuple[_IndexedRing, ...]] = {
|
||||
code: tuple(_index_ring(ring) for ring in rings) for code, rings in _REGION_BOUNDARIES.items()
|
||||
}
|
||||
|
||||
|
||||
def _point_in_ring(ring: _IndexedRing, lon: float, lat: float) -> bool:
|
||||
"""PNPOLY (W. R. Franklin) ray casting по рёбрам ОДНОЙ полосы кольца."""
|
||||
lat_min, lat_max, lon_min, lon_max, band_h, bands, _points = ring
|
||||
if lat < lat_min or lat > lat_max or lon < lon_min or lon > lon_max:
|
||||
return False
|
||||
band = int((lat - lat_min) / band_h)
|
||||
if band >= len(bands): # lat == lat_max попадает ровно на верхнюю кромку
|
||||
band = len(bands) - 1
|
||||
inside = False
|
||||
for xi, yi, xj, yj in bands[band]:
|
||||
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def _point_in_region_polygon(code: int, lat: float, lon: float) -> bool:
|
||||
"""True если (lat, lon) внутри настоящей границы региона `code`.
|
||||
|
||||
even-odd по всем кольцам сразу (см. комментарий выше про плоский список).
|
||||
Региона без загруженной границы — граничит только по bbox (см. вызов в
|
||||
region_for_point), сюда такой код не попадает."""
|
||||
inside = False
|
||||
for ring in _REGION_RING_INDEX[code]:
|
||||
if _point_in_ring(ring, lon, lat):
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def _distance_to_boundary(code: int, lat: float, lon: float) -> float:
|
||||
"""Расстояние от точки до ближайшего ребра границы региона `code`.
|
||||
|
||||
В градусах широты (1° ≈ 111 км) по ПЛОСКОМУ приближению с поправкой
|
||||
cos(lat) на сжатие долготы: значение сравнивается только с таким же
|
||||
значением другого региона-кандидата на тех же ~десятках метров, где
|
||||
геодезия и плоскость расходятся на доли процента.
|
||||
|
||||
Путь редкий (только щель между полигонами соседних регионов, см.
|
||||
region_for_point), поэтому обход честный — по всем рёбрам, без полос.
|
||||
Стоимость срезается оценкой снизу по bbox кольца: кольцо, чей bbox уже
|
||||
дальше найденного минимума, не может дать ребра ближе.
|
||||
"""
|
||||
lat_scale = math.cos(math.radians(lat))
|
||||
px = lon * lat_scale
|
||||
best = math.inf
|
||||
ranked: list[tuple[float, _IndexedRing]] = []
|
||||
for ring in _REGION_RING_INDEX[code]:
|
||||
dx = max(ring.lon_min - lon, 0.0, lon - ring.lon_max) * lat_scale
|
||||
dy = max(ring.lat_min - lat, 0.0, lat - ring.lat_max)
|
||||
ranked.append((dx * dx + dy * dy, ring))
|
||||
for lower_bound, ring in sorted(ranked, key=lambda item: item[0]):
|
||||
if lower_bound >= best:
|
||||
break
|
||||
ax, ay = ring.points[-1]
|
||||
ax *= lat_scale
|
||||
for bx_raw, by in ring.points:
|
||||
bx = bx_raw * lat_scale
|
||||
dx, dy = bx - ax, by - ay
|
||||
if dx or dy:
|
||||
t = ((px - ax) * dx + (lat - ay) * dy) / (dx * dx + dy * dy)
|
||||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
||||
ex = ax + t * dx - px
|
||||
ey = ay + t * dy - lat
|
||||
else: # выродившееся в точку ребро (дубль вершины в OSM)
|
||||
ex, ey = ax - px, ay - lat
|
||||
dist = ex * ex + ey * ey
|
||||
if dist < best:
|
||||
best = dist
|
||||
ax, ay = bx, by
|
||||
return math.sqrt(best)
|
||||
|
||||
|
||||
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
|
||||
TIER_AVITO_IMV = "avito_imv"
|
||||
TIER_YANDEX_VALUATION = "yandex_valuation"
|
||||
|
|
@ -233,14 +460,20 @@ REGIONS: dict[int, Region] = {
|
|||
),
|
||||
# Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный
|
||||
# индекс/кадастр/POI не заведены (проверено — frozenset() пуст
|
||||
# намеренно, не заглушка). Ряд Сбериндекса по области загружен в
|
||||
# источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50
|
||||
# ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением.
|
||||
# намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в
|
||||
# карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не
|
||||
# становится: у Москвы набор тиров тоже пуст, а свой ряд она читает —
|
||||
# поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде
|
||||
# за пределами этого реестра не спрашивают).
|
||||
enrichment_tiers=frozenset(),
|
||||
# Источники по области несут настоящий city (Химки, Балашиха — не
|
||||
# муниципальный округ/поселение, в отличие от Москвы) — перезаписывать
|
||||
# нечего и незачем, в отличие от 77.
|
||||
canonical_city=None,
|
||||
# Города-ядра нет (см. bbox_tight выше) — подставлять «Красногорск» в
|
||||
# запрос геокодера, когда город не назван, НЕЛЬЗЯ: «Луговая» есть и в
|
||||
# Красногорске, и в Сабурово, и ещё в десятке НП области.
|
||||
has_city_core=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -260,36 +493,76 @@ def _bbox_area(bbox: BBox) -> float:
|
|||
# Порядок обхода для region_for_point: от САМОГО специфичного (маленький
|
||||
# bbox_region) к самому общему — НЕ sorted(REGIONS) по числовому коду.
|
||||
#
|
||||
# Почему код региона как ключ порядка сломался: bbox_region(50) (Московская
|
||||
# область целиком, lat 54.20..56.96/lon 35.14..40.21) геометрически СОДЕРЖИТ
|
||||
# bbox_region(77) (Москва, 55.10..56.10/36.80..38.10) как прямоугольники — а
|
||||
# 50 < 66 < 77 по числу. При обходе `sorted(REGIONS)` регион 50 проверялся бы
|
||||
# ПЕРВЫМ (50 < 77) и забирал бы себе ВСЕ точки Москвы, включая центр
|
||||
# (55.75, 37.62) — она лежит в bbox_region обоих регионов одновременно. Старый
|
||||
# докстринг называл это «на случай, если когда-нибудь пересекутся» — случай
|
||||
# наступил прямо при добавлении региона 50, не гипотетически.
|
||||
# #3052: сам резолв региона идёт по настоящему полигону (see
|
||||
# _point_in_region_polygon), не по прямоугольнику — реальные админ-границы
|
||||
# 50 и 77 не пересекаются (Москва вырезана дырой из полигона области), так
|
||||
# что для КОНЕЧНОГО результата порядок обхода больше не обязателен: у точки
|
||||
# есть ровно один полигон-кандидат, bbox какого региона ни проверяй первым.
|
||||
# Порядок остаётся не как костыль корректности, а как ДЕШЁВЫЙ предварительный
|
||||
# отсев: bbox_region(50) (Московская область целиком, lat 54.20..56.96/
|
||||
# lon 35.14..40.21) геометрически СОДЕРЖИТ bbox_region(77) (Москва,
|
||||
# 55.10..56.10/36.80..38.10) как прямоугольники, а 50 < 77 по числовому коду.
|
||||
# Если проверять регионы в порядке `sorted(REGIONS)`, для точки в центре
|
||||
# Москвы bbox-отсев региона 50 пройдёт ПЕРВЫМ и завернёт в дорогой
|
||||
# point-in-polygon по 1 227 точкам области раньше, чем дело дойдёт до
|
||||
# компактного полигона Москвы (386 точек) — лишняя работа на каждый вызов,
|
||||
# не баг результата (полигон 50 всё равно отвергнет точку — она в дыре), но
|
||||
# systematic overhead на самом частом случае (Москва/область — соседи).
|
||||
#
|
||||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает это БЕЗ
|
||||
# ручного списка: чем компактнее регион, тем раньше его проверяют, поэтому
|
||||
# вложенный регион (77 внутри 50) всегда выигрывает у объемлющего, а будущий
|
||||
# новый регион сам встанет в верную позицию по своей площади — правку этого
|
||||
# места повторять не придётся.
|
||||
# Площадь bbox_region (см. `_bbox_area`) как ключ сортировки решает и это без
|
||||
# ручного списка: чем компактнее регион, тем раньше его bbox-отсев и (при
|
||||
# совпадении) полигон проверяют, поэтому вложенный по bbox регион (77 внутри
|
||||
# 50) почти всегда получает свою точку дешевле, а новый регион сам встанет в
|
||||
# верную позицию по своей площади.
|
||||
_POINT_LOOKUP_ORDER: tuple[int, ...] = tuple(
|
||||
sorted(REGIONS, key=lambda code: (_bbox_area(REGIONS[code].bbox_region), code))
|
||||
)
|
||||
|
||||
|
||||
def region_for_point(lat: float, lon: float) -> Region | None:
|
||||
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
|
||||
"""Регион покрытия, которому принадлежит точка, или None (вне охвата).
|
||||
|
||||
Обход — `_POINT_LOOKUP_ORDER` (компактный bbox_region раньше обширного), а
|
||||
не числовой код региона: код как ключ порядка ломается ровно на паре
|
||||
50/77, см. комментарий у `_POINT_LOOKUP_ORDER`.
|
||||
Два уровня отсева на каждого кандидата (порядок — `_POINT_LOOKUP_ORDER`,
|
||||
компактный bbox_region раньше обширного, см. комментарий там):
|
||||
1. `is_within_bbox` по `bbox_region` — дешёвый прямоугольный предфильтр,
|
||||
НЕ финальный ответ (прямоугольники Москвы и области пересекаются).
|
||||
2. Полигон (`_point_in_region_polygon`) — настоящая граница, решает
|
||||
результат. Если bbox прошёл, а полигон точку не принял (точка в
|
||||
прямоугольнике области, но не в её реальных границах — то есть,
|
||||
например, внутри вырезанной дыры Москвы), идём к следующему
|
||||
кандидату, а не возвращаем None сразу.
|
||||
|
||||
None — ТОЛЬКО когда точка вне bbox ВСЕХ регионов (Пермь, Таллин). Если
|
||||
хоть один прямоугольник её принял, а все полигоны отвергли, ответом
|
||||
становится кандидат, к ГРАНИЦЕ которого точка ближе
|
||||
(`_distance_to_boundary`). Иначе такая точка досталась бы вызывающему коду
|
||||
как None, а тот (`trade_in.py`: `target_region.code if target_region else
|
||||
DEFAULT_REGION_CODE`) молча подставил бы регион по умолчанию — адрес в
|
||||
агломерации посчитался бы по коэффициентам Екатеринбурга. Щели между
|
||||
полигонами соседних регионов неизбежны (у Москвы и области это независимо
|
||||
оцифрованные в OSM линии, совпадающие с точностью до метров, а не одна
|
||||
общая), и закрывать их возвратом к прямоугольнику нельзя — прямоугольники
|
||||
перекрываются и врут ровно там, ради чего #3052 и делался.
|
||||
|
||||
Региона без загруженного полигона в реестре нет (все REGIONS покрыты
|
||||
boundaries.geojson.json) — на практике до fallback-ветки дело не доходит,
|
||||
но она есть, чтобы новый регион без границы не «пропадал» молча, а
|
||||
работал по старому bbox-поведению до того, как для него добавят полигон.
|
||||
"""
|
||||
near_miss: list[int] = []
|
||||
for code in _POINT_LOOKUP_ORDER:
|
||||
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
|
||||
return REGIONS[code]
|
||||
region = REGIONS[code]
|
||||
if not is_within_bbox(lat, lon, region.bbox_region):
|
||||
continue
|
||||
if code not in _REGION_RING_INDEX or _point_in_region_polygon(code, lat, lon):
|
||||
return region
|
||||
near_miss.append(code)
|
||||
if not near_miss:
|
||||
return None
|
||||
if len(near_miss) == 1:
|
||||
return REGIONS[near_miss[0]]
|
||||
nearest = min(near_miss, key=lambda code: _distance_to_boundary(code, lat, lon))
|
||||
return REGIONS[nearest]
|
||||
|
||||
|
||||
def region_by_city(city: str | None) -> Region | None:
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ _DEFAULT_MAX_RETRIES = 5
|
|||
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
||||
_RATE_LIMIT_WINDOW_S = 60.0
|
||||
|
||||
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
|
||||
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
|
||||
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
|
||||
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
|
||||
|
||||
|
||||
class TelegramGroupRateLimiter:
|
||||
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
||||
|
|
@ -744,13 +749,13 @@ class TelegramClient:
|
|||
) -> bool:
|
||||
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
||||
|
||||
Используется НЕ по прямому назначению (индикация набора), а как способ
|
||||
проверить существование `message_thread_id` (темы форума) — см.
|
||||
`verify_chat_and_topic`. Это единственный метод Bot API, который
|
||||
принимает `message_thread_id` и не создаёт message-объект: если тема
|
||||
удалена/переименована в другую с иным id, Telegram отвечает `Bad
|
||||
Request: message thread not found` мгновенно, а в истории чата не
|
||||
остаётся ни строки (индикатор эфемерный и не персистится)."""
|
||||
⚠️ НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
|
||||
(#3471) показал, что Telegram принимает и мгновенно подтверждает
|
||||
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
|
||||
(`{"ok":true,"result":true}`) — метод молча принимает любую тему,
|
||||
существующую или нет. Раньше здесь было обратное (неверное)
|
||||
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
|
||||
использует `sendMessage`+`deleteMessage`."""
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
|
@ -759,6 +764,22 @@ class TelegramClient:
|
|||
)
|
||||
return bool(result)
|
||||
|
||||
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
|
||||
"""deleteMessage — удаляет сообщение бота.
|
||||
|
||||
Используется `verify_chat_and_topic` для зачистки зондирующего
|
||||
`sendMessage`-пробника сразу после проверки темы: тема существует
|
||||
тогда и только тогда, когда сообщение вообще удалось отправить —
|
||||
поэтому к моменту вызова `deleteMessage` id уже гарантированно
|
||||
валиден."""
|
||||
result = await self._request(
|
||||
"deleteMessage",
|
||||
{"chat_id": chat_id, "message_id": message_id},
|
||||
max_retries=1,
|
||||
max_backoff=5.0,
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def verify_chat_and_topic(
|
||||
client: TelegramClient,
|
||||
|
|
@ -776,18 +797,23 @@ async def verify_chat_and_topic(
|
|||
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
||||
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
||||
|
||||
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
|
||||
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
|
||||
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
||||
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
||||
- `send_chat_action` (typing-индикатор с `message_thread_id`) —
|
||||
единственный способ провалидировать САМУ тему без создания
|
||||
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
|
||||
показать «печатает...» именно в нужном треде, и явно отказывает, если
|
||||
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
|
||||
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
|
||||
КАЖДОМ рестарте контейнера — на rolling-деплое это многократно в
|
||||
сутки; typing-индикатор того же результата достигает без единого
|
||||
сообщения.
|
||||
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
|
||||
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
|
||||
`message_thread_id`, чтобы показать «печатает...» именно в нужном
|
||||
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
|
||||
принимает и подтверждает ЗАВЕДОМО несуществующий
|
||||
`message_thread_id` (`{"ok":true,"result":true}`) — проверка была
|
||||
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
|
||||
так же, как живая, а реальная отправка потом падала на каждом
|
||||
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
|
||||
отвечает `400 Bad Request: message thread not found` (эмпирически
|
||||
подтверждено на том же проде), а с валидным id создаёт сообщение —
|
||||
которое здесь же стирается `deleteMessage`, так что в истории треда
|
||||
не остаётся ничего, кроме микросекундного технического сообщения на
|
||||
каждом старте/рестарте контейнера.
|
||||
|
||||
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
||||
уровня error — задача явно требует шума в логе, а не падения воркера
|
||||
|
|
@ -802,9 +828,16 @@ async def verify_chat_and_topic(
|
|||
try:
|
||||
await client.get_chat(chat_id)
|
||||
if topic_id:
|
||||
await client.send_chat_action(
|
||||
chat_id=chat_id, action="typing", message_thread_id=topic_id
|
||||
probe = await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=_TOPIC_PROBE_TEXT,
|
||||
message_thread_id=topic_id,
|
||||
max_retries=1,
|
||||
max_backoff=5.0,
|
||||
)
|
||||
probe_message_id = probe.get("message_id")
|
||||
if probe_message_id:
|
||||
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
|
||||
except TelegramError as exc:
|
||||
logger.error(
|
||||
"tg topic check [%s]: чат/тема недоступны для отправки "
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Idempotent-логика (на каждый yandex-lot):
|
|||
- история ПУСТА:
|
||||
* price_previous задан И != price_rub → seed previous-точку (change_time = now-1d,
|
||||
timestamp синтетический — gate реального времени смены не несёт), затем current-точку;
|
||||
НО previous, отличающаяся от текущей цены на порядок и больше, не сеется (#3385);
|
||||
* иначе → только current-точку (now).
|
||||
- история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену.
|
||||
- история ЕСТЬ и latest_price == price_rub → skip (цена не менялась).
|
||||
|
|
@ -41,6 +42,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_SOURCE = "yandex"
|
||||
|
||||
# #3385: price.previous, отличающаяся от текущей цены в 9.5 раза и больше (в любую
|
||||
# сторону), — не рынок, а потерянный разряд или цена в тысячах. Прод 17.09.2026, все
|
||||
# 8313 посевов yandex: самый большой честный скачок ×2.13, дальше пусто до ×9.74; выше
|
||||
# порога 11 посевов (×10 — 7, ×175…×1000 — 4), и у всех нижняя цена 0.1–37 тыс/м².
|
||||
# Посев синтетический (now-1d, реального времени смены gate не несёт), без него
|
||||
# теряется только фиктивная точка — текущая цена пишется всё равно.
|
||||
_SEED_IMPLAUSIBLE_RATIO = 9.5
|
||||
|
||||
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
|
||||
_RESOLVE_SQL = text(
|
||||
"""
|
||||
|
|
@ -96,6 +105,12 @@ def _insert_point(
|
|||
)
|
||||
|
||||
|
||||
def _implausible_seed(previous: float, current: float) -> bool:
|
||||
"""previous и current разнятся на порядок и больше (или одна из цен не > 0)."""
|
||||
low, high = sorted((float(previous), float(current)))
|
||||
return low <= 0 or high / low >= _SEED_IMPLAUSIBLE_RATIO
|
||||
|
||||
|
||||
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend.
|
||||
|
||||
|
|
@ -115,6 +130,7 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
|||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
seed_rejected = 0
|
||||
errors = 0
|
||||
|
||||
for lot in lots:
|
||||
|
|
@ -140,20 +156,29 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
|||
{"listing_id": listing_id},
|
||||
).fetchone()
|
||||
|
||||
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
|
||||
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
|
||||
# к предыдущей точке и подтверждения у следующей. На этом пути серия
|
||||
# максимум из двух точек (последняя лежащая в БД + текущая), у
|
||||
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
|
||||
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
|
||||
# То есть проводка была бы декорацией: ветка, которая по построению
|
||||
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
|
||||
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
|
||||
# DELETE уже вставленной строки. Отдельная задача: #3385.
|
||||
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история
|
||||
# есть» на проде не записала ни одной строки (из 28 558 строк этого
|
||||
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер
|
||||
# record_listing_price_change (save_listings идёт первым), и сюда
|
||||
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории
|
||||
# — пары «посев из price.previous → текущая цена», ошибочна нижняя.
|
||||
if latest is None:
|
||||
# История пуста — seed (+ опционально previous-точка).
|
||||
prev = lot.price_previous_rub
|
||||
if prev is not None and prev != lot.price_rub:
|
||||
if (
|
||||
prev is not None
|
||||
and prev != lot.price_rub
|
||||
and _implausible_seed(prev, lot.price_rub)
|
||||
):
|
||||
seed_rejected += 1
|
||||
logger.warning(
|
||||
"yandex_price_history: price.previous=%s не сеется — на порядок "
|
||||
"от текущей %s (source_id=%s)",
|
||||
prev,
|
||||
lot.price_rub,
|
||||
lot.source_id,
|
||||
)
|
||||
elif prev is not None and prev != lot.price_rub:
|
||||
_insert_point(
|
||||
db,
|
||||
listing_id=listing_id,
|
||||
|
|
@ -191,10 +216,11 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
|||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d",
|
||||
"yandex_price_history: lots=%d inserted=%d skipped=%d seed_rejected=%d errors=%d",
|
||||
len(lots),
|
||||
inserted,
|
||||
skipped,
|
||||
seed_rejected,
|
||||
errors,
|
||||
)
|
||||
return inserted
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import LISTINGS_FRESH_DAYS, settings
|
||||
from app.services import regions as regions_mod
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||
|
|
@ -68,13 +69,29 @@ _PPM2_MIN: int = 30_000
|
|||
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||||
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||||
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||||
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
|
||||
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
|
||||
# ниже) — симметрично deal-стороне.
|
||||
#
|
||||
# #3512 (per-region ratio, migration 304): эта городская квота — костыль ИМЕННО региона
|
||||
# 66 (asking-скрейп исторически покрывал только сам ЕКБ, см. #C2 выше), и трогать её
|
||||
# нельзя — числа региона 66 обязаны остаться byte-for-byte прежними. Для ЛЮБОГО другого
|
||||
# региона (deals.region_code / listings.region_code — обе таблицы несут колонку) городской
|
||||
# квоты не было и не нужно: там достаточно `region_code = :region_code` симметрично на
|
||||
# обеих сторонах (см. _REDERIVE_SQL_REGION ниже) — колонка `district` остаётся
|
||||
# зарезервированной под #647 (гео-районы ВНУТРИ региона), per-region разрез теперь
|
||||
# несёт `region_code`, а не `district`.
|
||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||
|
||||
# #3512: регионы, для которых считается ОТДЕЛЬНАЯ (не-ЕКБ) деривация ниже —
|
||||
# весь реестр покрытия (app.services.regions — ЕДИНСТВЕННЫЙ источник правды про регионы)
|
||||
# минус 66 (у него своя историческая деривация выше). Появление нового региона в реестре
|
||||
# автоматически включает его в пересчёт ratio, без правки этого файла.
|
||||
_OTHER_REGION_CODES: tuple[int, ...] = tuple(
|
||||
sorted(code for code in regions_mod.REGIONS if code != 66)
|
||||
)
|
||||
|
||||
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
||||
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
||||
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
||||
|
|
@ -86,6 +103,103 @@ _AREA_ROOMS_BUCKET_SQL = (
|
|||
)
|
||||
|
||||
|
||||
# ── #time-adjust: приведение SOLD-стороны к сегодняшнему дню (rollback за settings) ──
|
||||
# ПРОБЛЕМА: sold-медиана (deal_side/deal_global/deal_geo) — сырые цены сделок за
|
||||
# трейлинг-12 мес (на проде фактическое окно сентябрь 2025 - апрель 2026, медиана
|
||||
# ~декабрь 2025), ask-медиана — объявления за LISTINGS_FRESH_DAYS (сегодня). На растущем
|
||||
# рынке это занижает ratio (числитель отстаёт от знаменателя во времени). Фикс — тот же
|
||||
# приём, что estimator._sber_time_factor применяет к ДКП-коридору (#794): каждая сделка
|
||||
# домножается на factor = idx[последний доступный месяц серии] / idx[месяц сделки]
|
||||
# (с clamp SBER_TIME_FACTOR_MIN/MAX). Серия и её имя — ТА ЖЕ карта sber_region_series_name
|
||||
# (app.services.estimator), НЕ вторая карта — импортируется ЛЕНИВО внутри
|
||||
# recompute_asking_to_sold_ratios(), т.к. estimator.py импортирует area_bucket ИЗ этого
|
||||
# модуля на верхнем уровне (см. выше) — top-level импорт в обратную сторону дал бы цикл.
|
||||
#
|
||||
# Двойной учёт (проверено, см. PR-описание/vault): estimator._fetch_dkp_corridor тоже
|
||||
# зовёт _sber_time_factor, но на ЖИВОЙ per-request ДКП-коридор (отдельный SQL, отдельная
|
||||
# цель — condition-guard corridor-clamp поверх ASKING-медианы, estimator.py:3989-4002),
|
||||
# а не на ratio из asking_to_sold_ratios. Эта таблица и коридор — независимые артефакты
|
||||
# по одному сырому источнику (deals); один и тот же множитель здесь и там НЕ перемножается
|
||||
# на одно и то же число дважды.
|
||||
#
|
||||
# Дашборд выбирается В SQL по приоритету SBER_COEFF_DASHBOARDS (bind-массив
|
||||
# :sber_dashboards, array_position — первый непустой побеждает, тот же порядок, что
|
||||
# estimator._load_sber_index_series перебирает по одному дашборду). Приведение
|
||||
# управляется флагом settings.asking_ratio_time_adjust_enabled (:time_adjust_enabled) —
|
||||
# False даёт factor=1.0 для КАЖДОЙ сделки (байт-в-байт прежнее поведение, откат без
|
||||
# деплоя). Если серии для города вообще нет (sber_bounds пуст) — тоже factor=1.0; это
|
||||
# НЕ ошибка (сделка не выбрасывается), но recompute_asking_to_sold_ratios логирует
|
||||
# факт счётчиком, а не молча (см. _sber_series_missing ниже). Если для конкретного
|
||||
# месяца сделки нет точки — берётся ближайший БОЛЕЕ РАННИЙ месяц серии (или самый
|
||||
# ранний, если сделка старше начала серии) — то же правило, что estimator._sber_time_factor.
|
||||
_SBER_FACTOR_CTES = """
|
||||
sber_series_raw AS (
|
||||
SELECT period_month, index_value_rub_m2, dashboard,
|
||||
array_position(CAST(:sber_dashboards AS text[]), dashboard) AS dash_priority
|
||||
FROM sber_price_index
|
||||
WHERE city = CAST(:sber_city AS text)
|
||||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
|
||||
),
|
||||
sber_best_dashboard AS (
|
||||
SELECT dashboard FROM sber_series_raw
|
||||
WHERE dash_priority IS NOT NULL
|
||||
ORDER BY dash_priority
|
||||
LIMIT 1
|
||||
),
|
||||
sber_series AS (
|
||||
SELECT r.period_month, r.index_value_rub_m2
|
||||
FROM sber_series_raw r
|
||||
JOIN sber_best_dashboard b ON r.dashboard = b.dashboard
|
||||
),
|
||||
sber_bounds AS (
|
||||
SELECT MAX(period_month) AS latest_month, MIN(period_month) AS earliest_month
|
||||
FROM sber_series
|
||||
),
|
||||
sber_latest_value AS (
|
||||
SELECT s.index_value_rub_m2 AS latest_value
|
||||
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.latest_month
|
||||
),
|
||||
sber_earliest_value AS (
|
||||
SELECT s.index_value_rub_m2 AS earliest_value
|
||||
FROM sber_series s JOIN sber_bounds b ON s.period_month = b.earliest_month
|
||||
)"""
|
||||
|
||||
# LEFT JOIN'ы к sber_* CTE выше — вставляются в `FROM deals` ДО `WHERE` (JOIN не может
|
||||
# идти после WHERE). ON TRUE — CTE содержат максимум одну строку (не декартово произведение).
|
||||
# snb — LATERAL «ближайший месяц серии <= месяца сделки», коррелирован по bare deal_date
|
||||
# (единственная таблица в запросе с этой колонкой — алиас не нужен).
|
||||
_SBER_FACTOR_JOINS = """
|
||||
LEFT JOIN sber_bounds sb ON TRUE
|
||||
LEFT JOIN sber_latest_value slv ON TRUE
|
||||
LEFT JOIN sber_earliest_value sev ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.index_value_rub_m2 AS base_value
|
||||
FROM sber_series s
|
||||
WHERE s.period_month <= date_trunc('month', deal_date)::date
|
||||
ORDER BY s.period_month DESC
|
||||
LIMIT 1
|
||||
) snb ON TRUE
|
||||
"""
|
||||
|
||||
# Сам фактор — 1.0 при выключенном флаге, при пустой серии, при сделке новее последнего
|
||||
# месяца серии (без экстраполяции вперёд — симметрично estimator._sber_time_factor).
|
||||
# GREATEST/LEAST — те же клампы SBER_TIME_FACTOR_MIN/MAX, что estimator применяет к
|
||||
# ДКП-коридору (bind :factor_min/:factor_max — значения ОТТУДА, не второй набор констант).
|
||||
_SBER_FACTOR_EXPR = """CASE
|
||||
WHEN NOT CAST(:time_adjust_enabled AS boolean) THEN 1.0
|
||||
WHEN sb.latest_month IS NULL THEN 1.0
|
||||
WHEN date_trunc('month', deal_date)::date >= sb.latest_month THEN 1.0
|
||||
ELSE GREATEST(
|
||||
CAST(:factor_min AS double precision),
|
||||
LEAST(
|
||||
CAST(:factor_max AS double precision),
|
||||
slv.latest_value / NULLIF(COALESCE(snb.base_value, sev.earliest_value), 0)
|
||||
)
|
||||
)
|
||||
END"""
|
||||
|
||||
|
||||
def area_bucket(area_m2: float) -> int:
|
||||
"""Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620).
|
||||
|
||||
|
|
@ -108,14 +222,26 @@ def area_bucket(area_m2: float) -> int:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
||||
# ── True-mirror cleanup: drop all #648 EKB rows before re-derivation ──────────
|
||||
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
|
||||
# #3512: явный region_code=66 — эта DELETE трогает ТОЛЬКО ЕКБ-строки; остальные регионы
|
||||
# чистит своя _DELETE_SQL_REGION в цикле ниже (иначе один бланкет-DELETE стирал бы
|
||||
# только что вставленные строки другого региона на повторном прогоне того же transaction).
|
||||
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
|
||||
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
|
||||
_DELETE_SQL = text(
|
||||
"""
|
||||
DELETE FROM asking_to_sold_ratios
|
||||
WHERE district = ''
|
||||
WHERE region_code = 66 AND district = ''
|
||||
"""
|
||||
)
|
||||
|
||||
# #3512: та же true-mirror очистка, но per-region (используется в цикле для
|
||||
# _OTHER_REGION_CODES) — CAST(:region_code AS int), никогда :region_code::int (psycopg v3).
|
||||
_DELETE_SQL_REGION = text(
|
||||
"""
|
||||
DELETE FROM asking_to_sold_ratios
|
||||
WHERE region_code = CAST(:region_code AS int) AND district = ''
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -136,14 +262,18 @@ _DELETE_SQL = text(
|
|||
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
|
||||
_REDERIVE_SQL = text(
|
||||
f"""
|
||||
WITH
|
||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||||
WITH{_SBER_FACTOR_CTES},
|
||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). #time-adjust:
|
||||
-- price_per_m2 домножен на sber-фактор приведения к последнему месяцу серии.
|
||||
deal_side AS (
|
||||
SELECT
|
||||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
|
||||
) AS sold_median,
|
||||
COUNT(*) AS n_deals
|
||||
FROM deals
|
||||
{_SBER_FACTOR_JOINS}
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
|
|
@ -215,9 +345,12 @@ _REDERIVE_SQL = text(
|
|||
-- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес — для global row.
|
||||
deal_global AS (
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||||
percentile_cont(0.5) WITHIN GROUP (
|
||||
ORDER BY price_per_m2 * ({_SBER_FACTOR_EXPR})
|
||||
) AS sold_median,
|
||||
COUNT(*) AS n_deals
|
||||
FROM deals
|
||||
{_SBER_FACTOR_JOINS}
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
|
|
@ -266,19 +399,384 @@ _REDERIVE_SQL = text(
|
|||
)
|
||||
INSERT INTO asking_to_sold_ratios (
|
||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis
|
||||
n_deals, n_listings, window_months, basis, region_code
|
||||
)
|
||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis FROM global_row
|
||||
n_deals, n_listings, window_months, basis, 66 FROM global_row
|
||||
UNION ALL
|
||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis FROM per_bucket
|
||||
n_deals, n_listings, window_months, basis, 66 FROM per_bucket
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Geography-matched per-region derivation (#3529) ───────────────────────────
|
||||
# ПРОБЛЕМА (#3512-путь, прод-замер 2026-09 по региону 50): обе стороны фильтровались
|
||||
# ТОЛЬКО по region_code и соединялись ТОЛЬКО по бакету комнат — т.е. sold-медиана и
|
||||
# ask-медиана считались по РАЗНЫМ географическим популяциям одного региона.
|
||||
# Разложение обл.50 по кольцам 10 км от центра Москвы (сделки 12 мес vs активные объявления):
|
||||
# 20-30 км: 12 189 сделок / 23 292 объявления → 0.808
|
||||
# 30-40 км: 4 132 / 10 206 → 0.847
|
||||
# 50-60 км: 1 432 / 5 243 → 0.951
|
||||
# 70-80 км: 150 / 2 294 → 0.688
|
||||
# ВНУТРИ колец отношение 0.69-0.95, ближние кольца (76% сделок) — 0.81-0.85, а общий пул
|
||||
# давал 0.891: объявления смещены к дальней дешёвой периферии СИЛЬНЕЕ, чем сделки. Это
|
||||
# перекос СОСТАВА выборки, а не свойство рынка: выкупная цена по области системно завышена.
|
||||
#
|
||||
# РЕШЕНИЕ: считать коэффициент на СОГЛАСОВАННОЙ географии — обе стороны раскладываются
|
||||
# по одним и тем же пространственным ячейкам, медианы берутся ВНУТРИ ячейки, и в итог
|
||||
# идут только ячейки, где есть ОБЕ стороны, с весами по числу СДЕЛОК. Т.е. ask-сторона
|
||||
# перевзвешивается на географию сделок (индекс Ласпейреса): ratio = Σ(w·sold) / Σ(w·ask),
|
||||
# w = n_deals ячейки. Строка остаётся внутренне согласованной: ratio == sold_median/ask_median,
|
||||
# где оба медианных столбца — взвешенные средние ячеечных медиан с ОДНИМИ весами.
|
||||
#
|
||||
# ПУТЬ ЕКБ (66) НЕ ТРОГАЕМ — там своя историческая калибровка городской квотой (#C2/#2583),
|
||||
# числа региона 66 обязаны остаться byte-for-byte прежними (_REDERIVE_SQL выше).
|
||||
|
||||
# РАЗМЕР ЯЧЕЙКИ — регулярная сетка 0.1° широты × 0.2° долготы ≈ 11 км × 12-16 км на
|
||||
# широтах 45-60°N (0.2° долготы × cos(lat): 15.7 км на 45°, 12.5 км на 55.7°, 11.1 км на 60°).
|
||||
# Почему именно так:
|
||||
# • Масштаб взят от замера выше: именно на ~10-км разрешении отношение перестаёт
|
||||
# гулять от состава (внутри кольца 0.69-0.95 вместо 0.891 по пулу), при этом ячейка
|
||||
# ещё достаточно крупная, чтобы набрать десятки сделок и объявлений.
|
||||
# • Сетка, а НЕ кольца от центра: кольцам нужен центр, а у региона 50 своего
|
||||
# города-центра нет (его фактический центр — Москва, т.е. ДРУГОЙ регион), и каждый
|
||||
# следующий регион реестра потребовал бы своего анкора и своего шага. Сетке анкор не нужен.
|
||||
# • Совмещение по НАЗВАНИЮ муниципалитета НЕВОЗМОЖНО: listings.city у региона 50
|
||||
# пуста (3 строки из 70 996). geom есть с обеих сторон (объявления 70 996/70 996,
|
||||
# сделки 87 562/113 351) — выравниваем ПРОСТРАНСТВЕННО.
|
||||
# • FLOOR по градусам — чистая арифметика по ST_X/ST_Y, без репроекций и без стыковки
|
||||
# с админграницами, которых в БД нет. Точность границ ячейки здесь не важна — важно,
|
||||
# что ОБЕ стороны режутся ОДИНАКОВО.
|
||||
_CELL_LAT_DEG: float = 0.1
|
||||
_CELL_LON_DEG: float = 0.2
|
||||
|
||||
# Порог НА ЯЧЕЙКУ (все комнатности вместе) — сколько нужно, чтобы ячейка считалась
|
||||
# покрытой ОБЕИМИ сторонами. Ниже глобального 30/30 НАМЕРЕННО: ячеечная медиана не
|
||||
# публикуется сама по себе — она входит во взвешенную сумму, а публикуемый барьер
|
||||
# остаётся прежним 30/30, но уже на СУММЕ по удержанным ячейкам (HAVING ниже).
|
||||
_CELL_MIN_DEALS: int = 10
|
||||
_CELL_MIN_LISTINGS: int = 10
|
||||
|
||||
# Порог на пару (ячейка, бакет комнат) — ещё мягче: внутри уже отобранной ячейки
|
||||
# комнатность дробит выборку ещё на 5 частей. Меньше 5 наблюдений на сторону — медиана
|
||||
# шум, и при большом весе этот шум попадёт в итоговую строку.
|
||||
_CELL_BUCKET_MIN_DEALS: int = 5
|
||||
_CELL_BUCKET_MIN_LISTINGS: int = 5
|
||||
|
||||
# ГАРДЫ ДЕГРАДАЦИИ (пункт 6 задачи): если согласованной географии по факту нет —
|
||||
# лучше НЕ писать строку вообще (эстиматор деградирует явно, без коэффициента),
|
||||
# чем посчитать неверно и выглядеть уверенно.
|
||||
_MIN_MATCHED_CELLS: int = 3
|
||||
# Доля СДЕЛОК (с geom), попавших в пересечение ячеек. Именно сделки — целевая популяция
|
||||
# (на их географию перевзвешивается ask-сторона); объявления за пределами пересечения
|
||||
# отбрасываются НАМЕРЕННО (это и есть фикс), поэтому гарда на них нет — только счётчик.
|
||||
_MIN_DEAL_CELL_COVERAGE: float = 0.5
|
||||
# Доля строк с geom на КАЖДОЙ стороне: если большая часть стороны без координат,
|
||||
# выравнивать пространственно нечего — получился бы коэффициент по неслучайному остатку.
|
||||
_MIN_GEOM_COVERAGE: float = 0.5
|
||||
# Сигнальный (не блокирующий) порог: выше него пишется WARNING. 0.25 выбран чуть выше
|
||||
# текущего прод-состояния региона 50 (25 789/113 351 = 22.8% сделок ждут геокодера),
|
||||
# чтобы лог не шумел на норме, но ухудшение было видно сразу. Доля попадает в счётчики
|
||||
# ВСЕГДА, независимо от порога — строки без geom не выпадают молча (пункт 3 задачи).
|
||||
_GEOM_WARN_SHARE: float = 0.25
|
||||
|
||||
# ОБЩИЕ ФИЛЬТРЫ сторон — один источник правды для stats- и insert-запросов (иначе счётчики
|
||||
# и деривация разъехались бы при первой же правке одного из них). Состав гардов тот же,
|
||||
# что у ЕКБ-деривации (12-мес окно, ppm²-полоса, свежесть #2656, novostroyki #1186,
|
||||
# area_m2 IS NOT NULL #2620) — меняется ТОЛЬКО гео-согласование.
|
||||
_DEAL_FROM_REGION = """
|
||||
FROM deals
|
||||
"""
|
||||
|
||||
_DEAL_WHERE_REGION = """
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
"""
|
||||
|
||||
# #time-adjust: FROM и WHERE разведены — deal_geo (ниже) вставляет sber-JOIN'ы МЕЖДУ
|
||||
# ними (JOIN обязан стоять до WHERE), deal_all (stats, без time-adjust) склеивает как раньше.
|
||||
_DEAL_FROM_WHERE_REGION = _DEAL_FROM_REGION + _DEAL_WHERE_REGION
|
||||
|
||||
_ASK_FROM_WHERE_REGION = """
|
||||
FROM listings
|
||||
WHERE is_active
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND rooms IS NOT NULL
|
||||
AND area_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
"""
|
||||
|
||||
# Ячеечные CTE — ОБЩИЕ для stats-запроса (счётчики + гард) и для самой деривации,
|
||||
# чтобы решение «писать / не писать» принималось РОВНО по тем ячейкам, которые потом считаются.
|
||||
_CELL_CTES_REGION = f"""{_SBER_FACTOR_CTES},
|
||||
-- #time-adjust: price_per_m2 домножен на sber-фактор ДО попадания в deal_cell/
|
||||
-- deal_cell_bucket — приведение проезжает через весь взвешенный (Ласпейрес) расчёт
|
||||
-- региона автоматически, отдельно трогать deal_cell/per_bucket не нужно.
|
||||
deal_geo AS (
|
||||
SELECT
|
||||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
||||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
||||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
||||
price_per_m2 * ({_SBER_FACTOR_EXPR}) AS price_per_m2
|
||||
{_DEAL_FROM_REGION}
|
||||
{_SBER_FACTOR_JOINS}
|
||||
{_DEAL_WHERE_REGION} AND geom IS NOT NULL
|
||||
),
|
||||
ask_geo AS (
|
||||
SELECT
|
||||
FLOOR(ST_Y(geom) / {_CELL_LAT_DEG}) AS cell_lat,
|
||||
FLOOR(ST_X(geom) / {_CELL_LON_DEG}) AS cell_lon,
|
||||
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
||||
price_per_m2
|
||||
{_ASK_FROM_WHERE_REGION} AND geom IS NOT NULL
|
||||
),
|
||||
deal_cell AS (
|
||||
SELECT
|
||||
cell_lat, cell_lon,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
|
||||
COUNT(*) AS n_deals
|
||||
FROM deal_geo
|
||||
GROUP BY cell_lat, cell_lon
|
||||
),
|
||||
ask_cell AS (
|
||||
SELECT
|
||||
cell_lat, cell_lon,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||||
COUNT(*) AS n_listings
|
||||
FROM ask_geo
|
||||
GROUP BY cell_lat, cell_lon
|
||||
),
|
||||
-- СОГЛАСОВАННАЯ ГЕОГРАФИЯ: ячейки, где ОБЕ стороны имеют свою массу.
|
||||
matched_cell AS (
|
||||
SELECT
|
||||
d.cell_lat, d.cell_lon,
|
||||
d.sold_median, d.n_deals,
|
||||
a.ask_median, a.n_listings
|
||||
FROM deal_cell d
|
||||
JOIN ask_cell a USING (cell_lat, cell_lon)
|
||||
WHERE d.n_deals >= {_CELL_MIN_DEALS}
|
||||
AND a.n_listings >= {_CELL_MIN_LISTINGS}
|
||||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
||||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
||||
)"""
|
||||
|
||||
# Статистика СОСТАВА выборки — считается ДО деривации и решает, писать ли регион вообще.
|
||||
# Строки БЕЗ geom тоже считаются (n_all vs n_geo) — они выпадают из деривации, и это
|
||||
# должно быть видно в счётчиках, а не тихо (пункт 3 задачи).
|
||||
_GEO_STATS_SQL_REGION = text(
|
||||
f"""
|
||||
WITH{_CELL_CTES_REGION},
|
||||
deal_all AS (
|
||||
SELECT
|
||||
COUNT(*) AS n_all,
|
||||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
||||
{_DEAL_FROM_WHERE_REGION} ),
|
||||
ask_all AS (
|
||||
SELECT
|
||||
COUNT(*) AS n_all,
|
||||
COUNT(*) FILTER (WHERE geom IS NOT NULL) AS n_geo
|
||||
{_ASK_FROM_WHERE_REGION} )
|
||||
SELECT
|
||||
da.n_all AS deals_total,
|
||||
da.n_geo AS deals_geo,
|
||||
aa.n_all AS listings_total,
|
||||
aa.n_geo AS listings_geo,
|
||||
(SELECT COUNT(*) FROM deal_cell) AS cells_deal,
|
||||
(SELECT COUNT(*) FROM ask_cell) AS cells_ask,
|
||||
(SELECT COUNT(*) FROM deal_cell d
|
||||
JOIN ask_cell a USING (cell_lat, cell_lon)) AS cells_both_sides,
|
||||
(SELECT COUNT(*) FROM matched_cell) AS cells_matched,
|
||||
(SELECT COALESCE(SUM(n_deals), 0) FROM matched_cell) AS deals_in_cells,
|
||||
(SELECT COALESCE(SUM(n_listings), 0) FROM matched_cell) AS listings_in_cells
|
||||
FROM deal_all da CROSS JOIN ask_all aa
|
||||
"""
|
||||
)
|
||||
|
||||
# Деривация на согласованной географии. Отличий от ЕКБ-пути (_REDERIVE_SQL) ровно два:
|
||||
# 1. городская квота ЕКБ → симметричный region_code на обеих сторонах (#3512);
|
||||
# 2. медианы считаются ВНУТРИ ячейки и агрегируются с весами по числу сделок (#3529).
|
||||
# Окно 12 мес, ppm²-полоса, area-бакет ask-стороны, порог 30/30 на публикуемую строку — прежние.
|
||||
_REDERIVE_SQL_REGION = text(
|
||||
f"""
|
||||
WITH{_CELL_CTES_REGION},
|
||||
-- Внутри УЖЕ отобранных ячеек — разрез по бакету комнат (обе стороны — тот же набор
|
||||
-- ячеек, т.е. гео-ключ есть И в фильтре, И в соединении — в отличие от старого
|
||||
-- `JOIN ... USING (rooms_bucket)`, где географии в соединении не было вообще).
|
||||
deal_cell_bucket AS (
|
||||
SELECT
|
||||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS sold_median,
|
||||
COUNT(*) AS n_deals
|
||||
FROM deal_geo g
|
||||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
||||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
||||
),
|
||||
ask_cell_bucket AS (
|
||||
SELECT
|
||||
g.cell_lat, g.cell_lon, g.rooms_bucket,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY g.price_per_m2) AS ask_median,
|
||||
COUNT(*) AS n_listings
|
||||
FROM ask_geo g
|
||||
JOIN matched_cell m USING (cell_lat, cell_lon)
|
||||
GROUP BY g.cell_lat, g.cell_lon, g.rooms_bucket
|
||||
),
|
||||
bucket_cell AS (
|
||||
SELECT
|
||||
d.rooms_bucket,
|
||||
d.sold_median, d.n_deals,
|
||||
a.ask_median, a.n_listings
|
||||
FROM deal_cell_bucket d
|
||||
JOIN ask_cell_bucket a USING (cell_lat, cell_lon, rooms_bucket)
|
||||
WHERE d.n_deals >= {_CELL_BUCKET_MIN_DEALS}
|
||||
AND a.n_listings >= {_CELL_BUCKET_MIN_LISTINGS}
|
||||
AND d.sold_median IS NOT NULL AND d.sold_median > 0
|
||||
AND a.ask_median IS NOT NULL AND a.ask_median > 0
|
||||
),
|
||||
-- Взвешивание по числу СДЕЛОК: ask-сторона приводится к географии сделок.
|
||||
-- ratio == sold_median/ask_median построчно (оба — взвешенные средние с ОДНИМИ весами),
|
||||
-- так что публикуемые столбцы остаются взаимно согласованными.
|
||||
per_bucket AS (
|
||||
SELECT
|
||||
rooms_bucket,
|
||||
''::text AS district,
|
||||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
||||
AS numeric) AS ratio,
|
||||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
||||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
||||
SUM(n_deals)::int AS n_deals,
|
||||
SUM(n_listings)::int AS n_listings,
|
||||
12 AS window_months,
|
||||
'per_rooms'::text AS basis
|
||||
FROM bucket_cell
|
||||
GROUP BY rooms_bucket
|
||||
-- ТОТ ЖЕ публикуемый барьер 30/30, что и раньше — теперь на сумме по ячейкам.
|
||||
HAVING SUM(n_deals) >= 30
|
||||
AND SUM(n_listings) >= 30
|
||||
AND SUM(ask_median * n_deals) > 0
|
||||
),
|
||||
-- Global -1 fallback — те же ячейки, но без разреза по комнатности.
|
||||
global_row AS (
|
||||
SELECT
|
||||
-1 AS rooms_bucket,
|
||||
''::text AS district,
|
||||
CAST(SUM(sold_median * n_deals) / SUM(ask_median * n_deals)
|
||||
AS numeric) AS ratio,
|
||||
round(SUM(sold_median * n_deals) / SUM(n_deals))::bigint AS sold_median,
|
||||
round(SUM(ask_median * n_deals) / SUM(n_deals))::bigint AS ask_median,
|
||||
SUM(n_deals)::int AS n_deals,
|
||||
SUM(n_listings)::int AS n_listings,
|
||||
12 AS window_months,
|
||||
'global_fallback'::text AS basis
|
||||
FROM matched_cell
|
||||
HAVING SUM(n_deals) > 0
|
||||
AND SUM(ask_median * n_deals) > 0
|
||||
)
|
||||
INSERT INTO asking_to_sold_ratios (
|
||||
rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis, region_code
|
||||
)
|
||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM global_row
|
||||
UNION ALL
|
||||
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
|
||||
n_deals, n_listings, window_months, basis, CAST(:region_code AS int) FROM per_bucket
|
||||
"""
|
||||
)
|
||||
|
||||
# КОЛОНКА district (#647-слот) ОСТАЁТСЯ ПУСТОЙ и здесь. Идентификатор ячейки в неё не
|
||||
# ложится: ячейка — ПРОМЕЖУТОЧНАЯ единица расчёта, а не единица публикации. На выходе
|
||||
# по-прежнему одна строка на (регион, бакет) — агрегат по всем ячейкам; записать в
|
||||
# district «какую-то одну» ячейку было бы враньём, а писать строку НА ЯЧЕЙКУ нельзя:
|
||||
# потребитель (estimator._get_asking_sold_ratio) читает строго `district = ''` и
|
||||
# ключевать оценку по гео-ячейке пока не умеет — это отдельная задача #647.
|
||||
|
||||
|
||||
def _pct(part: float, whole: float) -> int:
|
||||
"""Доля part/whole в ЦЕЛЫХ процентах (счётчики scrape_runs — dict[str, int])."""
|
||||
if whole <= 0:
|
||||
return 0
|
||||
return round(100.0 * part / whole)
|
||||
|
||||
|
||||
def geo_region_verdict(region_code: int, stats: dict[str, int] | None) -> tuple[bool, str]:
|
||||
"""Писать ли строки региона по согласованной географии (пункт 6 — явная деградация).
|
||||
|
||||
Возвращает (ok, reason). ok=False — регион НЕ получает НИ ОДНОЙ строки (старые всё
|
||||
равно удалены), и эстиматор честно остаётся без коэффициента вместо неверного.
|
||||
Чистая функция от строки stats — тестируется без базы.
|
||||
"""
|
||||
if not stats:
|
||||
return False, "geo-stats не вернулись"
|
||||
deals_total = int(stats.get("deals_total") or 0)
|
||||
deals_geo = int(stats.get("deals_geo") or 0)
|
||||
listings_total = int(stats.get("listings_total") or 0)
|
||||
listings_geo = int(stats.get("listings_geo") or 0)
|
||||
cells_matched = int(stats.get("cells_matched") or 0)
|
||||
deals_in_cells = int(stats.get("deals_in_cells") or 0)
|
||||
|
||||
if deals_total == 0 or listings_total == 0:
|
||||
return False, f"нет данных: deals={deals_total} listings={listings_total}"
|
||||
if deals_geo / deals_total < _MIN_GEOM_COVERAGE:
|
||||
return False, (
|
||||
f"сделки без geom: {_pct(deals_total - deals_geo, deals_total)}% "
|
||||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
||||
)
|
||||
if listings_geo / listings_total < _MIN_GEOM_COVERAGE:
|
||||
return False, (
|
||||
f"объявления без geom: {_pct(listings_total - listings_geo, listings_total)}% "
|
||||
f"(порог покрытия {_MIN_GEOM_COVERAGE:.0%})"
|
||||
)
|
||||
if cells_matched < _MIN_MATCHED_CELLS:
|
||||
return False, (
|
||||
f"ячеек с обеими сторонами {cells_matched} < {_MIN_MATCHED_CELLS} "
|
||||
f"(географии сделок и объявлений практически не пересекаются)"
|
||||
)
|
||||
if deals_geo > 0 and deals_in_cells / deals_geo < _MIN_DEAL_CELL_COVERAGE:
|
||||
return False, (
|
||||
f"в пересечение ячеек попало {_pct(deals_in_cells, deals_geo)}% сделок "
|
||||
f"(порог {_MIN_DEAL_CELL_COVERAGE:.0%})"
|
||||
)
|
||||
_ = region_code
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _geo_region_counters(region_code: int, stats: dict[str, int] | None) -> dict[str, int]:
|
||||
"""Порегионные счётчики состава выборки (пункты 3 и 5 задачи), всё — int."""
|
||||
s = stats or {}
|
||||
deals_total = int(s.get("deals_total") or 0)
|
||||
deals_geo = int(s.get("deals_geo") or 0)
|
||||
listings_total = int(s.get("listings_total") or 0)
|
||||
listings_geo = int(s.get("listings_geo") or 0)
|
||||
deals_in_cells = int(s.get("deals_in_cells") or 0)
|
||||
listings_in_cells = int(s.get("listings_in_cells") or 0)
|
||||
cells_matched = int(s.get("cells_matched") or 0)
|
||||
cells_both = int(s.get("cells_both_sides") or 0)
|
||||
p = f"geo_r{region_code}_"
|
||||
return {
|
||||
p + "cells_deal": int(s.get("cells_deal") or 0),
|
||||
p + "cells_ask": int(s.get("cells_ask") or 0),
|
||||
p + "cells_matched": cells_matched,
|
||||
# Ячейки, где есть обе стороны, но одна из них тоньше порога ячейки.
|
||||
p + "cells_dropped": max(cells_both - cells_matched, 0),
|
||||
# Сколько массы осталось ЗА пределами пересечения (от строк с geom).
|
||||
p + "deals_outside_pct": _pct(deals_geo - deals_in_cells, deals_geo),
|
||||
p + "listings_outside_pct": _pct(listings_geo - listings_in_cells, listings_geo),
|
||||
# Строки без координат — не выпадают молча (пункт 3).
|
||||
p + "deals_no_geom_pct": _pct(deals_total - deals_geo, deals_total),
|
||||
p + "listings_no_geom_pct": _pct(listings_total - listings_geo, listings_total),
|
||||
}
|
||||
|
||||
|
||||
# ── Post-insert counters ──────────────────────────────────────────────────────
|
||||
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
|
||||
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
|
||||
# #3512: считается ОДИН раз в самом конце, ПОСЛЕ всех регионов (ЕКБ + цикл по
|
||||
# _OTHER_REGION_CODES) — district='' покрывает все регионы разом, счётчики суммарные.
|
||||
_COUNTERS_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
|
|
@ -292,13 +790,22 @@ _COUNTERS_SQL = text(
|
|||
|
||||
|
||||
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
|
||||
"""Пересчитать asking_to_sold_ratios per region (#3512, TRUE-MIRROR refresh #648 Stage 4).
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh):
|
||||
1. DELETE FROM asking_to_sold_ratios WHERE district = '' — снести stale-строки.
|
||||
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
|
||||
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
|
||||
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh для уже
|
||||
посчитанных регионов):
|
||||
1. Регион 66 (ЕКБ): DELETE (region_code=66, district='') → 080-derivation
|
||||
INSERT...SELECT (byte-for-byte прежняя логика — городская квота ЕКБ, #C2/#2583).
|
||||
2. Каждый прочий регион реестра app.services.regions (минус 66): тот же
|
||||
DELETE/INSERT цикл, но derivation скоупится по `region_code` симметрично
|
||||
на sold- и asking-стороне (без городской квоты — она не применима вне
|
||||
ЕКБ, см. комментарий у generic-derivation SQL ниже).
|
||||
Регион без своих ДКП-сделок ИЛИ без активных listings просто не получает строк
|
||||
(global_row/per_bucket WHERE-гарды отфильтровывают NULL-медианы) — это НЕ
|
||||
ошибка, а честный признак «данных пока недостаточно», ловится потребителем
|
||||
(estimator._get_asking_sold_ratio) через отсутствие строки → явная деградация.
|
||||
Затем ОДИН общий counters-запрос по всей таблице, commit, mark_done.
|
||||
|
||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||
|
||||
|
|
@ -320,10 +827,77 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
|||
"rows_written": 0,
|
||||
"per_rooms_rows": 0,
|
||||
"used_global_fallback": 0,
|
||||
# #3529: сколько регионов посчитано по согласованной географии, а сколько
|
||||
# деградировало явно (строк нет → эстиматор без коэффициента).
|
||||
"geo_regions_written": 0,
|
||||
"geo_regions_skipped": 0,
|
||||
# #time-adjust: сколько регионов реально получили sber-приведение SOLD-стороны
|
||||
# (серия найдена) vs посчитаны с factor=1.0 (флаг выключен ИЛИ серии нет вовсе).
|
||||
"sber_time_adjust_regions_applied": 0,
|
||||
"sber_time_adjust_regions_missing_series": 0,
|
||||
}
|
||||
# #time-adjust: ленивый импорт — estimator.py импортирует area_bucket ИЗ этого модуля
|
||||
# на верхнем уровне, top-level импорт в обратную сторону дал бы цикл (см. комментарий
|
||||
# у _SBER_FACTOR_CTES выше).
|
||||
from app.services.estimator import (
|
||||
SBER_COEFF_DASHBOARDS,
|
||||
SBER_TIME_FACTOR_MAX,
|
||||
SBER_TIME_FACTOR_MIN,
|
||||
sber_region_series_name,
|
||||
)
|
||||
|
||||
sber_dashboards = list(SBER_COEFF_DASHBOARDS)
|
||||
time_adjust_enabled = bool(settings.asking_ratio_time_adjust_enabled)
|
||||
|
||||
def _sber_params(sber_city: str) -> dict[str, object]:
|
||||
return {
|
||||
"sber_city": sber_city,
|
||||
"sber_dashboards": sber_dashboards,
|
||||
"time_adjust_enabled": time_adjust_enabled,
|
||||
"factor_min": SBER_TIME_FACTOR_MIN,
|
||||
"factor_max": SBER_TIME_FACTOR_MAX,
|
||||
}
|
||||
|
||||
def _check_sber_series(region_code: int, sber_city: str) -> None:
|
||||
"""Пункт 3 задачи: если серии для города вообще нет — не молча, счётчик+лог."""
|
||||
if not time_adjust_enabled:
|
||||
return
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM sber_price_index
|
||||
WHERE city = CAST(:sber_city AS text)
|
||||
AND dashboard = ANY(CAST(:sber_dashboards AS text[]))
|
||||
"""
|
||||
),
|
||||
{"sber_city": sber_city, "sber_dashboards": sber_dashboards},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — defensive, graceful
|
||||
logger.warning("sber series presence-check failed (graceful): %s", exc)
|
||||
row = None
|
||||
n = int(row["n"]) if row and row.get("n") is not None else 0
|
||||
if n:
|
||||
counters["sber_time_adjust_regions_applied"] += 1
|
||||
else:
|
||||
counters["sber_time_adjust_regions_missing_series"] += 1
|
||||
logger.warning(
|
||||
"asking_to_sold_ratio region_code=%d: sber_price_index серии нет "
|
||||
"(city=%s) — SOLD-сторона считается БЕЗ time-adjust (factor=1.0)",
|
||||
region_code,
|
||||
sber_city,
|
||||
)
|
||||
|
||||
try:
|
||||
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
|
||||
# таблица не должна остаться пустой, если INSERT упадёт).
|
||||
# таблица не должна остаться пустой, если INSERT упадёт). Регион 66 —
|
||||
# прежняя ЕКБ-деривация байт-в-байт; остальные регионы — цикл ниже (#3512).
|
||||
ekb_sber_city = sber_region_series_name(66)
|
||||
_check_sber_series(66, ekb_sber_city)
|
||||
db.execute(_DELETE_SQL)
|
||||
db.execute(
|
||||
_REDERIVE_SQL,
|
||||
|
|
@ -332,8 +906,64 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
|||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||
"asking_city": _ASKING_CITY_PATTERN,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
**_sber_params(ekb_sber_city),
|
||||
},
|
||||
)
|
||||
for region_code in _OTHER_REGION_CODES:
|
||||
region_sber_city = sber_region_series_name(region_code)
|
||||
_check_sber_series(region_code, region_sber_city)
|
||||
params = {
|
||||
"region_code": region_code,
|
||||
"ppm2_min": _PPM2_MIN,
|
||||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
**_sber_params(region_sber_city),
|
||||
}
|
||||
# Сначала состав выборки (#3529) — он же решает, писать ли регион вообще.
|
||||
stats_row = db.execute(_GEO_STATS_SQL_REGION, params).mappings().first()
|
||||
stats = dict(stats_row) if stats_row is not None else None
|
||||
counters.update(_geo_region_counters(region_code, stats))
|
||||
ok, reason = geo_region_verdict(region_code, stats)
|
||||
|
||||
# DELETE идёт В ЛЮБОМ случае: если согласованной географии больше нет, старый
|
||||
# (считанный по пулу) коэффициент тем более не должен оставаться в таблице.
|
||||
db.execute(_DELETE_SQL_REGION, {"region_code": region_code})
|
||||
|
||||
no_geom_deals = counters.get(f"geo_r{region_code}_deals_no_geom_pct", 0)
|
||||
no_geom_listings = counters.get(f"geo_r{region_code}_listings_no_geom_pct", 0)
|
||||
if max(no_geom_deals, no_geom_listings) >= int(_GEOM_WARN_SHARE * 100):
|
||||
# Пункт 3: строки без координат не выпадают молча — это сигнал.
|
||||
logger.warning(
|
||||
"asking_to_sold_ratio region_code=%d: без geom сделок %d%%, "
|
||||
"объявлений %d%% — гео-согласование считается по остатку",
|
||||
region_code,
|
||||
no_geom_deals,
|
||||
no_geom_listings,
|
||||
)
|
||||
|
||||
if not ok:
|
||||
counters["geo_regions_skipped"] += 1
|
||||
counters[f"geo_r{region_code}_skipped"] = 1
|
||||
logger.warning(
|
||||
"asking_to_sold_ratio region_code=%d: СТРОКИ НЕ ПИШУТСЯ — %s. "
|
||||
"Оценка останется без коэффициента (явная деградация)",
|
||||
region_code,
|
||||
reason,
|
||||
)
|
||||
continue
|
||||
|
||||
counters[f"geo_r{region_code}_skipped"] = 0
|
||||
counters["geo_regions_written"] += 1
|
||||
db.execute(_REDERIVE_SQL_REGION, params)
|
||||
logger.info(
|
||||
"asking_to_sold_ratio region_code=%d: ячеек с обеими сторонами %d "
|
||||
"(отброшено по порогу %d), вне пересечения: сделок %d%%, объявлений %d%%",
|
||||
region_code,
|
||||
counters.get(f"geo_r{region_code}_cells_matched", 0),
|
||||
counters.get(f"geo_r{region_code}_cells_dropped", 0),
|
||||
counters.get(f"geo_r{region_code}_deals_outside_pct", 0),
|
||||
counters.get(f"geo_r{region_code}_listings_outside_pct", 0),
|
||||
)
|
||||
|
||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||
if row is not None:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
|
|||
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
||||
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
||||
"ABORT -- 1 consecutive blocks".
|
||||
|
||||
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
|
||||
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
|
||||
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
|
||||
7193/7264/7302 — по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
|
||||
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
|||
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
||||
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
||||
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
||||
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
|
||||
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
|
||||
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
|
||||
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
|
||||
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
|
||||
# класс бага что #2322/#2310.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
|
|||
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
||||
зовёт эту функцию в том режиме вовсе.
|
||||
|
||||
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
|
||||
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
|
||||
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
|
||||
активна. Значение true стоит только у tradein-backend, который добор не запускает.
|
||||
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом» —
|
||||
это предсуществующее заблуждение, а не описание текущего прода.
|
||||
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
|
||||
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
|
||||
админки scrape_avito_detail_backfill в tradein-backend, — поэтому режим задаётся
|
||||
дефолтом кода, а не environment'ом одного сервиса.
|
||||
|
||||
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
||||
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
||||
|
|
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
|
|||
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
||||
_threshold (module docstring).
|
||||
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
||||
default 25 (см. комментарий у чтения параметра ниже).
|
||||
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
|
||||
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
|
||||
|
||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||
|
|
@ -549,6 +554,10 @@ async def run_avito_detail_backfill(
|
|||
window_size=int(settings.detail_backfill_block_ratio_window),
|
||||
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
||||
safety_min=max_consecutive_blocks,
|
||||
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
|
||||
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
|
||||
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
|
||||
infra_streak_limit=max_consecutive_failures,
|
||||
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
|
||||
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
||||
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
||||
|
|
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
|
|||
# config.scraper_proxy_url=None для backconnect-gate'а
|
||||
# (elif not use_curl own-session path), теряя reconnect-
|
||||
# on-403 поведение legacy (settings.scraper_proxy_url
|
||||
# напрямую). Не влияет на use_curl=True (прод-дефолт) —
|
||||
# напрямую). Не влияет на use_curl=True —
|
||||
# там reconnect_on_block=False уже гасит backconnect.
|
||||
config=RealScraperConfig(),
|
||||
origin=serp_origin,
|
||||
|
|
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
|
|||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
|
||||
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
|
||||
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
|
||||
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
|
||||
if block_ban_kinds:
|
||||
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
|
||||
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
|
||||
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
||||
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
||||
|
|
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
|
|||
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
||||
if abort_reason is not None:
|
||||
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
||||
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
|
||||
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
|
||||
# утверждать про площадку то, чего не было.
|
||||
stop_reason: str | None = None
|
||||
if no_proxy_stop:
|
||||
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
|
||||
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
|
||||
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
||||
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
|
||||
current_counters["no_proxy_stop"] = 1
|
||||
runs_mod.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
"пул прокси пуст — к площадке не ходили (#3288)",
|
||||
current_counters,
|
||||
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
|
||||
elif abort_reason == "infra_streak":
|
||||
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
|
||||
stop_reason = (
|
||||
f"тракт недоступен: {breaker.abort_explanation()}, обогащено "
|
||||
f"{counters.enriched} из {counters.attempted}; причина: "
|
||||
f"{_top_failure(failure_census)} (#3439)"
|
||||
)
|
||||
if stop_reason is not None:
|
||||
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
|
||||
logger.info(
|
||||
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
|
||||
"avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d "
|
||||
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||
run_id,
|
||||
stop_reason,
|
||||
counters.attempted,
|
||||
counters.enriched,
|
||||
counters.blocked,
|
||||
|
|
|
|||
|
|
@ -488,12 +488,39 @@ def _build_confirmations_sql(
|
|||
)
|
||||
|
||||
|
||||
# ── Регионы, где снятие устаревших вообще осмысленно ──────────────────────────
|
||||
# Снятие читает МОЛЧАНИЕ как «объявление ушло»: строку давно не видели свежей ->
|
||||
# is_active=false. Такой вывод верен ТОЛЬКО там, где есть механизм пересбора: без
|
||||
# регулярного обхода «давно не видели» неотличимо от «мы туда ни разу не ходили
|
||||
# после разовой загрузки», и джоба снимет разом весь загруженный корпус региона.
|
||||
#
|
||||
# На 15.09.2026 регулярный сбор идёт только по Свердловской области (66): реестр
|
||||
# якорей CITY_ANCHORS в ките покрывает её одну, у ДомКлика гео-скоуп зашит в ЕКБ.
|
||||
# Объявления регионов 77 и 50 попали в listings разовой ручной загрузкой 10-12.09
|
||||
# и с тех пор не обновлялись. Без фильтра ниже около 26.09 кандидатами разом стали
|
||||
# бы 40 410 строк ДомКлика (ttl_days=14 по scraped_at), следом 60 174 cian и
|
||||
# 24 152 yandex (ttl_days=30). Пострадала бы не только Москва с областью:
|
||||
# аварийный потолок max_deactivated (DEFAULT_MAX_DEACTIVATED=15000, см. ниже)
|
||||
# сработал бы раньше, а прогон при skipped_cap_exceeded НЕ ТРОГАЕТ НИ ОДНОЙ
|
||||
# строки -- то есть чистка Екатеринбурга встала бы вместе с московской.
|
||||
#
|
||||
# Отсюда дефолт «снимаем только там, где сами собираем». Расширять список нужно
|
||||
# ВМЕСТЕ с появлением расписания регулярного сбора по региону (якоря кита +
|
||||
# гео-скоуп ДомКлика), а не раньше: пока пересбора нет, нечем отличить ушедшее
|
||||
# объявление от неопрошенного. Переопределяется ключом "region_codes" в
|
||||
# default_params строки расписания (jsonb-массив целых кодов регионов).
|
||||
DEFAULT_DEACTIVATION_REGION_CODES: tuple[int, ...] = (66,)
|
||||
|
||||
|
||||
def _build_all_segments_sql(staleness_column: str) -> Any:
|
||||
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
||||
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
|
||||
:ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type).
|
||||
:region_codes, :ttl_days, :run_id) остаются param-binding — psycopg v3 safe
|
||||
(никаких :param::type). :region_codes -- см. DEFAULT_DEACTIVATION_REGION_CODES:
|
||||
снятие идёт только по регионам с регулярным пересбором. NULL region_code не
|
||||
матчится ANY(...) намеренно -- неизвестный регион нечем отнести к покрытому.
|
||||
"""
|
||||
return text(
|
||||
f"""
|
||||
|
|
@ -501,6 +528,7 @@ def _build_all_segments_sql(staleness_column: str) -> Any:
|
|||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
RETURNING id, price_rub
|
||||
|
|
@ -514,7 +542,8 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
|||
"""UPDATE с фильтром по сегменту (segments задан): только указанные сегменты.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[]))
|
||||
-- psycopg v3 адаптирует Python list -> text[].
|
||||
-- psycopg v3 адаптирует Python list -> text[]; то же для :region_codes -> int[]
|
||||
(см. DEFAULT_DEACTIVATION_REGION_CODES).
|
||||
"""
|
||||
return text(
|
||||
f"""
|
||||
|
|
@ -522,6 +551,7 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
|||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||
|
|
@ -541,7 +571,8 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
|
|||
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
|
||||
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
|
||||
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе,
|
||||
но с :region_codes (см. DEFAULT_DEACTIVATION_REGION_CODES).
|
||||
"""
|
||||
return text(
|
||||
f"""
|
||||
|
|
@ -549,6 +580,7 @@ def _build_null_segment_sql(staleness_column: str) -> Any:
|
|||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
AND listing_segment IS NULL
|
||||
|
|
@ -639,13 +671,16 @@ def _build_all_segments_candidates_count_sql(staleness_column: str) -> Any:
|
|||
_build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов
|
||||
вне скоупа PR-B. Синхронность с UPDATE закреплена тестом
|
||||
test_candidates_predicate_matches_update_predicate
|
||||
(tests/test_deactivate_stale_deactivation_cap.py).
|
||||
(tests/test_deactivate_stale_deactivation_cap.py). Фильтр по :region_codes
|
||||
обязан стоять и здесь: считать кандидатов по всем регионам, а снимать по
|
||||
покрытым сбором -- значит кормить аварийный потолок чужими числами.
|
||||
"""
|
||||
return text(
|
||||
f"""
|
||||
SELECT count(*)
|
||||
FROM listings
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
"""
|
||||
|
|
@ -661,6 +696,7 @@ def _build_segments_candidates_count_sql(staleness_column: str) -> Any:
|
|||
SELECT count(*)
|
||||
FROM listings
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||
|
|
@ -677,6 +713,7 @@ def _build_null_segment_candidates_count_sql(staleness_column: str) -> Any:
|
|||
SELECT count(*)
|
||||
FROM listings
|
||||
WHERE source = :listing_source
|
||||
AND region_code = ANY(CAST(:region_codes AS int[]))
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
AND listing_segment IS NULL
|
||||
|
|
@ -691,6 +728,7 @@ def deactivate_stale_listings(
|
|||
listing_source: str,
|
||||
ttl_days: int,
|
||||
segments: list[str] | None = None,
|
||||
region_codes: list[int] | None = None,
|
||||
staleness_column: str = "last_seen_at",
|
||||
min_confirmations: int = 0,
|
||||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||
|
|
@ -710,6 +748,24 @@ def deactivate_stale_listings(
|
|||
segments: если задан -- деактивировать только объявления с указанными
|
||||
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
|
||||
Несовместимо с null_segment_only=True (см. ниже).
|
||||
region_codes: регионы (listings.region_code), в которых снятие вообще
|
||||
разрешено. None -> DEFAULT_DEACTIVATION_REGION_CODES = (66,), то есть
|
||||
только Свердловская область. ЭТО НЕ КОСМЕТИЧЕСКИЙ ФИЛЬТР: снятие
|
||||
трактует молчание как «объявление ушло», а это верно лишь там, где
|
||||
есть механизм пересбора -- без регулярного обхода «давно не видели»
|
||||
неотличимо от «мы туда не ходили с разовой загрузки» (см. подробный
|
||||
комментарий у DEFAULT_DEACTIVATION_REGION_CODES: что именно случилось
|
||||
бы с корпусом 77/50 и почему вместе с ним встала бы чистка ЕКБ).
|
||||
Расширять список нужно ВМЕСТЕ с запуском регулярного сбора по региону,
|
||||
через ключ "region_codes" в default_params строки расписания. None
|
||||
означает дефолт, а НЕ «без фильтра»: отключить региональное
|
||||
ограничение целиком нельзя намеренно -- только перечислить регионы
|
||||
явно. Строки с region_code IS NULL не снимаются никогда (ANY(...) не
|
||||
матчит NULL): неизвестный регион нечем отнести к покрытому сбором.
|
||||
Гейты здоровья/пола (min_confirmations, revisit_floor_quantile) и
|
||||
active_pool ОСТАЮТСЯ общими по источнику, без региона -- их пороги
|
||||
откалиброваны на источнике целиком, региональная нарезка потребовала
|
||||
бы пересчёта калибровки и здесь не делается.
|
||||
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
|
||||
{"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт
|
||||
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
||||
|
|
@ -815,7 +871,11 @@ def deactivate_stale_listings(
|
|||
min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ
|
||||
любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false
|
||||
вместо числа, что и у ttl_days/cap_mult выше -- default_params
|
||||
расписания это единственный запланированный способ их переопределить).
|
||||
расписания это единственный запланированный способ их переопределить),
|
||||
ЛИБО region_codes -- пустой список (= ANY(ARRAY[]) не матчит ничего,
|
||||
прогон молча снял бы 0 и выглядел бы здоровым) или содержит не-int /
|
||||
bool (та же jsonb-опечатка, что выше: "region_codes": [true] прошло бы
|
||||
как [1] -- регион 1 существует).
|
||||
"""
|
||||
counters: dict[str, int] = {"deactivated": 0}
|
||||
try:
|
||||
|
|
@ -850,9 +910,7 @@ def deactivate_stale_listings(
|
|||
raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}")
|
||||
|
||||
if isinstance(floor_drop_ratio, bool):
|
||||
raise ValueError(
|
||||
f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}"
|
||||
)
|
||||
raise ValueError(f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}")
|
||||
if floor_drop_ratio < 1:
|
||||
raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}")
|
||||
|
||||
|
|
@ -876,6 +934,21 @@ def deactivate_stale_listings(
|
|||
if null_segment_only and segments is not None:
|
||||
raise ValueError("null_segment_only=True несовместимо с заданным segments")
|
||||
|
||||
# Регионы снятия (см. DEFAULT_DEACTIVATION_REGION_CODES): None -> дефолт,
|
||||
# а НЕ «без фильтра» -- снимать можно только там, где идёт пересбор.
|
||||
# Пустой список отвергаем явно: он матчил бы 0 строк, и прогон выглядел бы
|
||||
# здоровым (deactivated=0) вместо того, чтобы упасть на очевидной ошибке
|
||||
# конфигурации. bool отсекаем до isinstance(int), как у ttl_days/cap_mult
|
||||
# выше: jsonb `true` прошёл бы как регион 1.
|
||||
effective_region_codes: list[int] = (
|
||||
list(DEFAULT_DEACTIVATION_REGION_CODES) if region_codes is None else list(region_codes)
|
||||
)
|
||||
if not effective_region_codes:
|
||||
raise ValueError("region_codes must be a non-empty list, got empty")
|
||||
for code in effective_region_codes:
|
||||
if isinstance(code, bool) or not isinstance(code, int):
|
||||
raise ValueError(f"region_codes must contain ints, got {code!r}")
|
||||
|
||||
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
|
||||
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||
# проверяем ПЕРЕД записью, а не откатываем после.
|
||||
|
|
@ -1060,6 +1133,9 @@ def deactivate_stale_listings(
|
|||
preflight_params: dict[str, Any] = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
# Тот же срез регионов, что и у UPDATE ниже -- иначе потолок
|
||||
# считал бы кандидатов по регионам, которых снятие не коснётся.
|
||||
"region_codes": effective_region_codes,
|
||||
}
|
||||
if null_segment_only:
|
||||
candidates_sql = _build_null_segment_candidates_count_sql(staleness_column)
|
||||
|
|
@ -1108,6 +1184,7 @@ def deactivate_stale_listings(
|
|||
params: dict[str, Any] = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"region_codes": effective_region_codes,
|
||||
"run_id": run_id,
|
||||
}
|
||||
result = db.execute(_build_null_segment_sql(staleness_column), params)
|
||||
|
|
@ -1115,6 +1192,7 @@ def deactivate_stale_listings(
|
|||
params = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"region_codes": effective_region_codes,
|
||||
"segments": segments,
|
||||
"run_id": run_id,
|
||||
}
|
||||
|
|
@ -1123,6 +1201,7 @@ def deactivate_stale_listings(
|
|||
params = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"region_codes": effective_region_codes,
|
||||
"run_id": run_id,
|
||||
}
|
||||
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
||||
|
|
@ -1133,14 +1212,15 @@ def deactivate_stale_listings(
|
|||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
||||
"(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
|
||||
"staleness_column=%s)",
|
||||
"(ttl_days=%d эффективный, задан %d, segments=%r, region_codes=%r, "
|
||||
"null_segment_only=%s, staleness_column=%s)",
|
||||
listing_source,
|
||||
run_id,
|
||||
counters["deactivated"],
|
||||
effective_ttl_days,
|
||||
ttl_days,
|
||||
segments,
|
||||
effective_region_codes,
|
||||
null_segment_only,
|
||||
staleness_column,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
|
||||
|
||||
python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01
|
||||
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
|
||||
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
|
||||
|
||||
Делает два шага в одной транзакционной сессии:
|
||||
1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит).
|
||||
|
|
@ -11,11 +13,19 @@
|
|||
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
|
||||
|
||||
python -m app.tasks.gar_flats_load --match-only --region 66
|
||||
python -m app.tasks.gar_flats_load --match-only --region 77
|
||||
python -m app.tasks.gar_flats_load --match-only --region 50
|
||||
|
||||
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
|
||||
|
||||
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
|
||||
docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально.
|
||||
|
||||
Город-фильтр матча (--city) по умолчанию НЕ вводится руками на каждый запуск — берётся
|
||||
ПО РЕГИОНУ (см. app.services.gar_flats_loader.default_city_filter_for_region): region 66
|
||||
получает byte-for-byte прежний фильтр «Екатеринбург», остальные регионы (77, 50 и любой
|
||||
новый) — без фильтра. `--city ""` явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66);
|
||||
`--city "Имя"` — явный override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -25,21 +35,23 @@ import logging
|
|||
from datetime import date
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.gar_flats_loader import load_gar_region, match_houses_to_gar
|
||||
from app.services.gar_flats_loader import (
|
||||
CITY_FILTER_AUTO,
|
||||
CityFilterAutoType,
|
||||
default_city_filter_for_region,
|
||||
load_gar_region,
|
||||
match_houses_to_gar,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Город-фильтр матча по умолчанию (см. match_houses_to_gar): ЕКБ-restricted против
|
||||
# cross-town коллизий в region 66. Пустая строка в --city → None (фильтр отключён).
|
||||
DEFAULT_CITY_FILTER = "Екатеринбург"
|
||||
|
||||
|
||||
def run_gar_flats_load(
|
||||
dir_path: str,
|
||||
region_code: str,
|
||||
gar_version: str,
|
||||
*,
|
||||
city_filter: str | None = DEFAULT_CITY_FILTER,
|
||||
city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO,
|
||||
) -> dict[str, int]:
|
||||
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
|
||||
db = SessionLocal()
|
||||
|
|
@ -67,7 +79,7 @@ def run_gar_flats_load(
|
|||
|
||||
|
||||
def run_gar_match_only(
|
||||
region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
|
||||
region_code: str, *, city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO
|
||||
) -> dict[str, int]:
|
||||
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
|
||||
|
||||
|
|
@ -108,8 +120,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
parser.add_argument(
|
||||
"--city",
|
||||
default=DEFAULT_CITY_FILTER,
|
||||
help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)",
|
||||
default=None,
|
||||
help=(
|
||||
"город-фильтр матча (ILIKE по full_address); по умолчанию берётся ПО РЕГИОНУ "
|
||||
"(--region) — см. default_city_filter_for_region; пустая строка явно отключает "
|
||||
"фильтр для ЛЮБОГО региона"
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
|
@ -122,7 +138,12 @@ def main() -> None:
|
|||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Пустой --city → None (фильтр отключён).
|
||||
# --city не передан явно (argparse default=None) → дефолт ПО РЕГИОНУ (66 → «Екатеринбург»
|
||||
# byte-for-byte как раньше, остальные — без фильтра). Передан явно (в т.ч. "") →
|
||||
# уважаем волю вызывающего: "" → None (фильтр отключён), непустая строка → override.
|
||||
if args.city is None:
|
||||
city_filter: str | None = default_city_filter_for_region(args.region)
|
||||
else:
|
||||
city_filter = args.city or None
|
||||
|
||||
if args.match_only:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
|
||||
"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50).
|
||||
|
||||
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
|
||||
несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
|
||||
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
|
||||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
|
||||
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
|
||||
|
||||
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
|
||||
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||||
опознаётся. Замер по проду (60 464 карточки): с округом — 35 551, ВСЕ внутри
|
||||
bbox региона 77; без округа внутри bbox — 17 576 (это Московская область, регион
|
||||
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox —
|
||||
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» — артефакт
|
||||
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
|
||||
Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
|
||||
Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
|
||||
опознаётся — байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
|
||||
— 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox — 17 576 (это
|
||||
Московская область, регион 50); без округа вне bbox — 7 337. Отдельно 212
|
||||
карточек с адресом вида «Екатеринбург (Cian)» — артефакт парсера, считаются
|
||||
своим счётчиком, чтобы не растворяться в «не целевой регион».
|
||||
|
||||
Область (регион 50) у Циана в адресе НЕ видна вовсе — берём по ПОДДОМЕНУ
|
||||
`source_url` (`https://<sub>.cian.ru/...`): `sub != "www"` ⇒ область. Замер по
|
||||
`msk_raw.cian_latest` 12.09.2026: `www` — 38 030 карточек, из них 36 569 с
|
||||
префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065,
|
||||
balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306,
|
||||
mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с префиксом округа,
|
||||
итого не-www ≈ 24 784. Поддомен и префикс округа нигде не противоречат друг
|
||||
другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область —
|
||||
на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`) →
|
||||
карточка НЕ область (консервативно, счётчик «не целевой регион»).
|
||||
|
||||
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с
|
||||
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
|
||||
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
|
||||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
|
||||
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр —
|
||||
для ЛЮБОГО целевого региона.
|
||||
|
||||
Два сигнала, и оба нужны — ни один по отдельности не годится.
|
||||
|
||||
|
|
@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это
|
|||
qc_geo=0 у 97% найденных.
|
||||
|
||||
Что куда едет:
|
||||
* регион 77 → в `listings`, С координатами (geom есть сразу, radius-подбор
|
||||
аналогов работает без ожидания `geocode_missing`);
|
||||
* регион 50 → НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
|
||||
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) — когда
|
||||
регион появится, прогон по этой полке уже не потребует внешних вызовов;
|
||||
* регион == `--region` → в `listings`, С координатами (geom есть сразу,
|
||||
radius-подбор аналогов работает без ожидания `geocode_missing`);
|
||||
* адрес разрешился, но регион ответа не совпал с `--region` → не пишется,
|
||||
лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим
|
||||
`--region` подхватит её из кэша без единого внешнего вызова;
|
||||
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») →
|
||||
свой счётчик, карточка не пишется.
|
||||
|
||||
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
|
||||
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||
целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
|
||||
|
||||
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
|
||||
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
|
||||
|
|
@ -71,28 +83,43 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
|
|||
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
|
||||
одинаковы.
|
||||
|
||||
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||||
Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
|
||||
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
|
||||
вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два
|
||||
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
|
||||
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
|
||||
|
||||
Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине:
|
||||
сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне
|
||||
сборщика, поэтому областных карточек в нём нет по построению. Регион читается
|
||||
ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр
|
||||
оставлен второй линией — на случай, если в ту же таблицу когда-нибудь приедет
|
||||
второй город. Замер по 5 024 карточкам первого прохода: единственное значение
|
||||
первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное,
|
||||
…», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты
|
||||
заполнены у 100% карточек.
|
||||
Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА
|
||||
РАЗНЫХ прогона сборщика с разными GUID: московский (батч
|
||||
`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч
|
||||
`mo-serp-domclick-20260912`) — оба пишут в один и тот же
|
||||
`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью
|
||||
`msk_raw.domclick_latest` отдаёт обе партии вперемешку — курсор по `id` не
|
||||
различает, из какого прогона строка. Поэтому фильтр по адресу здесь не
|
||||
опциональная вторая линия, а единственный способ развести регионы.
|
||||
|
||||
Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика
|
||||
12.09.2026: московские карточки — «Москва, Генерала Дорохова проспект, 49» и
|
||||
подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса;
|
||||
областные карточки — «Московская область, Химки, 7-й м-н, проспект
|
||||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||||
Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у
|
||||
всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и
|
||||
симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая
|
||||
Москва приходит как «Москва, пос. Птичное, …» — посёлок стоит вторым
|
||||
компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты
|
||||
заполнены у 100% карточек в обоих прогонах.
|
||||
|
||||
Запуск:
|
||||
python -m app.tasks.msk_raw_import --dry-run
|
||||
python -m app.tasks.msk_raw_import --limit 500
|
||||
python -m app.tasks.msk_raw_import --source yandex
|
||||
python -m app.tasks.msk_raw_import --source yandex --region 50
|
||||
python -m app.tasks.msk_raw_import --source cian --region 50
|
||||
python -m app.tasks.msk_raw_import --source domclick
|
||||
python -m app.tasks.msk_raw_import --source domclick --region 50
|
||||
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
|
||||
python -m app.tasks.msk_raw_import --source avito --geocode --region 50
|
||||
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
|
||||
"""
|
||||
|
||||
|
|
@ -102,6 +129,7 @@ import argparse
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -113,15 +141,18 @@ from sqlalchemy.orm import Session
|
|||
from app.core.db import SessionLocal
|
||||
from app.services import dadata
|
||||
from app.services.geocoder import normalize_address
|
||||
from app.services.regions import REGIONS, is_within_bbox
|
||||
from app.services.scraper_adapters import RealMatcherAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MOSCOW_REGION_CODE = 77
|
||||
MOSCOW_CITY = "Москва"
|
||||
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
|
||||
# карточки области не пишутся, а откладываются (см. докстринг модуля).
|
||||
# Московская область в реестре `app.services.regions` заведена, но своего
|
||||
# единого города у неё нет (`canonical_city is None`) — в listings.city для
|
||||
# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит).
|
||||
OBLAST_REGION_CODE = 50
|
||||
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
|
||||
|
|
@ -427,14 +458,16 @@ class ImportCounters:
|
|||
|
||||
read: int = 0
|
||||
skipped_artifact: int = 0
|
||||
skipped_not_moscow: int = 0
|
||||
# Карточка сама говорит про другой регион (префикс округа / поддомен
|
||||
# Циана / компонент адреса не совпал с целевым `--region`).
|
||||
skipped_not_target_region: int = 0
|
||||
skipped_invalid: int = 0
|
||||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
|
||||
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
|
||||
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
|
||||
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
|
||||
# домов».
|
||||
skipped_oblast: int = 0
|
||||
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
|
||||
# регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
|
||||
# совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
|
||||
# бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
|
||||
# не «в целевом регионе стало меньше домов».
|
||||
skipped_geo_other_region: int = 0
|
||||
skipped_ungeocoded: int = 0
|
||||
selected: int = 0
|
||||
inserted: int = 0
|
||||
|
|
@ -461,8 +494,8 @@ class ImportCounters:
|
|||
self.read
|
||||
== self.selected
|
||||
+ self.skipped_artifact
|
||||
+ self.skipped_not_moscow
|
||||
+ self.skipped_oblast
|
||||
+ self.skipped_not_target_region
|
||||
+ self.skipped_geo_other_region
|
||||
+ self.skipped_ungeocoded
|
||||
+ self.skipped_invalid
|
||||
)
|
||||
|
|
@ -480,6 +513,37 @@ def is_moscow_address(address: str | None) -> bool:
|
|||
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
|
||||
|
||||
|
||||
def cian_subdomain(payload: dict) -> str | None:
|
||||
"""Поддомен `source_url` Циана: `https://<sub>.cian.ru/...` → `<sub>`.
|
||||
|
||||
Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому
|
||||
область читается из URL. Поддомен и префикс округа не противоречат друг
|
||||
другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026):
|
||||
`www` — 38 030 карточек (36 569 с префиксом округа, это Москва); все
|
||||
прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod,
|
||||
khimki, mytishchi, podolsk, odintsovo, …) — 0 карточек с префиксом округа.
|
||||
Хост не `*.cian.ru` или `source_url` отсутствует → None (консервативно:
|
||||
региону не сопоставляем).
|
||||
"""
|
||||
url = payload.get("source_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
# `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем
|
||||
# регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо
|
||||
# суффикса и уехал в «не целевой регион».
|
||||
host = urlsplit(url).hostname or ""
|
||||
if not host.endswith(".cian.ru"):
|
||||
return None
|
||||
sub = host[: -len(".cian.ru")]
|
||||
return sub or None
|
||||
|
||||
|
||||
def is_cian_oblast_payload(payload: dict) -> bool:
|
||||
"""Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`)."""
|
||||
sub = cian_subdomain(payload)
|
||||
return sub is not None and sub != "www"
|
||||
|
||||
|
||||
def is_moscow_yandex_address(address: str | None) -> bool:
|
||||
"""У Яндекса регион — второй компонент полного адреса.
|
||||
|
||||
|
|
@ -497,6 +561,17 @@ def is_moscow_yandex_address(address: str | None) -> bool:
|
|||
return len(parts) > 1 and parts[1] == "Москва"
|
||||
|
||||
|
||||
def is_oblast_yandex_address(address: str | None) -> bool:
|
||||
"""Регион 50 у Яндекса: второй компонент адреса — «Московская область».
|
||||
|
||||
Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются
|
||||
ровно два значения (см. докстринг модуля), третьего нет — среди карточек
|
||||
Яндекса «не Москва» и означает «область».
|
||||
"""
|
||||
parts = [part.strip() for part in (address or "").split(",")]
|
||||
return len(parts) > 1 and parts[1] == "Московская область"
|
||||
|
||||
|
||||
def is_moscow_domclick_address(address: str | None) -> bool:
|
||||
"""У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …».
|
||||
|
||||
|
|
@ -515,12 +590,70 @@ def is_moscow_domclick_address(address: str | None) -> bool:
|
|||
return bool(parts) and parts[0] == "Москва"
|
||||
|
||||
|
||||
# Источники, у которых город виден в самой карточке. Ключ отсутствует —
|
||||
# источник про город молчит, и без пред-геокода писать его нельзя (avito).
|
||||
CITY_FILTERS = {
|
||||
"cian": is_moscow_address,
|
||||
"yandex": is_moscow_yandex_address,
|
||||
"domclick": is_moscow_domclick_address,
|
||||
def is_oblast_domclick_address(address: str | None) -> bool:
|
||||
"""Регион 50 у ДомКлика: первый компонент адреса — «Московская область».
|
||||
|
||||
Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards`
|
||||
копит ДВА разных прогона сборщика (московский батч
|
||||
`msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без
|
||||
своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии
|
||||
вперемешку — фильтр по адресу обязателен, не опционален. Замер живьём на
|
||||
API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект
|
||||
Мельникова, 33», «Московская область, Одинцовский городской округ,
|
||||
Звенигород, 3-й м-н, 28» и подобные — первый компонент «Московская
|
||||
область» у всех 140 карточек выборки с семи разных смещений выдачи.
|
||||
"""
|
||||
parts = [part.strip() for part in (address or "").split(",")]
|
||||
return bool(parts) and parts[0] == "Московская область"
|
||||
|
||||
|
||||
def _payload_point(payload: dict) -> tuple[float, float] | None:
|
||||
"""(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются."""
|
||||
try:
|
||||
return float(payload["lat"]), float(payload["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_oblast_domclick_payload(payload: dict) -> bool:
|
||||
"""Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области.
|
||||
|
||||
Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка
|
||||
областного батча): «Московская область» стоит первым компонентом у 2 960, а
|
||||
у одной — «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293.
|
||||
Это настоящая область (Можайск), и строгий префикс выбросил бы её молча.
|
||||
Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ
|
||||
22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox
|
||||
Москвы (вложенный в областной) не может протащить столичную карточку в 50.
|
||||
"""
|
||||
address = payload.get("address")
|
||||
if is_oblast_domclick_address(address):
|
||||
return True
|
||||
if is_moscow_domclick_address(address):
|
||||
return False
|
||||
point = _payload_point(payload)
|
||||
if point is None:
|
||||
return False
|
||||
return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region)
|
||||
|
||||
|
||||
def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
|
||||
"""Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
|
||||
return lambda payload: fn(payload.get("address"))
|
||||
|
||||
|
||||
# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только
|
||||
# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него
|
||||
# молчит. Ключа нет только для источника, который вообще не умеет отличать
|
||||
# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар
|
||||
# фильтр обязан быть в реестре явно.
|
||||
REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = {
|
||||
("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address),
|
||||
("cian", OBLAST_REGION_CODE): is_cian_oblast_payload,
|
||||
("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address),
|
||||
("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address),
|
||||
("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address),
|
||||
("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -569,6 +702,7 @@ def import_msk_raw(
|
|||
db: Session,
|
||||
*,
|
||||
source: str = "cian",
|
||||
region: int = MOSCOW_REGION_CODE,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
limit: int | None = None,
|
||||
dry_run: bool = False,
|
||||
|
|
@ -577,32 +711,38 @@ def import_msk_raw(
|
|||
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
|
||||
) -> ImportCounters:
|
||||
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
|
||||
if region not in SUPPORTED_REGIONS:
|
||||
raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}")
|
||||
|
||||
view = SOURCE_VIEWS[source]
|
||||
counters = ImportCounters()
|
||||
matcher = RealMatcherAdapter()
|
||||
budget = GeocodeBudget(remaining=max(0, geocode_limit))
|
||||
|
||||
# Источник, который сам говорит про город: у Циана это префикс округа, у
|
||||
# Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему
|
||||
# нужен пред-геокод, поэтому его в словаре нет.
|
||||
city_filter = CITY_FILTERS.get(source)
|
||||
# Источник, который сам говорит про целевой регион: у Циана это префикс
|
||||
# округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика —
|
||||
# первый. Авито не говорит ничего ни для какого региона, ему нужен
|
||||
# пред-геокод, поэтому в реестре его нет вовсе.
|
||||
city_filter = REGION_FILTERS.get((source, region))
|
||||
if geocode and city_filter is not None:
|
||||
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
||||
# Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
|
||||
# незачем.
|
||||
raise SystemExit(f"source={source}: --geocode нужен только для avito")
|
||||
if city_filter is None and not geocode:
|
||||
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
|
||||
# ни города в адресе, ни координат. Пишем только по явному разрешению.
|
||||
# Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
|
||||
# прочих: ни города в адресе, ни координат. Пишем только по явному
|
||||
# разрешению.
|
||||
if not (dry_run or allow_unfiltered):
|
||||
raise SystemExit(
|
||||
f"source={source}: адрес не содержит признака города, Москву от "
|
||||
"области не отличить. Нужен --geocode (штатный путь), "
|
||||
f"source={source}: адрес не содержит признака города, регион "
|
||||
f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
|
||||
"--allow-unfiltered (аварийный) или --dry-run."
|
||||
)
|
||||
logger.warning(
|
||||
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
|
||||
"строки лягут без geom и вперемешку с областью",
|
||||
"source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
|
||||
"вовсе; строки лягут без geom и вперемешку с прочими регионами",
|
||||
source,
|
||||
region,
|
||||
)
|
||||
if geocode and not dry_run:
|
||||
db.execute(text(_GEO_CACHE_DDL))
|
||||
|
|
@ -618,16 +758,16 @@ def import_msk_raw(
|
|||
if is_artifact_address(address):
|
||||
counters.skipped_artifact += 1
|
||||
continue
|
||||
if city_filter is not None and not city_filter(address):
|
||||
counters.skipped_not_moscow += 1
|
||||
if city_filter is not None and not city_filter(payload):
|
||||
counters.skipped_not_target_region += 1
|
||||
continue
|
||||
if geocode:
|
||||
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
|
||||
if point is None:
|
||||
counters.skipped_ungeocoded += 1
|
||||
continue
|
||||
if point.region_code != MOSCOW_REGION_CODE:
|
||||
counters.skipped_oblast += 1
|
||||
if point.region_code != region:
|
||||
counters.skipped_geo_other_region += 1
|
||||
continue
|
||||
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
|
||||
# трогаем, пересбор корпуса от этого не зависит. geom появляется
|
||||
|
|
@ -649,19 +789,26 @@ def import_msk_raw(
|
|||
if dry_run or not lots:
|
||||
continue
|
||||
|
||||
# У региона 50 своего единого города нет (`canonical_city is None` в
|
||||
# реестре regions) — пишем city=None, `save_listings` его COALESCE'ит
|
||||
# и существующее значение не затирает. Подбор аналогов не страдает:
|
||||
# он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП
|
||||
# ключуется на `deals.city`, а не на `listings.city`.
|
||||
city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None
|
||||
inserted, updated = save_listings(
|
||||
db,
|
||||
lots,
|
||||
matcher=matcher,
|
||||
region_code=MOSCOW_REGION_CODE,
|
||||
city=MOSCOW_CITY,
|
||||
region_code=region,
|
||||
city=city,
|
||||
)
|
||||
counters.inserted += inserted
|
||||
counters.updated += updated
|
||||
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
|
||||
logger.info(
|
||||
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||
"msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
|
||||
source,
|
||||
region,
|
||||
counters.read,
|
||||
counters.selected,
|
||||
counters.written,
|
||||
|
|
@ -679,11 +826,12 @@ def import_msk_raw(
|
|||
geocode_limit,
|
||||
)
|
||||
logger.info(
|
||||
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
|
||||
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||
"msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
|
||||
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
|
||||
"геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
|
||||
"геокод-вызовов=%d | сходится=%s",
|
||||
source,
|
||||
region,
|
||||
" (dry-run)" if dry_run else "",
|
||||
counters.read,
|
||||
counters.selected,
|
||||
|
|
@ -691,8 +839,8 @@ def import_msk_raw(
|
|||
counters.inserted,
|
||||
counters.updated,
|
||||
counters.writer_skipped if not dry_run else 0,
|
||||
counters.skipped_not_moscow,
|
||||
counters.skipped_oblast,
|
||||
counters.skipped_not_target_region,
|
||||
counters.skipped_geo_other_region,
|
||||
counters.skipped_ungeocoded,
|
||||
counters.skipped_artifact,
|
||||
counters.skipped_invalid,
|
||||
|
|
@ -707,21 +855,30 @@ def main() -> None:
|
|||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)"
|
||||
)
|
||||
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
|
||||
parser.add_argument(
|
||||
"--region",
|
||||
type=int,
|
||||
choices=SUPPORTED_REGIONS,
|
||||
default=MOSCOW_REGION_CODE,
|
||||
help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область",
|
||||
)
|
||||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
|
||||
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
|
||||
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
|
||||
parser.add_argument(
|
||||
"--allow-unfiltered",
|
||||
action="store_true",
|
||||
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
|
||||
help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geocode",
|
||||
action="store_true",
|
||||
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
|
||||
"в listings уходит только регион 77, область откладывается",
|
||||
"в listings уходит только целевой регион (--region), прочее пропускается",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geocode-limit",
|
||||
|
|
@ -737,6 +894,7 @@ def main() -> None:
|
|||
import_msk_raw(
|
||||
db,
|
||||
source=args.source,
|
||||
region=args.region,
|
||||
batch_size=args.batch_size,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б
|
|||
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||||
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||||
(detail_enriched_at IS NULL) возьмёт её снова.
|
||||
|
||||
Вечный недогруз (#3191, 13.09–16.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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
-- 302_scrape_schedules_seed_rosreestr_dkp_50.sql
|
||||
-- Seed-строка scrape_schedules для региона 50 (Московская область) — #3051, трек МО.
|
||||
--
|
||||
-- Dependencies: 289_rosreestr_fdw_msk_columns_seed77.sql (та же таблица, тот же
|
||||
-- формат source/default_params).
|
||||
-- Apply after: 301_web_support_message_idempotency_key.sql
|
||||
--
|
||||
-- WHY:
|
||||
-- Код-часть импорта уже параметризована регионом (`scheduler.py::_job_rosreestr_dkp`
|
||||
-- читает `region_code` из default_params и валидирует его по `app.services.regions`),
|
||||
-- а wildcard-хендлер `rosreestr_dkp_import_*` резолвит любое имя с суффиксом кода.
|
||||
-- Регион 50 заведён в реестре и уже на проде, поэтому включение области стоит ровно
|
||||
-- одной строки расписания — новой логики не требуется.
|
||||
--
|
||||
-- У региона 50 `canonical_city IS NULL`, то есть он идёт по ветке региона 66:
|
||||
-- `deals.city` берётся из источника (муниципалитет — Балашиха, Химки, Подольск),
|
||||
-- `raw_payload` не заполняется, строки с пустым city отбрасываются. Для области это
|
||||
-- и есть верное поведение: единого города у региона нет, подставлять нечего.
|
||||
--
|
||||
-- Замер по FDW (2026-09-12, прод): под полным WHERE импорта регион 50 даёт
|
||||
-- 113 351 сделку ДКП с 2024-01-01 — корпус того же порядка, что московский.
|
||||
--
|
||||
-- Строка ВЫКЛЮЧЕНА (enabled=false) — ровно как seed 77 в миграции 289: миграция
|
||||
-- заводит расписание, включение и первый прогон остаются отдельным решением
|
||||
-- main-сессии. Окно 4-6 UTC совпадает с окнами регионов 66 и 77; прогоны
|
||||
-- сериализуются планировщиком, а первая полная заливка 77 заняла 3 минуты на
|
||||
-- 212 937 строк, так что третий регион в то же окно помещается с запасом.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и,
|
||||
-- что важнее, НЕ сбрасывает enabled обратно в false после ручного включения.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
default_params
|
||||
)
|
||||
VALUES (
|
||||
'rosreestr_dkp_import_50',
|
||||
false,
|
||||
4,
|
||||
6,
|
||||
'{"region_code": 50, "since": "2024-01-01", "batch_size": 2000}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
-- 303_scrape_schedules_seed_landing_showcase_deals.sql
|
||||
-- Расписание для пересчёта витрины сделок публичного лэндинга (issue #3469).
|
||||
--
|
||||
-- ЧТО БЫЛО. Задача `landing_showcase_deals` (миграции 276/277, таблицы
|
||||
-- landing_showcase_deals + landing_showcase_runs) в scrape_schedules НЕ СТОЯЛА:
|
||||
-- `SELECT * FROM scrape_schedules WHERE source LIKE '%showcase%'` — 0 строк
|
||||
-- (замер на проде 12.09.2026). Пересчёт был ручным шагом, и за всё время его
|
||||
-- запускали четырежды; на 12.09 лэндинг показывал прогон от 30.08 — тринадцать
|
||||
-- суток. Handler в реестре тоже отсутствовал, то есть строка расписания без
|
||||
-- него не помогла бы: обе половины регистрации задачи (Handler в
|
||||
-- app/services/product_handlers.py + вот эта строка) едут одним PR.
|
||||
--
|
||||
-- ТАКТ — СУТКИ, И СЧИТАЕТСЯ ОН НЕ ОТ ДАННЫХ, А ОТ КОДА.
|
||||
-- Вход витрины — ДКП-сделки Росреестра, они приезжают ПОКВАРТАЛЬНО, и по
|
||||
-- входу хватило бы такта в квартал. Но витрина показывает не сделки, а
|
||||
-- РАСХОЖДЕНИЕ прогноза МЕРЫ с ценой сделки, а прогноз пересчитывается тем же
|
||||
-- спайном оценщика, что и боевой расчёт: любая правка оценщика, коэффициентов
|
||||
-- СберИндекса, набора активных объявлений или правила отбора (миграция 276,
|
||||
-- полоса −5..+20 % от 12.09.2026) меняет ЧИСЛА на странице, не трогая ни одной
|
||||
-- сделки. Деплой у продукта чаще, чем квартал, — поэтому такт суточный: столько
|
||||
-- живёт окно «код уже другой, а витрина ещё прежняя». Прогон дешёвый и без
|
||||
-- внешних вызовов (200 сделок через спайн + запись 20 строк, ~минуты CPU
|
||||
-- ночью), так что цена суточного такта — та же, что у соседнего
|
||||
-- landing_stats_refresh (миграция 275).
|
||||
--
|
||||
-- ЭТА ЖЕ СТРОКА ЗАВОДИТ ВИТРИНУ В МОНИТОР СВЕЖЕСТИ. Сводка просроченных
|
||||
-- источников (`emit_stale_digest`, scraper_kit/orchestration/scheduler.py,
|
||||
-- #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и бьёт тревогу (logger.error →
|
||||
-- GlitchTip), когда источник не приносил данных дольше
|
||||
-- STALE_DIGEST_INTERVAL_FACTOR × его такта — то есть здесь дольше ТРЁХ СУТОК.
|
||||
-- Отдельного монитора для витрины не заводится намеренно: её молчание было
|
||||
-- невидимо ровно потому, что источника не существовало для сводки, а не потому,
|
||||
-- что сводка не умеет про него говорить (живой пример с прода 12.09.2026:
|
||||
-- «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep
|
||||
-- 3.5d/1d»). interval_days в default_params стоит ЯВНО — им же сводка считает
|
||||
-- порог (`_schedule_interval_days`), и умолчание «1» лучше не подразумевать.
|
||||
--
|
||||
-- ОКНО 06:00–07:00 UTC (11:00–12:00 по Екатеринбургу): после импорта сделок
|
||||
-- Росреестра (rosreestr_dkp_import, окно 04–06) и после landing_stats_refresh
|
||||
-- (05–06) — витрина считается по уже обновлённым за ночь данным; и за два часа
|
||||
-- до deals_freshness_monitor (08–09), так что утренний пересчёт успевает
|
||||
-- сняться с просрочки до утренней же проверки.
|
||||
--
|
||||
-- enabled=true — как у landing_stats_refresh: задача только читает базу и
|
||||
-- перезаписывает две свои маленькие таблицы, внешних вызовов нет, цена ошибки —
|
||||
-- минуты CPU. Дожидаться ручного включения тут значило бы оставить дефект
|
||||
-- #3469 на месте, просто под другой причиной.
|
||||
--
|
||||
-- next_run_at на завтра 06:00 UTC — прогон не выстреливает в момент деплоя
|
||||
-- (образец: 162_seed_deals_freshness_monitor.sql, 275_landing_stats.sql).
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 276/277
|
||||
-- (таблицы витрины), Handler 'landing_showcase_deals' в product_handlers.py.
|
||||
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): блокирующий DDL/DML под lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'landing_showcase_deals',
|
||||
true,
|
||||
6,
|
||||
7,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
|
||||
'{"interval_days": 1, "sample": 200, "limit": 20}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
-- 304_asking_to_sold_ratios_region.sql
|
||||
-- asking_to_sold_ratios: ключ (region_code, rooms_bucket, district) — #3512.
|
||||
--
|
||||
-- ПРОБЛЕМА. asking_to_sold_ratios (миграция 080) несёт РОВНО 6 строк (per area-бакет
|
||||
-- 0..4 + global -1), все с district='' — колонки региона в таблице НЕТ ВООБЩЕ (проверено
|
||||
-- на проде 13.09.2026: `\d asking_to_sold_ratios` без region_code). Деривация
|
||||
-- (app/tasks/asking_to_sold_ratio.py) скоупит ОБЕ стороны расчёта на
|
||||
-- `city ILIKE '%Екатеринбург%'` — ratio физически посчитан только по рынку ЕКБ. Но
|
||||
-- estimator.py (_get_asking_sold_ratio) читает строку по `rooms_bucket AND district=''`
|
||||
-- БЕЗ единого понятия региона запроса — тот же коэффициент домножает asking-цену ЛЮБОГО
|
||||
-- адреса, включая Москву (region_code=77) и Московскую обл. (region_code=50). Рынки
|
||||
-- разные на порядок: медиана ДКП за 12 мес по region_code — 66: 90 278 ₽/м²,
|
||||
-- 50: 161 176 ₽/м², 77: 322 976 ₽/м² (замер 13.09.2026) — екатеринбургский sold/asking
|
||||
-- коэффициент, применённый к московской asking-цене, calibration mismatch, а не оценка.
|
||||
--
|
||||
-- ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ (только схема — derivation региона 66 остаётся байт-в-байт
|
||||
-- прежней, деривация ДРУГИХ регионов теперь считается отдельным SQL-путём в
|
||||
-- app/tasks/asking_to_sold_ratio.py, сама эта миграция ничего не пересчитывает):
|
||||
-- 1. ADD COLUMN region_code int NOT NULL DEFAULT 66 — существующие 6 строк ВСЕ
|
||||
-- екатеринбургские (единственная деривация на момент 080/098/109), DEFAULT
|
||||
-- backfill'ит их на 66 автоматически при ALTER (Postgres 11+ — DEFAULT на
|
||||
-- NOT NULL столбце без DEFAULT ранее вычисляется один раз, без rewrite всей
|
||||
-- таблицы, но здесь таблица микроскопическая — 6 строк, cost неощутим в любом
|
||||
-- случае).
|
||||
-- 2. PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district) — строки
|
||||
-- теперь живут per (регион, area-бакет, district). district остаётся зарезервирован
|
||||
-- под #647 (гео-районы ВНУТРИ региона), всегда ''.
|
||||
--
|
||||
-- ПОЧЕМУ НЕ НОВАЯ ПАРАЛЛЕЛЬНАЯ ТАБЛИЦА (в отличие от 098 tiered-подхода). Там
|
||||
-- параллельная таблица защищала fetchone()-safety при flag-OFF откате (#928). Здесь
|
||||
-- отката не предполагается: region_code — обязательный параметр запроса везде, где
|
||||
-- ratio читается (estimator._get_asking_sold_ratio), и ADD COLUMN NOT NULL DEFAULT 66
|
||||
-- не меняет СУЩЕСТВУЮЩИЕ 6 строк по значению (только добавляет им явный регион) —
|
||||
-- старый flag-OFF путь `WHERE rooms_bucket=B AND district=''` для региона 66
|
||||
-- продолжает возвращать ТУ ЖЕ строку, если добавить `AND region_code=66` (что и
|
||||
-- делает estimator.py в этом же PR).
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS — повторный прогон no-op на колонке.
|
||||
-- PK-guard (как 298_deal_city_price_bands_region.sql) смотрит ТЕКУЩЕЕ определение PK
|
||||
-- через pg_get_constraintdef и меняет его ТОЛЬКО если это ещё старый
|
||||
-- PK(rooms_bucket, district) — повторный прогон видит PK(region_code, rooms_bucket,
|
||||
-- district) и ничего не трогает. strict/exit-1 на деплое — без SET LOCAL lock_timeout
|
||||
-- ALTER TABLE мог бы зависнуть под конкурентным чтением; таблица in-memory-масштаба
|
||||
-- (6 строк), но конвенция проекта (#2752) требует потолок на любой блокирующий DDL.
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 080_asking_to_sold_ratios.sql (таблица, PK(rooms_bucket, district)),
|
||||
-- 002_core_tables.sql (deals.region_code, listings.region_code — обе уже существуют).
|
||||
-- Apply after: 303_scrape_schedules_seed_landing_showcase_deals.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE asking_to_sold_ratios
|
||||
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
|
||||
|
||||
COMMENT ON COLUMN asking_to_sold_ratios.region_code IS
|
||||
'Регион коэффициента (deals.region_code / listings.region_code, #3512). '
|
||||
'Существующие строки (посчитаны деривацией, скоупленной на ЕКБ, #C2) — 66. '
|
||||
'Часть составного PK (region_code, rooms_bucket, district).';
|
||||
|
||||
-- PK (rooms_bucket, district) -> PK (region_code, rooms_bucket, district), идемпотентно:
|
||||
-- смотрим ТЕКУЩЕЕ определение PK и меняем его только если это ещё старый PK без региона.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_pk_def text;
|
||||
BEGIN
|
||||
SELECT pg_get_constraintdef(oid) INTO v_pk_def
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
||||
AND contype = 'p';
|
||||
|
||||
IF v_pk_def = 'PRIMARY KEY (rooms_bucket, district)' THEN
|
||||
ALTER TABLE asking_to_sold_ratios DROP CONSTRAINT asking_to_sold_ratios_pkey;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'asking_to_sold_ratios'::regclass
|
||||
AND contype = 'p'
|
||||
) THEN
|
||||
ALTER TABLE asking_to_sold_ratios
|
||||
ADD CONSTRAINT asking_to_sold_ratios_pkey
|
||||
PRIMARY KEY (region_code, rooms_bucket, district);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
-- 305_scrape_schedules_seed_msk_city_sweeps.sql
|
||||
-- Регулярный сбор объявлений по Москве (77) и Московской области (50).
|
||||
--
|
||||
-- Apply after: 304_asking_to_sold_ratios_region.sql
|
||||
--
|
||||
-- WHY:
|
||||
-- Корпус 77/50 живёт разовой загрузкой: всё, что есть, собрано 10-12.09.2026.
|
||||
-- При LISTINGS_FRESH_DAYS = 14 свежих объявлений не останется с 26-27.09, после
|
||||
-- чего оценка по этим регионам деградирует до релаксированного тира, а
|
||||
-- asking->sold ratio перестанет переписываться. Регулярного сбора для 77/50 в
|
||||
-- расписании не было ни одной строки: код якорей, гео-профилей и региональной
|
||||
-- параметризации слит (#3533), но не включён.
|
||||
--
|
||||
-- Слаги "moskva" / "moskovskaya_oblast" уже заведены в CITY_ANCHORS
|
||||
-- (pipeline.py): 25 якорей сетки 5x5 по Москве и 44 по области (22 городских +
|
||||
-- 22 кластерных по массе объявлений). Wildcard-хендлеры "*_city_sweep_*"
|
||||
-- резолвят эти source'ы на те же джобы, что и ЕКБ.
|
||||
--
|
||||
-- radius_m задан ЯВНО и равен тому, на котором мерили покрытие сетки: 8000 м по
|
||||
-- Москве, 10000 м по области (MOSCOW_ANCHOR_RADIUS_M / MOSCOW_OBLAST_ANCHOR_
|
||||
-- RADIUS_M). Дефолт джобы — 1500 м; оставить его значило бы обрушить покрытие,
|
||||
-- ради которого якоря и подбирались (замер: 73.8% -> 95.2% массы лотов области).
|
||||
--
|
||||
-- newbuilding_only=false у cian — дефолт джобы true, а корпус нужен вторичный.
|
||||
-- Тот же ключ стоит во всех oblast-строках миграции 179.
|
||||
--
|
||||
-- ВКЛЮЧЕНЫ СРАЗУ (enabled=true), в отличие от seed-строк 289/302, где включение
|
||||
-- оставляли отдельным решением. Причина названа выше: без сбора корпус истекает
|
||||
-- 26-27.09, а регионы отдаются тестировщикам. Решение принято явно 16.09.2026.
|
||||
--
|
||||
-- DomClick (≈23% корпуса обоих регионов) здесь НЕ заводится: у него нет
|
||||
-- wildcard-хендлера ("domclick_city_sweep_*" отсутствует в _default_kit_handlers),
|
||||
-- а единственная возможная строка source='domclick_city_sweep' занята
|
||||
-- Екатеринбургом. Нужна правка кода — отдельной задачей.
|
||||
--
|
||||
-- Окна 16-23 UTC выбраны как наименее занятые (по одной строке на час против
|
||||
-- 5-7 в ночных окнах); прогоны всё равно сериализуются планировщиком, а
|
||||
-- трёхчасовое окно даёт запас на overrun предшественника.
|
||||
--
|
||||
-- Оценка нагрузки на такт: Москва avito 25x3 страниц при 7 с ≈ 9 мин, cian при
|
||||
-- 5 с ≈ 6 мин, yandex при 9 с ≈ 11 мин; область (44 якоря) ≈ 15 / 11 / 20 мин.
|
||||
-- Суммарно ≈ 72 мин раз в 3 суток.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и не
|
||||
-- сбрасывает enabled, если строку потом выключили вручную.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
INSERT INTO scrape_schedules (source, enabled, window_start_hour, window_end_hour, default_params)
|
||||
VALUES
|
||||
('avito_city_sweep_moskva', true, 16, 19,
|
||||
'{"city": "moskva", "region_code": 77, "radius_m": 8000, "pages_per_anchor": 3,
|
||||
"detail_top_n": 0, "enrich_houses": true, "interval_days": 3,
|
||||
"request_delay_sec": 7}'::jsonb),
|
||||
('cian_city_sweep_moskva', true, 17, 20,
|
||||
'{"city": "moskva", "region_code": 77, "radius_m": 8000, "pages_per_anchor": 3,
|
||||
"detail_top_n": 10, "enrich_houses": true, "interval_days": 3,
|
||||
"newbuilding_only": false, "request_delay_sec": 5}'::jsonb),
|
||||
('yandex_city_sweep_moskva', true, 18, 21,
|
||||
'{"city": "moskva", "region_code": 77, "radius_m": 8000, "pages_per_anchor": 3,
|
||||
"interval_days": 3, "request_delay_sec": 9}'::jsonb),
|
||||
('avito_city_sweep_moskovskaya_oblast', true, 19, 22,
|
||||
'{"city": "moskovskaya_oblast", "region_code": 50, "radius_m": 10000,
|
||||
"pages_per_anchor": 3, "detail_top_n": 0, "enrich_houses": true,
|
||||
"interval_days": 3, "request_delay_sec": 7}'::jsonb),
|
||||
('cian_city_sweep_moskovskaya_oblast', true, 20, 23,
|
||||
'{"city": "moskovskaya_oblast", "region_code": 50, "radius_m": 10000,
|
||||
"pages_per_anchor": 3, "detail_top_n": 10, "enrich_houses": true,
|
||||
"interval_days": 3, "newbuilding_only": false, "request_delay_sec": 5}'::jsonb),
|
||||
('yandex_city_sweep_moskovskaya_oblast', true, 21, 23,
|
||||
'{"city": "moskovskaya_oblast", "region_code": 50, "radius_m": 10000,
|
||||
"pages_per_anchor": 3, "interval_days": 3, "request_delay_sec": 9}'::jsonb)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
-- 306_scrape_schedules_seed_domclick_msk.sql
|
||||
-- Регулярный сбор ДомКлика по Москве (77) и Московской области (50).
|
||||
--
|
||||
-- Apply after: 305_scrape_schedules_seed_msk_city_sweeps.sql
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграция 305 завела регулярный сбор 77/50 для avito / cian / yandex и прямо
|
||||
-- отложила ДомКлик: "у него нет wildcard-хендлера, а единственная возможная
|
||||
-- строка source='domclick_city_sweep' занята Екатеринбургом. Нужна правка кода".
|
||||
-- Правка сделана в этом же PR — "domclick_city_sweep_*" добавлен в
|
||||
-- _default_kit_handlers (scraper-kit) И в build_product_handlers (app-side
|
||||
-- override с инъекцией куки Sber ID, #3264). Эта миграция — вторая половина.
|
||||
--
|
||||
-- Цена вопроса: ДомКлик даёт ≈23% корпуса обоих регионов. Без него свежий срез
|
||||
-- 77/50 теряет почти четверть массы, а вместе с ней — часть домов, по которым
|
||||
-- вообще существуют аналоги.
|
||||
--
|
||||
-- Гео-скоуп задаётся ТОЛЬКО region_code: профиль региона
|
||||
-- (providers/domclick/serp.py DOMCLICK_GEO_PROFILES) несёт address GUID,
|
||||
-- страницу прогрева QRATOR, bbox гарда и имя города. Профили 77 и 50 заведены и
|
||||
-- проверены живыми запросами 12-15.09.2026 (#3533). city_id здесь НЕ задаётся
|
||||
-- намеренно: он vestigial — run_domclick_city_sweep штампует город из профиля,
|
||||
-- а не из city_id (pipeline.py, комментарий у save_listings). Неизвестный
|
||||
-- region_code роняет прогон ДО первого запроса, тихого отката на ЕКБ нет.
|
||||
--
|
||||
-- pages_per_anchor=100 — как у ЕКБ-строки: ДомКлик citywide, anchor-loop у него
|
||||
-- отсутствует (fetch_around → NotImplementedError), "pages" здесь означает
|
||||
-- глубину пагинации на комнатный бакет, а не число якорей. Москва отдаёт ≈23 690
|
||||
-- лотов вторички, то есть глубину надо брать полную, а не свипную.
|
||||
--
|
||||
-- Окна 0-3 и 3-6 UTC: 305 занял 16-23, ЕКБ-домклик сидит в 3-6. Ставим Москву в
|
||||
-- 0-3 (пусто), область — в 3-6 рядом с ЕКБ; прогоны всё равно сериализуются
|
||||
-- планировщиком, окно лишь ограничивает старт.
|
||||
--
|
||||
-- interval_days=3 — как у остальных строк 305: корпус живёт 14 суток
|
||||
-- (LISTINGS_FRESH_DAYS), трёхсуточный такт даёт четырёхкратный запас на
|
||||
-- пропуски из-за QRATOR-банов.
|
||||
--
|
||||
-- ВКЛЮЧЕНЫ СРАЗУ (enabled=true) по той же причине, что и 305: регионы уходят
|
||||
-- тестировщикам, корпус истекает.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: ON CONFLICT (source) DO NOTHING — повторный прогон no-op и не
|
||||
-- сбрасывает enabled, если строку потом выключили вручную.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
INSERT INTO scrape_schedules (source, enabled, window_start_hour, window_end_hour, default_params)
|
||||
VALUES
|
||||
('domclick_city_sweep_moskva', true, 0, 3,
|
||||
'{"region_code": 77, "pages_per_anchor": 100, "interval_days": 3,
|
||||
"request_delay_sec": 6}'::jsonb),
|
||||
('domclick_city_sweep_moskovskaya_oblast', true, 3, 6,
|
||||
'{"region_code": 50, "pages_per_anchor": 100, "interval_days": 3,
|
||||
"request_delay_sec": 6}'::jsonb)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
-- 307_domclick_msk_window_no_collision.sql
|
||||
-- Развести окна ДомКлика: областной свип не должен стартовать вместе с ЕКБ-свипом.
|
||||
--
|
||||
-- Apply after: 306_scrape_schedules_seed_domclick_msk.sql
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграция 306 поставила 'domclick_city_sweep_moskovskaya_oblast' в окно 3-6 — РОВНО
|
||||
-- то же, где с самого начала сидит ЕКБ-строка 'domclick_city_sweep'. Планировщик
|
||||
-- домклик-джобы не сериализует: 17.09.2026 обе новые строки были заклеймлены в одну
|
||||
-- секунду (run_id 7333 и 7334, 05:00:55) и пошли параллельно.
|
||||
--
|
||||
-- Почему это плохо, без домыслов о причинности: два свипа ДомКлика делят ОДИН
|
||||
-- браузерный сайдкар и ОДИН пул. В минуты параллельного прогона tradein-browser
|
||||
-- держал 67% CPU, 1.67 из 2.5 ГиБ и 2297 PID, а часть запросов возвращалась как
|
||||
-- 'Page.goto: Timeout 60000ms' и 'socket hang up' → HTTP 500. Узлов с
|
||||
-- provider_affinity='any' в пуле всего два (id 13 и 14) — их же берёт cian, так что
|
||||
-- параллельная пара домклик-свипов забирает оба.
|
||||
--
|
||||
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ УТВЕРЖДАЕТ: что параллельность уронила сбор. Померить это по
|
||||
-- ходу прогона нечем — DomClickScraper.fetch_city копит лоты в память и сохраняет их
|
||||
-- ОДНИМ save_listings после обхода всех шести бакетов, инкрементального сохранения
|
||||
-- нет. Поэтому 'ноль строк в listings за 45 минут' — нормальное состояние середины
|
||||
-- прогона (здоровый одиночный ЕКБ-свип 16.09, run 7219, тоже не писал ничего 70 минут
|
||||
-- и затем положил 6372 seen / 237 new). Разводим окна по ресурсной причине выше, а не
|
||||
-- по выводу из этой метрики.
|
||||
--
|
||||
-- Новое окно области — 9-12: там нет НИ ОДНОЙ домклик-строки (домклик-джобы всего
|
||||
-- три: 'domclick_city_sweep' 3-6, 'domclick_city_sweep_moskva' 0-3 и
|
||||
-- 'domclick_detail_backfill' 15-18). Москва остаётся в 0-3 — она и так единственный
|
||||
-- домклик в этом окне.
|
||||
--
|
||||
-- next_run_at выставляется ЯВНО и разнесён: без этого строки сохранили бы значения,
|
||||
-- посчитанные при первом клейме (Москва 20.09 01:46, область 20.09 04:11), а 04:11
|
||||
-- попадает внутрь ЕКБ-окна 3-6 — та же коллизия повторилась бы через трое суток.
|
||||
--
|
||||
-- Скобки + 'AT TIME ZONE UTC' на РЕЗУЛЬТАТЕ, а не только внутри: next_run_at —
|
||||
-- timestamptz, а date_trunc от naive-времени вернул бы timestamp без зоны, и приведение
|
||||
-- поехало бы по таймзоне сессии. Здесь обе стороны явные.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source = '...' — повторный прогон переписывает те
|
||||
-- же значения. enabled НЕ трогаем: если строку выключили вручную, она останется
|
||||
-- выключенной.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET window_start_hour = 9,
|
||||
window_end_hour = 12,
|
||||
next_run_at = (date_trunc('day', now() AT TIME ZONE 'UTC')
|
||||
+ interval '3 days 9 hours 30 minutes') AT TIME ZONE 'UTC'
|
||||
WHERE source = 'domclick_city_sweep_moskovskaya_oblast';
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET next_run_at = (date_trunc('day', now() AT TIME ZONE 'UTC')
|
||||
+ interval '3 days 0 hours 30 minutes') AT TIME ZONE 'UTC'
|
||||
WHERE source = 'domclick_city_sweep_moskva';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
-- 308_domclick_msk_disable_until_incremental_save.sql
|
||||
-- Выключить домклик-свипы 77/50: в текущем виде они по построению не могут ничего собрать.
|
||||
--
|
||||
-- Apply after: 307_domclick_msk_window_no_collision.sql
|
||||
--
|
||||
-- WHY (замерено 17.09.2026 одиночным прогоном, без соседей по сайдкару и пулу):
|
||||
--
|
||||
-- run 7344, source='domclick_city_sweep_moskva', прокси id=13, стартовал 06:12:48.
|
||||
-- За ДВА часа пройдено 2 бакета из 6: 'st' занял 34.2 мин, '1' шёл 86+ мин и не
|
||||
-- закончился. Watchdog прогона — 11100 с (3 ч 05 мин).
|
||||
--
|
||||
-- Это НЕ одновременность и НЕ блок площадки: прогон был единственным, узел не банился,
|
||||
-- QRATOR-блока в логе нет. Причина структурная — ДомКлик режет offset на 2000, поэтому
|
||||
-- на больших выдачах скрейпер бисекцией делит диапазон по цене на листья, и КАЖДЫЙ лист
|
||||
-- пагинируется отдельно. У Москвы ≈23 690 лотов вторички против ≈6 300 у ЕКБ, листьев
|
||||
-- кратно больше. А формула watchdog'а (pipeline.run_domclick_city_sweep) считает число
|
||||
-- фетчей как _DOMCLICK_NUM_BUCKETS * pages = 6 * 100 и объём выдачи не учитывает вовсе:
|
||||
-- 11100 = 600 * (6 + 12) + 300. Она линейна по pages, поэтому уменьшение
|
||||
-- pages_per_anchor урезает и сам watchdog — лечения не даёт.
|
||||
--
|
||||
-- Хуже, чем просто «не успевает». _domclick_phase — «единственная citywide-фаза:
|
||||
-- fetch_city + save»: лоты копятся в памяти и сохраняются ОДНИМ save_listings после
|
||||
-- всех шести бакетов. Снятие фазы по watchdog (asyncio.wait_for → TimeoutError) теряет
|
||||
-- всё собранное. При этом чекпоинт #3118 пишется ПОСЛЕ этого, из живой ссылки на
|
||||
-- скрейпер: done_buckets = унаследованное ∪ _s.completed_buckets. То есть бакеты
|
||||
-- помечаются пройденными, хотя ни одна их строка не сохранена, и следующий прогон
|
||||
-- пропускает их через skip_buckets. Два-три таких прогона закрывают чекпоинт целиком —
|
||||
-- итоговый сбор по 77/50 равен нулю навсегда.
|
||||
--
|
||||
-- Пока строки включены, вред не нулевой: каждые трое суток каждая забирает один из ДВУХ
|
||||
-- узлов с provider_affinity='any' (id 13 и 14) на три часа и не отдаёт ничего. 17.09
|
||||
-- из-за этого ЕКБ-свип run 7339 отбился 'banned' за 0 секунд, пока оба узла держали
|
||||
-- московские прогоны. Выключение восстанавливает статус-кво ЕКБ.
|
||||
--
|
||||
-- ЧТО НУЖНО ДЛЯ ВОЗВРАТА (не в этой миграции — это правка кода в scraper-kit):
|
||||
-- 1. Инкрементальное сохранение по бакетам: save_listings внутри цикла fetch_city
|
||||
-- (колбэк on_bucket, как у run_cian_full_load), чтобы снятие по watchdog сохраняло
|
||||
-- собранное, а чекпоинт перестал врать.
|
||||
-- 2. Watchdog, зависящий от объёма выдачи (snippetsCount), а не только от pages.
|
||||
-- 3. Кооперативная отмена по бакетам: runs.is_cancelled сейчас проверяется только ПЕРЕД
|
||||
-- SERP-фазой, поэтому повисший свип нельзя снять до watchdog'а.
|
||||
-- 4. При возврате — сбросить унаследованный чекпоинт (counters.done_buckets прогонов
|
||||
-- 7333/7344), иначе resume пропустит бакеты 'st' и '1', по которым ничего не сохранено.
|
||||
--
|
||||
-- ЕКБ-строка 'domclick_city_sweep' НЕ трогается: её выдача помещается в watchdog
|
||||
-- (run 7219 16.09 — 70 минут, 6372 seen / 237 new), у неё дефект не проявляется.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source IN (...) — повторный прогон переписывает то же
|
||||
-- значение. Возврат — ручным UPDATE enabled = true после правок выше, либо новой
|
||||
-- миграцией.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET enabled = false
|
||||
WHERE source IN ('domclick_city_sweep_moskva', 'domclick_city_sweep_moskovskaya_oblast');
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
-- 310_yandex_seed_decimal_slips.sql
|
||||
-- Удалить из offer_price_history yandex-посевы, отличающиеся от следующей точки на
|
||||
-- порядок и больше (#3385).
|
||||
--
|
||||
-- Apply after: 307_domclick_msk_window_no_collision.sql
|
||||
--
|
||||
-- ЧТО НЕ ТАК
|
||||
-- services/yandex_price_history.py при пустой истории сеет точку из gate-поля
|
||||
-- price.previous: change_time = now − 1 сутки (синтетическое время), следом текущая
|
||||
-- цена лота с change_time = now; обе строки одним INSERT-батчем, поэтому recorded_at у
|
||||
-- них совпадает. У 11 объявлений price.previous — не рынок: 1 370 000 → 13 700 000
|
||||
-- (87.8 м², нижняя цена 15.6 тыс/м²), 10 900 → 10 900 000 (цена в тысячах). Замер
|
||||
-- прода 17.09.2026 по всем 8313 yandex-посевам: ×10 — 7 (одно из них ×9.74), ×175…×1000
|
||||
-- — 4, «вниз» — 0; самый большой скачок ниже порога — ×2.13, между ×2.13 и ×9.74 нет
|
||||
-- ни одного. Ошибочна именно НИЖНЯЯ, посевная точка: у нижних 0.1–37 тыс/м², у верхних
|
||||
-- 118–375 тыс/м²; верхняя совпадает с listings.price_rub у 10 из 11, а 10387563 дальше
|
||||
-- дешевеет от неё же (14M → 13M → 12M, триггерные строки).
|
||||
--
|
||||
-- Код починен в том же PR: посев на порядок и больше больше не пишется. Эта миграция —
|
||||
-- задним числом по уже записанным строкам.
|
||||
--
|
||||
-- КРИТЕРИЙ — ПОДПИСЬ ПОСЕВА, А НЕ ТОЛЬКО ОТНОШЕНИЕ
|
||||
-- Удаляется строка o, только если у того же объявления есть строка s, записанная тем же
|
||||
-- батчем (s.recorded_at = o.recorded_at) ровно через 24 часа (s.change_time =
|
||||
-- o.change_time + 24 hours), и цены разнятся в 9.5 раза и больше. Так посевы пишет
|
||||
-- только этот писатель; триггерные строки (change_time = recorded_at) и строки других
|
||||
-- источников условием не задеваются. Интервал '24 hours', а не '1 day': прибавление
|
||||
-- суток к timestamptz зависит от таймзоны сессии, а Python считает ровно 24 часа.
|
||||
-- diff_percent у второй строки не трогаем: писатель его не заполняет, у всех 11 он NULL,
|
||||
-- и после удаления посева вторая строка становится первой — NULL там и есть верное.
|
||||
--
|
||||
-- СКОЛЬКО ЖДЁМ
|
||||
-- 11 строк на 17.09.2026 (7 × 10 и 4 × 175…1000). До деплоя кода новые посевы ещё
|
||||
-- могут появиться (06.09 → 17.09 прибавился один). Порог остановки 30: поймали больше —
|
||||
-- критерий ловит не то, миграция падает, а не молча чистит историю.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ
|
||||
-- Удалённый посев второй раз не находится, у оставшейся строки пары больше нет →
|
||||
-- повторный прогон удаляет 0. Новых объектов схемы нет.
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): DELETE берёт блокировки строк offer_price_history; без
|
||||
-- lock_timeout деплой встанет в очередь за чужой сессией.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
deleted_rows bigint;
|
||||
BEGIN
|
||||
DELETE FROM offer_price_history o
|
||||
USING offer_price_history s
|
||||
WHERE o.source = 'yandex'
|
||||
AND s.source = 'yandex'
|
||||
AND s.listing_id = o.listing_id
|
||||
AND s.recorded_at = o.recorded_at
|
||||
AND s.change_time = o.change_time + interval '24 hours'
|
||||
AND o.price_rub > 0
|
||||
AND s.price_rub > 0
|
||||
AND greatest(o.price_rub, s.price_rub) / least(o.price_rub, s.price_rub) >= 9.5;
|
||||
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
|
||||
RAISE NOTICE 'offer_price_history: удалено yandex-посевов на порядок = %', deleted_rows;
|
||||
|
||||
-- Исключение откатывает всю транзакцию вместе с DELETE выше.
|
||||
IF deleted_rows > 30 THEN
|
||||
RAISE EXCEPTION
|
||||
'offer_price_history: к удалению % yandex-посевов при замере 17.09.2026 = 11 — '
|
||||
'критерий ловит не то, миграция остановлена', deleted_rows;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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 м (0–222 м, все — тот же
|
||||
-- адрес в другом написании, напр. «Академика Вавилова, 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;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
-- 320_listings_domclick_zero_area_to_null.sql
|
||||
-- Issue #3252: Домклик отдаёт `livingArea: 0` / `kitchenArea: 0` как «не указано»
|
||||
-- (живая карточка 2078257603, 17.09: обе площади 0, ремонт пустой). Парсер карточки
|
||||
-- писал этот 0 в колонку как настоящую площадь; COALESCE(:new, old) в UPDATE при этом
|
||||
-- затирал нулём ранее известное значение. Писатель починен в том же PR
|
||||
-- (providers/domclick/detail.py::_pos_float), но починка разбора строк не чинит:
|
||||
-- COALESCE(NULL, 0) оставит 0 навсегда.
|
||||
--
|
||||
-- ЗАМЕР ПРОДА 2026-09-17 (SELECT source, count(*) FILTER (WHERE kitchen_area_m2=0) ...):
|
||||
-- domklik: kitchen_area_m2 = 0 — 303 строки, living_area_m2 = 0 — 84 строки;
|
||||
-- все обогащены карточкой 26.08–14.09 (detail_enriched_at), ни одной до этого.
|
||||
-- avito / cian / yandex / n1: нулей нет вовсе.
|
||||
-- Ожидаемо тронуто: 303 + 84 строки. Отрицательных площадей нет ни у кого.
|
||||
--
|
||||
-- Idempotent: повторный прогон — 0 строк.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE listings
|
||||
SET kitchen_area_m2 = NULL
|
||||
WHERE source = 'domklik'
|
||||
AND kitchen_area_m2 = 0;
|
||||
|
||||
UPDATE listings
|
||||
SET living_area_m2 = NULL
|
||||
WHERE source = 'domklik'
|
||||
AND living_area_m2 = 0;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -134,6 +134,10 @@ USAGE
|
|||
|
||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||
|
||||
# #3520 любой регион (choices come from app.services.regions.REGIONS):
|
||||
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
|
||||
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -156,6 +160,15 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.regions import REGIONS, region_for_point
|
||||
|
||||
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
|
||||
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
|
||||
# (app/services/regions.py), а не отдельный литерал: регион появляется в
|
||||
# реестре один раз и автоматически становится доступен и здесь, и в
|
||||
# msk_raw_import, и в самом эстиматоре.
|
||||
DEFAULT_BACKTEST_REGION_CODE = 66
|
||||
|
||||
|
||||
def _import_estimator() -> tuple[Any, Any]:
|
||||
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
|
||||
|
|
@ -753,11 +766,106 @@ def _expected_sold_metrics(
|
|||
return m
|
||||
|
||||
|
||||
def _pi_calibration_metrics(
|
||||
rows: list[tuple[float, int]],
|
||||
*,
|
||||
low_mult: float,
|
||||
high_mult: float,
|
||||
) -> dict[str, Any]:
|
||||
"""PI (prediction interval) calibration — overall + per-rooms (``--pi-report``).
|
||||
|
||||
Each input row is ``(r, rooms)`` where ``r = sold_total / expected_sold_price``
|
||||
— BOTH RUB TOTALS, not ₽/m². This matches exactly how the interval is
|
||||
APPLIED in prod: ``estimator.py`` builds ``expected_sold_range_low/high`` as
|
||||
``expected_sold_price * estimate_pi_low_mult/high_mult`` (a total-price
|
||||
multiplier). config.py's own comment on those constants — "empirical p10/p90
|
||||
of sold/expected_sold (#1966, n=2366)" — confirms p10/p90 of THIS SAME ratio
|
||||
is exactly what was captured to produce 0.649 / 1.392 originally; per-m² would
|
||||
silently drop the area term and give a different (wrong) distribution.
|
||||
|
||||
For each slice (overall, per room bucket) returns:
|
||||
- ``n`` : observations with a priced expected_sold_price.
|
||||
- ``p10`` / ``p50`` / ``p90``: percentiles of r — p10/p90 are the CANDIDATE
|
||||
new low_mult/high_mult for this slice.
|
||||
- ``achieved_coverage_pct`` : % of r inside the CURRENT [low_mult, high_mult]
|
||||
— this is the number to compare against the
|
||||
80% target (e.g. Москва: 70.5%).
|
||||
- ``proposed_coverage_pct`` : % of r inside the PROPOSED [p10, p90] — a
|
||||
sanity check, ≈80.0 BY CONSTRUCTION (p10/p90
|
||||
are defined as the 10th/90th percentile of the
|
||||
same sample). If this drifts far from 80, `r`
|
||||
was computed on the wrong quantity — mismatch
|
||||
with how the mult is applied, not a real find.
|
||||
- ``width_current`` / ``width_proposed``: interval width relative to the
|
||||
slice's mean r — ``(high_mult - low_mult) /
|
||||
mean(r)`` and ``(p90 - p10) / mean(r)`` — so a
|
||||
wider region-specific band shows up as a
|
||||
visible precision cost, not just "coverage
|
||||
fixed for free".
|
||||
|
||||
Pure: no DB (percentile via the reused estimator ``_percentile``, same as
|
||||
every other block in this file). Rows with expected_sold_price<=0 must
|
||||
already be filtered out by the caller (mirrors ``_range_coverage`` etc.).
|
||||
"""
|
||||
_, _percentile = _import_estimator() # reuse estimator's interpolation percentile
|
||||
|
||||
def _slice(ratios: list[float]) -> dict[str, Any]:
|
||||
n = len(ratios)
|
||||
if n == 0:
|
||||
return {
|
||||
"n": 0,
|
||||
"p10": None,
|
||||
"p50": None,
|
||||
"p90": None,
|
||||
"achieved_coverage_pct": None,
|
||||
"proposed_coverage_pct": None,
|
||||
"width_current": None,
|
||||
"width_proposed": None,
|
||||
}
|
||||
s = sorted(ratios)
|
||||
p10 = _percentile(s, 0.10)
|
||||
p50 = _percentile(s, 0.50)
|
||||
p90 = _percentile(s, 0.90)
|
||||
mean_r = statistics.mean(ratios)
|
||||
achieved = sum(1 for r in ratios if low_mult <= r <= high_mult) / n
|
||||
proposed = sum(1 for r in ratios if p10 <= r <= p90) / n
|
||||
return {
|
||||
"n": n,
|
||||
"p10": round(p10, 4),
|
||||
"p50": round(p50, 4),
|
||||
"p90": round(p90, 4),
|
||||
"achieved_coverage_pct": round(100.0 * achieved, 2),
|
||||
"proposed_coverage_pct": round(100.0 * proposed, 2),
|
||||
"width_current": round((high_mult - low_mult) / mean_r, 4) if mean_r else None,
|
||||
"width_proposed": round((p90 - p10) / mean_r, 4) if mean_r else None,
|
||||
}
|
||||
|
||||
by_bucket: dict[int, list[float]] = {b: [] for b in ROOM_BUCKETS}
|
||||
overall: list[float] = []
|
||||
for r, rooms in rows:
|
||||
overall.append(r)
|
||||
by_bucket[_bucketize_rooms(rooms)].append(r)
|
||||
|
||||
per_rooms: dict[int, dict[str, Any]] = {}
|
||||
for bucket in ROOM_BUCKETS:
|
||||
summary = _slice(by_bucket[bucket])
|
||||
summary["label"] = _rooms_label(bucket)
|
||||
per_rooms[bucket] = summary
|
||||
|
||||
return {
|
||||
"low_mult": low_mult,
|
||||
"high_mult": high_mult,
|
||||
"overall": _slice(overall),
|
||||
"per_rooms": per_rooms,
|
||||
}
|
||||
|
||||
|
||||
def _compute_full_metrics(
|
||||
predictions: list[Prediction],
|
||||
*,
|
||||
n_no_prediction: int = 0,
|
||||
per_rooms_no_prediction: dict[int, int] | None = None,
|
||||
pi_report: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate full-spine Prediction records into the complete metrics dict.
|
||||
|
||||
|
|
@ -769,6 +877,13 @@ def _compute_full_metrics(
|
|||
coverage).
|
||||
- ``calibration`` : per-confidence n / coverage% / MAPE%.
|
||||
- ``sharpness`` : median relative range width (high-low)/point.
|
||||
- ``pi_calibration`` : ONLY when ``pi_report=True`` (``--pi-report``) — PI
|
||||
calibration (p10/p50/p90 of sold_total/expected_
|
||||
sold_price, achieved vs proposed coverage, width)
|
||||
overall + per-rooms, to re-derive estimate_pi_low_
|
||||
mult/high_mult PER REGION instead of the Екатеринбург
|
||||
constants. Default False → key absent, prior dict
|
||||
shape/JSON output UNCHANGED (regression-gate safe).
|
||||
|
||||
Pure: no DB. Safe on an empty list (every block renders with None/0).
|
||||
"""
|
||||
|
|
@ -785,6 +900,9 @@ def _compute_full_metrics(
|
|||
sharp_rows: list[tuple[float, float, float]] = [] # (point, range_low, range_high)
|
||||
calib_rows: list[tuple[str, float | None, bool | None]] = []
|
||||
es_area_rows: list[tuple[float, float, float]] = [] # #3251 (pred, sold, area)
|
||||
pi_rows: list[
|
||||
tuple[float, int]
|
||||
] = [] # --pi-report only: (sold_total/expected_sold_price, rooms)
|
||||
|
||||
for p in predictions:
|
||||
signed: float | None = None
|
||||
|
|
@ -793,6 +911,9 @@ def _compute_full_metrics(
|
|||
es_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.rooms))
|
||||
es_area_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.area_m2))
|
||||
|
||||
if pi_report and p.expected_sold_price is not None and p.expected_sold_price > 0:
|
||||
pi_rows.append((p.sold_total / p.expected_sold_price, p.rooms))
|
||||
|
||||
covered: bool | None = None
|
||||
if p.range_low is not None and p.range_high is not None:
|
||||
covered = p.range_low <= p.sold_total <= p.range_high
|
||||
|
|
@ -805,7 +926,7 @@ def _compute_full_metrics(
|
|||
|
||||
calib_rows.append((p.confidence, signed, covered))
|
||||
|
||||
return {
|
||||
out: dict[str, Any] = {
|
||||
"expected_sold": _expected_sold_metrics(
|
||||
es_rows,
|
||||
n_no_prediction=n_no_prediction,
|
||||
|
|
@ -820,6 +941,14 @@ def _compute_full_metrics(
|
|||
"sharpness": _sharpness(sharp_rows),
|
||||
"confidence_order": conf_order,
|
||||
}
|
||||
if pi_report:
|
||||
_settings = _import_estimator_full().settings
|
||||
out["pi_calibration"] = _pi_calibration_metrics(
|
||||
pi_rows,
|
||||
low_mult=_settings.estimate_pi_low_mult,
|
||||
high_mult=_settings.estimate_pi_high_mult,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _render_table(metrics: dict[str, Any], headline: dict[str, Any]) -> str:
|
||||
|
|
@ -1093,6 +1222,53 @@ def _render_calibrate_segments_block(per_segment: dict[str, Any]) -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
def _render_pi_calibration_block(pi_calibration: dict[str, Any]) -> list[str]:
|
||||
"""Render the PI calibration block (``--pi-report``): overall + per-rooms.
|
||||
|
||||
``r = sold_total / expected_sold_price``. p10/p90 are candidate replacement
|
||||
low/high multipliers FOR THIS SLICE (region/rooms). achieved_coverage_pct is
|
||||
what the CURRENT constants score here — the number to compare against 80%.
|
||||
proposed_coverage_pct must land ≈80.00 by construction; a big deviation means
|
||||
``r`` was built from the wrong quantity, not a real calibration finding.
|
||||
"""
|
||||
low_mult = pi_calibration["low_mult"]
|
||||
high_mult = pi_calibration["high_mult"]
|
||||
out: list[str] = [
|
||||
"PI CALIBRATION (r = sold_total / expected_sold_price; current mult = "
|
||||
f"[{low_mult:.3f}, {high_mult:.3f}]):",
|
||||
]
|
||||
header = (
|
||||
f" {'':<8} {'n':>6} {'p10':>7} {'p50':>7} {'p90':>7} "
|
||||
f"{'achiev%':>8} {'propos%':>8} {'w_cur':>7} {'w_prop':>7}"
|
||||
)
|
||||
out.append(header)
|
||||
out.append(" " + "-" * (len(header) - 2))
|
||||
|
||||
def _row(label: str, s: dict[str, Any]) -> str:
|
||||
n = int(s.get("n", 0) or 0)
|
||||
if n == 0:
|
||||
dash = "—"
|
||||
return (
|
||||
f" {label:<8} {n:>6} {dash:>7} {dash:>7} {dash:>7} "
|
||||
f"{dash:>8} {dash:>8} {dash:>7} {dash:>7}"
|
||||
)
|
||||
return (
|
||||
f" {label:<8} {n:>6} {s['p10']:>7.3f} {s['p50']:>7.3f} {s['p90']:>7.3f} "
|
||||
f"{s['achieved_coverage_pct']:>8.2f} {s['proposed_coverage_pct']:>8.2f} "
|
||||
f"{s['width_current']:>7.3f} {s['width_proposed']:>7.3f}"
|
||||
)
|
||||
|
||||
out.append(_row("OVERALL", pi_calibration["overall"]))
|
||||
for bucket in ROOM_BUCKETS:
|
||||
row = pi_calibration["per_rooms"][bucket]
|
||||
out.append(_row(row["label"], row))
|
||||
out.append(
|
||||
" NB: proposed% should be ≈80.00 by construction (sanity check). "
|
||||
"achieved% is the number to compare against the 80% target."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _render_coverage_block(range_coverage: dict[str, Any], conf_order: list[str]) -> list[str]:
|
||||
"""Render range-coverage: overall + per-confidence (sold_total ∈ range)."""
|
||||
ov = range_coverage["overall"]
|
||||
|
|
@ -1153,6 +1329,7 @@ _SAMPLE_SQL = text(
|
|||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -1204,6 +1381,7 @@ _SAMPLE_SQL_SCATTERED = text(
|
|||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -1233,6 +1411,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
|
|||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -1271,10 +1450,9 @@ _CANDIDATES_SQL = text(
|
|||
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
|
||||
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
|
||||
# _resolve_city_ppm2_band); the default (city=None) path never issues this
|
||||
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
|
||||
# region CLI flag yet, so every call is scoped to the oblast, matching every
|
||||
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
|
||||
# which only ever saw region-66 rows since the table was oblast-only before).
|
||||
# query. region_code (#3520 "любой регион") is threaded from the CLI --region
|
||||
# flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
|
||||
# every pre-existing invocation, which never passed --region).
|
||||
_CITY_PPM2_BAND_SQL = text(
|
||||
"""
|
||||
SELECT ppm2_min, ppm2_max
|
||||
|
|
@ -1288,10 +1466,13 @@ _CITY_PPM2_BAND_SQL = text(
|
|||
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||
|
||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||
before this change — literal identity, not just equal text — so the
|
||||
default CLI invocation (and the frozen EKB regression gate, which never
|
||||
calls this path at all) see byte-identical SQL.
|
||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object every
|
||||
time — literal identity, not just equal text (asserted by
|
||||
``test_sample_sql_city_none_returns_original_object``) — so callers that
|
||||
cache/compare the query object see a stable reference. All four SQL
|
||||
variants (unscoped/scattered × city/no-city) carry a ``region_code``
|
||||
predicate (#3520 "любой регион") bound from ``_load_sample``'s
|
||||
``region_code`` param, default 66.
|
||||
|
||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||
|
|
@ -1321,6 +1502,7 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
|||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND region_code = CAST(:region_code AS int)
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -1348,9 +1530,9 @@ def _resolve_city_ppm2_band(
|
|||
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
|
||||
error → the globals; read-only best-effort, never raises.
|
||||
|
||||
``region_code`` defaults to 66 (Свердловская обл.) — this script has no
|
||||
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
|
||||
--city caller keeps its byte-identical lookup.
|
||||
``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
|
||||
direct callers; ``_load_sample`` (#3520) always passes the actual
|
||||
``--region`` value through explicitly.
|
||||
"""
|
||||
if city is None:
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
|
|
@ -1376,22 +1558,32 @@ def _load_sample(
|
|||
city: str | None = None,
|
||||
scattered: bool = False,
|
||||
seed: str = "mera",
|
||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||
) -> list[DealSample]:
|
||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||
|
||||
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
|
||||
scopes the sample to one ``deals.region_code`` value — REQUIRED once
|
||||
``deals`` carries more than one region (212 937 Москва + 113 351
|
||||
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
|
||||
query would silently mix regions together. Must be a key of
|
||||
``app.services.regions.REGIONS`` — validated by ``main()``/``_parse_args``
|
||||
(CLI ``choices``) so this function only ever sees a supported code.
|
||||
|
||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||
falls back to the module globals). Default None is byte-identical to the
|
||||
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||
no extra query.
|
||||
``deal_city_price_bands`` for that (region_code, city) pair
|
||||
(``_resolve_city_ppm2_band``, falls back to the module globals). Default
|
||||
None is byte-identical to the pre-oblast-D behaviour: same SQL object,
|
||||
same PPM2_MIN/PPM2_MAX globals, no extra query.
|
||||
"""
|
||||
if city is None:
|
||||
ppm2_min: float = PPM2_MIN
|
||||
ppm2_max: float = PPM2_MAX
|
||||
else:
|
||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
|
||||
params: dict[str, Any] = {
|
||||
"region_code": region_code,
|
||||
"ppm2_min": ppm2_min,
|
||||
"ppm2_max": ppm2_max,
|
||||
"since": since,
|
||||
|
|
@ -1482,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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1769,7 +1965,7 @@ def _make_call_stub(
|
|||
return _stub
|
||||
|
||||
|
||||
def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
||||
def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[str, Any]:
|
||||
"""Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB.
|
||||
|
||||
For every captured deal record: rebuild the ``GeocodeResult``, build order-based
|
||||
|
|
@ -1783,12 +1979,11 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
||||
``params`` block. Touches NO DB / network and does NOT consult
|
||||
``settings_at_capture`` — it prices against the live committed
|
||||
``estimator.settings`` defaults (so a settings change is caught as a metric
|
||||
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение —
|
||||
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
|
||||
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
|
||||
потока (см. комментарий у пина ниже). Deterministic: same fixture → identical
|
||||
dict.
|
||||
``estimator.settings`` defaults and module constants (so a change is caught as a
|
||||
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
|
||||
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
|
||||
(закоммиченная — такая); более старая упадёт на «control flow diverged from
|
||||
capture» — её надо перезахватить. Deterministic: same fixture → identical dict.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
m = est.m
|
||||
|
|
@ -1799,26 +1994,6 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
pred_ppm2_all: list[float] = []
|
||||
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
||||
|
||||
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
|
||||
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
|
||||
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
|
||||
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
|
||||
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
|
||||
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
|
||||
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
|
||||
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
|
||||
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
|
||||
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
|
||||
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
|
||||
# вызывается, а реплей с dedup=False их оценивает и упирается в
|
||||
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
|
||||
# False — для старых фикстур без settings_at_capture поведение прежнее).
|
||||
_dedup_capture = bool(
|
||||
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
|
||||
)
|
||||
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
|
||||
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
|
||||
try:
|
||||
for rec in deals:
|
||||
kw = dict(rec["kwargs"])
|
||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||
|
|
@ -1874,11 +2049,8 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
predictions.append(prediction)
|
||||
pred_ppm2_all.append(prediction.median_ppm2)
|
||||
|
||||
finally:
|
||||
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
||||
|
||||
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0)
|
||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
||||
|
||||
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
||||
ask_median = statistics.median(pred_ppm2_all) if pred_ppm2_all else None
|
||||
|
|
@ -1931,7 +2103,6 @@ def _predict_full_spine(
|
|||
whether the house IMV anchor actually resolved a row for this deal.
|
||||
"""
|
||||
m = est.m
|
||||
settings = est.settings
|
||||
|
||||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
||||
db, deal, est, target_house_id=target_house_id
|
||||
|
|
@ -1947,8 +2118,21 @@ def _predict_full_spine(
|
|||
# harness measures the SAME corridor prod actually computes today, not the
|
||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||
target_city = m._resolve_target_city(deal.address)
|
||||
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
|
||||
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
|
||||
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
|
||||
# without this, _fetch_dkp_corridor's default (region 66) would scope a
|
||||
# Москва/Московская обл. deal's corridor query to the WRONG region and
|
||||
# silently return no rows (or, worse, an unrelated same-named street in 66).
|
||||
target_region = region_for_point(deal.lat, deal.lon)
|
||||
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
|
||||
dkp_raw = m._fetch_dkp_corridor(
|
||||
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||
db,
|
||||
address=deal.address,
|
||||
rooms=deal.rooms,
|
||||
area=deal.area_m2,
|
||||
city=target_city,
|
||||
region_code=target_region_code,
|
||||
)
|
||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||
|
|
@ -2007,7 +2191,7 @@ def _predict_full_spine(
|
|||
res = m._lookup_quarter_index(
|
||||
db,
|
||||
quarter_cad_number=q,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
if capture is not None:
|
||||
ret = _sanitize_json(list(res) if res is not None else None)
|
||||
|
|
@ -2018,13 +2202,20 @@ def _predict_full_spine(
|
|||
res = m._lookup_quarter_indexes(
|
||||
db,
|
||||
quarter_cad_numbers=qs,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
if capture is not None:
|
||||
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
||||
return res
|
||||
|
||||
pr = m._price_from_inputs(
|
||||
# #pi-by-region: множители вилки теперь региональные, и живой прогон обязан
|
||||
# считать ТЕ ЖЕ, что прод — иначе харнесс молча мерил бы старые скаляры и
|
||||
# показывал покрытие, которого в проде нет. Регион берём из уже посчитанного
|
||||
# target_region_code (строка выше), он зеркалит резолв estimate_quality.
|
||||
# Замороженный реплей фикстуры (replay_fixture) сюда НЕ ходит и региона не
|
||||
# передаёт — его путь остаётся байт-в-байт прежним, регресс-гейт не двигается.
|
||||
region_code=target_region_code,
|
||||
listings=listings,
|
||||
area_m2=deal.area_m2,
|
||||
rooms=deal.rooms,
|
||||
|
|
@ -2169,6 +2360,7 @@ def run_backtest(
|
|||
city: str | None = None,
|
||||
scattered: bool = False,
|
||||
seed: str = "mera",
|
||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the full read-only backtest and return a metrics dict.
|
||||
|
||||
|
|
@ -2184,12 +2376,29 @@ def run_backtest(
|
|||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||
|
||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
||||
``deals.region_code`` — see ``_load_sample``.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour).
|
||||
"""
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||
deals = _load_sample(
|
||||
db,
|
||||
sample=sample,
|
||||
since=since,
|
||||
city=city,
|
||||
scattered=scattered,
|
||||
seed=seed,
|
||||
region_code=region_code,
|
||||
)
|
||||
logger.info(
|
||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
|
||||
len(deals),
|
||||
since,
|
||||
city,
|
||||
region_code,
|
||||
)
|
||||
|
||||
matched_rows: list[tuple[float, float, int]] = []
|
||||
matched_ids: list[int] = []
|
||||
|
|
@ -2264,6 +2473,8 @@ def run_backtest_full(
|
|||
city: str | None = None,
|
||||
scattered: bool = False,
|
||||
seed: str = "mera",
|
||||
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
|
||||
pi_report: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||
|
||||
|
|
@ -2289,16 +2500,42 @@ def run_backtest_full(
|
|||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||
attached to the returned metrics. Default False → byte-identical prior output.
|
||||
|
||||
``region_code`` (#3520, default 66) scopes the deal sample to one
|
||||
``deals.region_code`` — see ``_load_sample``. Independently of this flag,
|
||||
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
|
||||
lat/lon via ``regions.region_for_point`` (mirroring prod's
|
||||
``estimate_quality`` region resolution) before calling
|
||||
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
|
||||
sample somehow mixed regions.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||
|
||||
``pi_report`` (default False, ``--pi-report``) attaches a ``pi_calibration``
|
||||
block (see ``_pi_calibration_metrics``) — p10/p50/p90 of sold_total/expected_
|
||||
sold_price overall + per-rooms, plus achieved/proposed PI coverage, so
|
||||
estimate_pi_low_mult/high_mult (calibrated on Екатеринбург only, #1966) can be
|
||||
re-derived PER REGION. Default False → byte-identical prior output.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||
deals = _load_sample(
|
||||
db,
|
||||
sample=sample,
|
||||
since=since,
|
||||
city=city,
|
||||
scattered=scattered,
|
||||
seed=seed,
|
||||
region_code=region_code,
|
||||
)
|
||||
logger.info(
|
||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d) [full spine]",
|
||||
len(deals),
|
||||
since,
|
||||
city,
|
||||
region_code,
|
||||
)
|
||||
|
||||
predictions: list[Prediction] = []
|
||||
|
|
@ -2317,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,
|
||||
|
|
@ -2353,6 +2602,7 @@ def run_backtest_full(
|
|||
predictions,
|
||||
n_no_prediction=n_no_prediction,
|
||||
per_rooms_no_prediction=per_rooms_no_prediction,
|
||||
pi_report=pi_report,
|
||||
)
|
||||
|
||||
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
|
||||
|
|
@ -2382,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:
|
||||
|
|
@ -2399,7 +2650,9 @@ def _write_fixture(
|
|||
) -> None:
|
||||
"""Freeze captured per-deal replay records into a committed JSON fixture.
|
||||
|
||||
``settings_at_capture`` records every ``estimate_*`` Settings field as an
|
||||
``settings_at_capture`` records every ``estimate_*`` Settings field plus the
|
||||
estimator's numeric module constants (#1970: калибровочные пороги переехали из
|
||||
Settings в код — без них снимок не говорит, с какими порогами захватили) as an
|
||||
informational snapshot (NOT consulted by ``replay_fixture``). A final
|
||||
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
||||
JSON-plain before ``json.dump(allow_nan=False)``.
|
||||
|
|
@ -2409,6 +2662,11 @@ def _write_fixture(
|
|||
for name in sorted(type(est.settings).model_fields)
|
||||
if name.startswith("estimate_")
|
||||
}
|
||||
settings_at_capture.update(
|
||||
(name, _sanitize_json(value))
|
||||
for name, value in sorted(vars(est.m).items())
|
||||
if name.isupper() and isinstance(value, int | float)
|
||||
)
|
||||
fixture = {
|
||||
"schema_version": FIXTURE_SCHEMA_VERSION,
|
||||
"engine": "full",
|
||||
|
|
@ -2482,6 +2740,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
||||
"прогона подряд отличаются только правкой, а не составом выборки.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--region",
|
||||
type=int,
|
||||
default=DEFAULT_BACKTEST_REGION_CODE,
|
||||
choices=sorted(REGIONS),
|
||||
help="#3520 'любой регион': deals.region_code to backtest against (default "
|
||||
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
|
||||
"Supported values come from the app.services.regions.REGIONS registry — "
|
||||
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
|
||||
+ ". An unsupported code is rejected by argparse before any DB call.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--city",
|
||||
default=None,
|
||||
|
|
@ -2542,6 +2811,27 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
"proposes estimate_segment_multipliers, it does NOT apply them or touch "
|
||||
"the baseline. Run with --resolve-house-id for prod-parity biases.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--pi-report",
|
||||
action="store_true",
|
||||
help="FULL engine only: after the run, print a PI (prediction interval) "
|
||||
"calibration block — p10/p50/p90 of sold_total/expected_sold_price overall "
|
||||
"+ per-rooms, achieved coverage of the CURRENT estimate_pi_low_mult/"
|
||||
"high_mult (calibrated on Екатеринбург only, #1966), and the coverage the "
|
||||
"sample's own [p10, p90] would give. Use --region to re-derive multipliers "
|
||||
"for a non-EKB region. PRINT-ONLY, applies nothing. Also adds a "
|
||||
"'pi_calibration' key to --json output. Default OFF → byte-identical to "
|
||||
"the prior behaviour (both text and --json).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--pi-report-json",
|
||||
metavar="PATH",
|
||||
default=None,
|
||||
help="With --pi-report: also write JUST the pi_calibration block (plus "
|
||||
"region/city/since/sample run params) to PATH as JSON, so multiple "
|
||||
"per-region runs can be compared without parsing the text table. "
|
||||
"Independent of --json (which dumps the FULL metrics dict to stdout).",
|
||||
)
|
||||
# #1966 PR 3/3 — fixture capture + hermetic replay. --dump-fixture (DB run,
|
||||
# full engine) and --from-fixture (NO DB) are mutually exclusive modes.
|
||||
fixture_mode = p.add_mutually_exclusive_group()
|
||||
|
|
@ -2571,14 +2861,50 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def _emit_pi_report(
|
||||
metrics: dict[str, Any],
|
||||
*,
|
||||
json_path: str | None,
|
||||
region_code: int,
|
||||
city: str | None,
|
||||
since: str,
|
||||
sample: int,
|
||||
) -> None:
|
||||
"""``--pi-report`` side effects — text block + optional JSON — shared by the
|
||||
live-DB and ``--from-fixture`` paths. No-op if ``pi_calibration`` is absent
|
||||
(i.e. ``--pi-report`` wasn't actually threaded into the metrics computation).
|
||||
"""
|
||||
pi = metrics.get("pi_calibration")
|
||||
if pi is None:
|
||||
return
|
||||
print("\n" + "\n".join(_render_pi_calibration_block(pi)))
|
||||
if json_path:
|
||||
payload = {
|
||||
"region_code": region_code,
|
||||
"city": city,
|
||||
"since": since,
|
||||
"sample": sample,
|
||||
"pi_calibration": pi,
|
||||
}
|
||||
Path(json_path).write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("wrote pi-report json: %s", json_path)
|
||||
print(f"wrote pi-report json: {json_path}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point. Returns the count of matched (predicted) / replayed deals."""
|
||||
args = _parse_args(argv)
|
||||
|
||||
if args.pi_report_json and not args.pi_report:
|
||||
raise SystemExit("--pi-report-json requires --pi-report")
|
||||
|
||||
# ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ──
|
||||
if args.from_fixture:
|
||||
fixture = load_fixture(args.from_fixture)
|
||||
metrics = replay_fixture(fixture)
|
||||
metrics = replay_fixture(fixture, pi_report=args.pi_report)
|
||||
print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
if args.update_baseline:
|
||||
out = Path(args.update_baseline)
|
||||
|
|
@ -2587,6 +2913,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||
encoding="utf-8",
|
||||
)
|
||||
logger.info("wrote baseline: %s", out)
|
||||
_emit_pi_report(
|
||||
metrics,
|
||||
json_path=args.pi_report_json,
|
||||
region_code=args.region,
|
||||
city=args.city,
|
||||
since=args.since,
|
||||
sample=len(fixture.get("deals") or []),
|
||||
)
|
||||
return len(fixture.get("deals") or [])
|
||||
|
||||
if args.update_baseline:
|
||||
|
|
@ -2597,11 +2931,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||
raise SystemExit("--resolve-house-id is only supported with --engine full")
|
||||
if args.calibrate_segments and args.engine != "full":
|
||||
raise SystemExit("--calibrate-segments is only supported with --engine full")
|
||||
if args.pi_report and args.engine != "full":
|
||||
raise SystemExit("--pi-report is only supported with --engine full")
|
||||
|
||||
logger.info(
|
||||
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||
"backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
|
||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||
args.engine,
|
||||
args.region,
|
||||
args.sample,
|
||||
args.since,
|
||||
args.radius,
|
||||
|
|
@ -2624,6 +2961,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
city=args.city,
|
||||
scattered=(args.spread == "scattered"),
|
||||
seed=args.seed,
|
||||
region_code=args.region,
|
||||
pi_report=args.pi_report,
|
||||
)
|
||||
else:
|
||||
metrics = run_backtest(
|
||||
|
|
@ -2636,6 +2975,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
city=args.city,
|
||||
scattered=(args.spread == "scattered"),
|
||||
seed=args.seed,
|
||||
region_code=args.region,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -2654,6 +2994,15 @@ def main(argv: list[str] | None = None) -> int:
|
|||
per_segment = metrics["expected_sold"]["per_segment"]
|
||||
print("\n" + "\n".join(_render_calibrate_segments_block(per_segment)))
|
||||
|
||||
_emit_pi_report(
|
||||
metrics,
|
||||
json_path=args.pi_report_json,
|
||||
region_code=args.region,
|
||||
city=args.city,
|
||||
since=args.since,
|
||||
sample=args.sample,
|
||||
)
|
||||
|
||||
return int(metrics["params"]["n_matched"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
|
|
@ -66,11 +67,13 @@ from sqlalchemy.orm import Session
|
|||
# matches the pattern from backfill_houses_dadata.py) and as a stand-alone file.
|
||||
try:
|
||||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||||
from app.services.regions import REGIONS # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.regions import REGIONS
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -91,6 +94,47 @@ _LOG_EVERY = 1000
|
|||
# houses (centroid) and deals (lookup) side so garbage never anchors a match.
|
||||
_MIN_KEY_LEN = 3
|
||||
|
||||
# Default region. 66 (Свердловская обл.) — единственный регион, на котором
|
||||
# скрипт исторически работал; остаётся умолчанием, чтобы существующие вызовы
|
||||
# без флага вели себя ровно как раньше.
|
||||
_DEFAULT_REGION_CODE = 66
|
||||
|
||||
# Sanity-порог разброса домов ВНУТРИ одного ключа (км). Ключ — (регион, НП,
|
||||
# улица), так что легитимный разброс — это длина одной улицы: даже проспект в
|
||||
# большом городе редко длиннее 10-15 км, а типичная улица — 1-3 км. Разброс
|
||||
# больше порога означает, что в ведро слиплись ОДНОИМЁННЫЕ улицы разных
|
||||
# населённых пунктов (или один НП распознан двумя написаниями) — среднее таких
|
||||
# точек даёт правдоподобную координату ПОСЕРЕДИНЕ между городами, и ошибка
|
||||
# тихая. Такое ведро выбрасывается целиком: дыра в покрытии честнее, чем
|
||||
# сделка, посаженная в поле между Клином и Серпуховом.
|
||||
_DEFAULT_MAX_SPREAD_KM = 5.0
|
||||
|
||||
# Классификация comma-сегмента адреса (см. `_split_place_street`).
|
||||
# Тип населённого пункта в начале сегмента: «г Химки», «пос. Голубое»,
|
||||
# «д Сабурово», «рп Оболенск», «снт Заря». За типом ОБЯЗАН идти буквенный
|
||||
# токен — иначе «д 5» (номер дома) распознался бы как деревня.
|
||||
_LOCALITY_TYPE_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"г|гор|город|пгт|рп|дп|снт|днп|тер"
|
||||
r"|п|пос|поселок|посёлок|п/ст"
|
||||
r"|д|дер|деревня|с|село|сл|слобода|ст|станция|х|хутор|аул"
|
||||
r")\.?\s+(?=[а-яё])",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
# Сегмент — номер дома/квартиры/корпуса, а не имя: «125», «д 5», «кв 12»,
|
||||
# «корп 3», «литера А». Такие сегменты не могут быть ни НП, ни улицей.
|
||||
_NUMERIC_SEGMENT_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"д|дом|к|кор|корп|корпус|стр|строение|кв|квартира|литера?|уч|участок"
|
||||
r"|пом|помещение|оф|офис|бокс|гараж"
|
||||
# Хвост `$` обязателен: сегмент считается номером, только если КРОМЕ
|
||||
# числа в нём ничего нет. Иначе «8 марта» (числовое имя улицы) было бы
|
||||
# принято за номер дома и улица потерялась бы целиком.
|
||||
r")?\.?\s*\d+[а-яё]?(?:\s*[/-]\s*\d+[а-яё]?)?$",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Street-key normalization — the crux of match rate
|
||||
|
|
@ -109,9 +153,11 @@ _ADMIN_SEGMENT_RES = [
|
|||
r"^[а-яё][а-яё\s.-]*\b(?:р-н|район|округ|край|республика)$",
|
||||
flags=re.UNICODE,
|
||||
),
|
||||
re.compile(r"^(?:г|гор|город)\.?\s+[а-яё][а-яё-]+$", flags=re.UNICODE),
|
||||
re.compile(r"^екатеринбург$", flags=re.UNICODE),
|
||||
]
|
||||
# NB: сегменты-НАСЕЛЁННЫЕ ПУНКТЫ («г. Екатеринбург», «екатеринбург») здесь
|
||||
# больше НЕ выбрасываются — они несут вторую половину ключа и разбираются
|
||||
# `_locality_name` по реестру регионов. Хардкод `^екатеринбург$` уехал туда же:
|
||||
# список городов региона живёт в `REGIONS[region_code].cities`, а не здесь.
|
||||
|
||||
# Street-type token at the START of the street segment. Stripped because
|
||||
# deals.address sometimes omits it entirely ('Екатеринбург, Малышева'), so the
|
||||
|
|
@ -187,63 +233,176 @@ def _strip_house_tail(segment: str) -> str:
|
|||
return " ".join(keep).strip()
|
||||
|
||||
|
||||
def _street_key(address: str | None) -> str:
|
||||
"""Reduce any address to a bare-street-name key for the centroid join.
|
||||
def _is_admin_segment(seg: str) -> bool:
|
||||
"""Сегмент — страна/регион/район (выбрасывается целиком)."""
|
||||
return any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES)
|
||||
|
||||
Both sides must produce the SAME key or the join under-matches:
|
||||
'Екатеринбург, ул. Малышева, 125' → 'малышева'
|
||||
'г Екатеринбург, улица Малышева' → 'малышева'
|
||||
'Екатеринбург, Малышева' → 'малышева'
|
||||
'Свердловская обл., Екатеринбург, ул. Большакова, 17' → 'большакова'
|
||||
'улица Яскина, 12 · р-н Октябрьский' → 'яскина'
|
||||
'г. Екатеринбург, проспект Ленина, 50' → 'ленина'
|
||||
'Екатеринбург, ул. 8 Марта, 100' → '8 марта'
|
||||
|
||||
Steps:
|
||||
1. NFC normalize, lowercase, ё→е (deals/houses differ on ё usage).
|
||||
2. Drop a trailing district marker (' · р-н ...', ' | ...').
|
||||
3. Split on commas; drop leading segments that are admin chunks
|
||||
(Россия / region / district / город / Екатеринбург). The first
|
||||
non-admin segment is the street segment.
|
||||
4. Drop apartment/corpus/строение noise inside that segment.
|
||||
5. Strip the street-type token at the start (ул/улица/проспект/...).
|
||||
6. Strip the trailing house number, preserving numeric street names.
|
||||
7. Collapse whitespace.
|
||||
def _is_numeric_segment(seg: str) -> bool:
|
||||
"""Сегмент — номер дома/квартиры/корпуса («125», «д 5», «кв 12»)."""
|
||||
return bool(_NUMERIC_SEGMENT_RE.match(seg))
|
||||
|
||||
Returns '' when nothing usable remains (caller filters by _MIN_KEY_LEN).
|
||||
"""
|
||||
if not address:
|
||||
|
||||
def _normalize_place(value: str | None) -> str:
|
||||
"""Нормализованное имя НП: lower, ё→е, без типа («г. Химки» → «химки»)."""
|
||||
if not value:
|
||||
return ""
|
||||
v = unicodedata.normalize("NFC", value).lower().replace("ё", "е")
|
||||
v = _WS_RE.sub(" ", v).strip()
|
||||
v = _LOCALITY_TYPE_RE.sub("", v)
|
||||
return _WS_RE.sub(" ", v).strip(" .,")
|
||||
|
||||
|
||||
def _locality_name(seg: str, cities: frozenset[str]) -> str | None:
|
||||
"""Имя НП, если сегмент — населённый пункт, иначе None.
|
||||
|
||||
Два признака: явный тип («г Химки», «д Сабурово», «снт Заря») — имя берём
|
||||
как есть; ИЛИ голое имя, которое реестр региона знает как город
|
||||
(`REGIONS[region_code].cities`). Голое незнакомое имя здесь НЕ считается
|
||||
НП — иначе «Малышева, 125» прочиталось бы как НП «Малышева»; такой случай
|
||||
ловит позиционный fallback в `_split_place_street`.
|
||||
"""
|
||||
m = _LOCALITY_TYPE_RE.match(seg)
|
||||
if m:
|
||||
name = seg[m.end() :]
|
||||
else:
|
||||
if _STREET_TYPE_RE.match(seg) or _is_numeric_segment(seg):
|
||||
return None
|
||||
if _WS_RE.sub(" ", seg).strip() not in cities:
|
||||
return None
|
||||
name = seg
|
||||
name = _WS_RE.sub(" ", name).strip(" .,")
|
||||
return name or None
|
||||
|
||||
|
||||
def _split_place_street(
|
||||
address: str | None, region_code: int = _DEFAULT_REGION_CODE
|
||||
) -> tuple[str, str]:
|
||||
"""Разложить адрес на (населённый пункт, улица) — обе половины ключа.
|
||||
|
||||
ПОЧЕМУ НП обязан быть в ключе. Раньше функция звалась `_street_key` и
|
||||
возвращала ГОЛОЕ имя улицы, выбрасывая НП. Для одного города (скрипт жил
|
||||
ЕКБ-only, с хардкодом `^екатеринбург$`) это безвредно. Для Московской
|
||||
области — тихая катастрофа: «Ленина» / «Центральная» / «Советская» есть
|
||||
почти в каждом из ~970 НП области, дома со всех таких улиц слиплись бы в
|
||||
одно ведро, а среднее их координат — правдоподобная точка ПОСЕРЕДИНЕ между
|
||||
городами. Сделка уезжает на десятки километров, и ни одна проверка этого не
|
||||
замечает: координата валидная, внутри области, рядом есть дома.
|
||||
|
||||
Разбор:
|
||||
1. NFC, lower, ё→е, снять хвост-район (' · р-н ...', ' | ...').
|
||||
2. Порезать на comma-сегменты, выбросить страну/регион/район.
|
||||
3. Первый сегмент-НП (`_locality_name`) → place, улицу ищем ПОСЛЕ него.
|
||||
4. Улица — первый не-числовой сегмент из остатка.
|
||||
5. Fallback «НП без типа и вне реестра» («Сабурово, Луговая» — ровно
|
||||
формат deals.address по области): если НП не нашёлся, а не-числовых
|
||||
сегментов >= 2 и первый не начинается с типа улицы — первый считается
|
||||
НП, второй улицей.
|
||||
6. Улицу чистим как раньше: квартира/корпус, тип улицы, номер дома.
|
||||
|
||||
Неизвестный `region_code` -> KeyError реестра, не молчаливый ''.
|
||||
"""
|
||||
cities = REGIONS[region_code].cities
|
||||
if not address:
|
||||
return "", ""
|
||||
s = unicodedata.normalize("NFC", address).lower().replace("ё", "е")
|
||||
s = _WS_RE.sub(" ", s).strip()
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
# 2. Drop trailing district marker (' · Октябрьский', ' | ...').
|
||||
return "", ""
|
||||
s = _DISTRICT_SUFFIX_RE.sub("", s)
|
||||
|
||||
# 3. Split on commas, drop leading admin segments. Each segment is matched
|
||||
# whole, so a non-admin street segment is never partially eaten.
|
||||
segments = [seg.strip() for seg in s.split(",") if seg.strip()]
|
||||
street_seg = ""
|
||||
for seg in segments:
|
||||
if any(rx.match(seg) for rx in _ADMIN_SEGMENT_RES):
|
||||
continue
|
||||
street_seg = seg
|
||||
non_admin = [seg for seg in segments if not _is_admin_segment(seg)]
|
||||
if not non_admin:
|
||||
return "", ""
|
||||
|
||||
place = ""
|
||||
rest = non_admin
|
||||
for i, seg in enumerate(non_admin):
|
||||
name = _locality_name(seg, cities)
|
||||
if name:
|
||||
place = name
|
||||
rest = non_admin[i + 1 :]
|
||||
break
|
||||
|
||||
street_seg = next((seg for seg in rest if not _is_numeric_segment(seg)), "")
|
||||
|
||||
if not place:
|
||||
usable = [seg for seg in non_admin if not _is_numeric_segment(seg)]
|
||||
if len(usable) >= 2 and not _STREET_TYPE_RE.match(usable[0]):
|
||||
place = _WS_RE.sub(" ", usable[0]).strip(" .,")
|
||||
street_seg = usable[1]
|
||||
|
||||
if not street_seg:
|
||||
return ""
|
||||
return place, ""
|
||||
|
||||
# 4. Drop apartment/corpus/строение noise inside the street segment.
|
||||
street_seg = _APT_SUFFIX_RE.sub("", street_seg).strip()
|
||||
|
||||
# 5. Strip the street-type token if present.
|
||||
street_seg = _STREET_TYPE_RE.sub("", street_seg).strip()
|
||||
|
||||
# 6. Strip trailing house number (keep '8 марта' style numeric streets).
|
||||
street_seg = _strip_house_tail(street_seg)
|
||||
return place, _WS_RE.sub(" ", street_seg).strip()
|
||||
|
||||
return _WS_RE.sub(" ", street_seg).strip()
|
||||
|
||||
def _street_key(address: str | None, region_code: int = _DEFAULT_REGION_CODE) -> str:
|
||||
"""Только уличная половина ключа. Как ключ join'а САМА ПО СЕБЕ не годится
|
||||
(одноимённые улицы разных НП) — см. `_address_key`; оставлена для отчётов
|
||||
и тестов нормализации улицы.
|
||||
|
||||
Примеры (region 66):
|
||||
'Екатеринбург, ул. Малышева, 125' -> 'малышева'
|
||||
'г Екатеринбург, улица Малышева' -> 'малышева'
|
||||
'Свердловская обл., Екатеринбург, ул. Большакова, 17' -> 'большакова'
|
||||
'улица Яскина, 12 · р-н Октябрьский' -> 'яскина'
|
||||
'Екатеринбург, ул. 8 Марта, 100' -> '8 марта'
|
||||
"""
|
||||
return _split_place_street(address, region_code)[1]
|
||||
|
||||
|
||||
def _address_key(
|
||||
address: str | None,
|
||||
region_code: int = _DEFAULT_REGION_CODE,
|
||||
city: str | None = None,
|
||||
) -> tuple[int, str, str] | None:
|
||||
"""Составной ключ join'а: (регион, населённый пункт, улица). None — мусор.
|
||||
|
||||
`city` (у deals колонка заполнена на 100% и надёжнее текста адреса)
|
||||
переопределяет НП, разобранный из адреса.
|
||||
|
||||
Пустой НП: у региона С городом-ядром (66) подставляется `city_token` —
|
||||
ровно историческое поведение «всё, что без города, это Екатеринбург». У
|
||||
региона БЕЗ ядра (50) подставлять нечего, и ключ отбрасывается: сделка без
|
||||
распознанного НП лучше останется без координат, чем сядет в случайный
|
||||
город области.
|
||||
"""
|
||||
place, street = _split_place_street(address, region_code)
|
||||
from_city = _normalize_place(city)
|
||||
if from_city:
|
||||
place = from_city
|
||||
if len(street) < _MIN_KEY_LEN:
|
||||
return None
|
||||
if not place:
|
||||
region = REGIONS[region_code]
|
||||
if not region.has_city_core:
|
||||
return None
|
||||
place = region.city_token
|
||||
if len(place) < _MIN_KEY_LEN:
|
||||
return None
|
||||
return (region_code, place, street)
|
||||
|
||||
|
||||
def _spread_km(points: list[tuple[float, float]], lat_c: float, lon_c: float) -> float:
|
||||
"""Максимальное удаление точки ведра от его центроида, км (equirectangular).
|
||||
|
||||
Проекция плоская — на масштабе одного НП (единицы километров) ошибка
|
||||
сотые доли процента, а формула дешевле haversine на каждом из десятков
|
||||
тысяч домов.
|
||||
"""
|
||||
worst = 0.0
|
||||
cos_lat = math.cos(math.radians(lat_c))
|
||||
for lat, lon in points:
|
||||
dy = (lat - lat_c) * 111.32
|
||||
dx = (lon - lon_c) * 111.32 * cos_lat
|
||||
worst = max(worst, math.hypot(dx, dy))
|
||||
return worst
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -253,11 +412,13 @@ def _street_key(address: str | None) -> str:
|
|||
|
||||
@dataclass
|
||||
class Centroid:
|
||||
"""One street's centroid, averaged over all geocoded houses on it."""
|
||||
"""Центроид одного ключа (регион, НП, улица) по домам этого ключа."""
|
||||
|
||||
lat: float
|
||||
lon: float
|
||||
house_count: int
|
||||
# Максимальное удаление дома ведра от центроида, км (sanity-чек склейки).
|
||||
spread_km: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -266,6 +427,9 @@ class DealRow:
|
|||
|
||||
id: int
|
||||
address: str | None
|
||||
# deals.city — росреестровая колонка; по области заполнена у 100% строк без
|
||||
# geom и надёжнее текста адреса, поэтому переопределяет НП из адреса.
|
||||
city: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -276,6 +440,8 @@ class Stats:
|
|||
geocoded: int = 0
|
||||
no_street_match: int = 0
|
||||
failed: int = 0
|
||||
# Вёдер выброшено sanity-чеком разброса (склейка нескольких НП).
|
||||
dropped_spread: int = 0
|
||||
# street_key → count of deals that had no house centroid (dry-run report).
|
||||
unmatched_streets: Counter[str] = field(default_factory=Counter)
|
||||
|
||||
|
|
@ -285,14 +451,28 @@ class Stats:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
||||
"""Per-street centroid from houses WHERE geom IS NOT NULL.
|
||||
def _build_centroid_map(
|
||||
db: Session,
|
||||
region_code: int = _DEFAULT_REGION_CODE,
|
||||
max_spread_km: float = _DEFAULT_MAX_SPREAD_KM,
|
||||
) -> dict[tuple[int, str, str], Centroid]:
|
||||
"""Центроиды по ключу (регион, НП, улица) из houses с координатами.
|
||||
|
||||
We read raw (address, lat, lon) and aggregate in Python so the street-key
|
||||
derivation is the SAME code path as the deals side — pushing it into SQL
|
||||
would require duplicating the regex logic in plpgsql and risk drift.
|
||||
8,600 rows is trivial to hold in memory.
|
||||
Фильтр региона обязателен: без него карта строится по домам ВСЕХ регионов,
|
||||
и прогон по одному региону тихо тащит чужие координаты.
|
||||
|
||||
`houses.region_code` добавлен поздней миграцией (272_houses_region_code.sql)
|
||||
и у части строк NULL. Отбрасывать их нельзя (это ударило бы по покрытию 66),
|
||||
поэтому строка без региона принимается по географии — если её координаты
|
||||
внутри `bbox_region` реестра. Это именно гео-проверка, а не догадка о
|
||||
происхождении строки.
|
||||
|
||||
Агрегация в Python, а не в SQL: вывод ключа должен быть ОДНИМ И ТЕМ ЖЕ
|
||||
кодом на обеих сторонах join'а, дублировать регексы в plpgsql — верный
|
||||
дрейф.
|
||||
"""
|
||||
region = REGIONS[region_code]
|
||||
lat_min, lat_max, lon_min, lon_max = region.bbox_region
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -302,32 +482,81 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
|
|||
" AND lat IS NOT NULL "
|
||||
" AND lon IS NOT NULL "
|
||||
" AND address IS NOT NULL "
|
||||
" AND length(trim(address)) > 0"
|
||||
)
|
||||
" AND length(trim(address)) > 0 "
|
||||
" AND ( region_code = CAST(:rc AS smallint) "
|
||||
" OR ( region_code IS NULL "
|
||||
" AND lat BETWEEN CAST(:lat_min AS double precision) "
|
||||
" AND CAST(:lat_max AS double precision) "
|
||||
" AND lon BETWEEN CAST(:lon_min AS double precision) "
|
||||
" AND CAST(:lon_max AS double precision) ) )"
|
||||
),
|
||||
{
|
||||
"rc": region_code,
|
||||
"lat_min": lat_min,
|
||||
"lat_max": lat_max,
|
||||
"lon_min": lon_min,
|
||||
"lon_max": lon_max,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
# street_key → running [lat_sum, lon_sum, n]
|
||||
acc: dict[str, list[float]] = {}
|
||||
acc: dict[tuple[int, str, str], list[tuple[float, float]]] = {}
|
||||
for r in rows:
|
||||
key = _street_key(r["address"])
|
||||
if len(key) < _MIN_KEY_LEN:
|
||||
key = _address_key(r["address"], region_code)
|
||||
if key is None:
|
||||
continue
|
||||
bucket = acc.setdefault(key, [0.0, 0.0, 0.0])
|
||||
bucket[0] += float(r["lat"])
|
||||
bucket[1] += float(r["lon"])
|
||||
bucket[2] += 1.0
|
||||
acc.setdefault(key, []).append((float(r["lat"]), float(r["lon"])))
|
||||
|
||||
return {
|
||||
key: Centroid(lat=lat_sum / n, lon=lon_sum / n, house_count=int(n))
|
||||
for key, (lat_sum, lon_sum, n) in acc.items()
|
||||
}
|
||||
out: dict[tuple[int, str, str], Centroid] = {}
|
||||
dropped = 0
|
||||
for key, points in acc.items():
|
||||
n = len(points)
|
||||
lat_c = sum(lat for lat, _ in points) / n
|
||||
lon_c = sum(lon for _, lon in points) / n
|
||||
spread = _spread_km(points, lat_c, lon_c)
|
||||
if spread > max_spread_km:
|
||||
# Одна «улица» шириной в десятки километров — это не улица, а
|
||||
# слипшиеся одноимённые улицы разных НП (или один НП, записанный
|
||||
# двумя способами). Среднее таких точек — координата в поле между
|
||||
# городами; отдавать её сделке нельзя, ведро выбрасывается.
|
||||
dropped += 1
|
||||
logger.warning(
|
||||
"centroid bucket dropped: key=%s houses=%d spread=%.1f km > %.1f km",
|
||||
key,
|
||||
n,
|
||||
spread,
|
||||
max_spread_km,
|
||||
)
|
||||
continue
|
||||
out[key] = Centroid(lat=lat_c, lon=lon_c, house_count=n, spread_km=spread)
|
||||
|
||||
if dropped:
|
||||
logger.warning(
|
||||
"sanity: %d/%d вёдер выброшено по разбросу > %.1f km",
|
||||
dropped,
|
||||
len(acc),
|
||||
max_spread_km,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
||||
"""deals needing coords (lat IS NULL) — resume-safe candidate set.
|
||||
def _region_predicate(column: str = "region_code") -> str:
|
||||
"""SQL-предикат «строка принадлежит региону :rc» для deals.
|
||||
|
||||
`deals.region_code` проставлен миграцией 177_deals_city_region.sql; строки,
|
||||
существовавшие ДО неё, по построению екатеринбургские (в скрипт импорта
|
||||
был зашит префикс 'Екатеринбург, '), поэтому NULL засчитывается региону 66
|
||||
и только ему. Для любого другого региона NULL — не кандидат.
|
||||
"""
|
||||
return f"( {column} = CAST(:rc AS int) OR ( {column} IS NULL AND CAST(:rc AS int) = 66 ) )"
|
||||
|
||||
|
||||
def _select_deals_without_coords(
|
||||
db: Session, limit: int, region_code: int = _DEFAULT_REGION_CODE
|
||||
) -> list[DealRow]:
|
||||
"""deals needing coords (lat IS NULL) в пределах ОДНОГО региона.
|
||||
|
||||
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
|
||||
UPDATE sets lat NOT NULL, dropping the row out on the next run.
|
||||
|
|
@ -335,20 +564,21 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
|||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT id, address "
|
||||
"SELECT id, address, city "
|
||||
"FROM deals "
|
||||
"WHERE lat IS NULL "
|
||||
" AND address IS NOT NULL "
|
||||
" AND length(trim(address)) > 0 "
|
||||
" AND " + _region_predicate() + " "
|
||||
"ORDER BY id "
|
||||
"LIMIT CAST(:lim AS int)"
|
||||
),
|
||||
{"lim": limit},
|
||||
{"lim": limit, "rc": region_code},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
|
||||
return [DealRow(id=r["id"], address=r["address"], city=r.get("city")) for r in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -356,7 +586,14 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) -> None:
|
||||
def _update_deal_coords(
|
||||
db: Session,
|
||||
*,
|
||||
deal_id: int,
|
||||
lat: float,
|
||||
lon: float,
|
||||
region_code: int = _DEFAULT_REGION_CODE,
|
||||
) -> None:
|
||||
"""UPDATE deals SET lat/lon + geocode_tried_at=NOW(); geom auto-fills.
|
||||
|
||||
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
|
||||
|
|
@ -370,9 +607,10 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
|||
" SET lat = CAST(:lat AS double precision), "
|
||||
" lon = CAST(:lon AS double precision), "
|
||||
" geocode_tried_at = NOW() "
|
||||
" WHERE id = CAST(:id AS bigint)"
|
||||
" WHERE id = CAST(:id AS bigint) "
|
||||
" AND " + _region_predicate()
|
||||
),
|
||||
{"id": deal_id, "lat": lat, "lon": lon},
|
||||
{"id": deal_id, "lat": lat, "lon": lon, "rc": region_code},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -384,10 +622,11 @@ def _update_deal_coords(db: Session, *, deal_id: int, lat: float, lon: float) ->
|
|||
def _run_backfill(
|
||||
db: Session,
|
||||
deals: list[DealRow],
|
||||
centroids: dict[str, Centroid],
|
||||
centroids: dict[tuple[int, str, str], Centroid],
|
||||
*,
|
||||
batch: str,
|
||||
dry_run: bool,
|
||||
region_code: int = _DEFAULT_REGION_CODE,
|
||||
) -> Stats:
|
||||
"""For each deal, look up its street centroid and UPDATE lat/lon.
|
||||
|
||||
|
|
@ -399,14 +638,14 @@ def _run_backfill(
|
|||
stats = Stats()
|
||||
|
||||
for i, deal in enumerate(deals, start=1):
|
||||
key = _street_key(deal.address)
|
||||
centroid = centroids.get(key) if len(key) >= _MIN_KEY_LEN else None
|
||||
key = _address_key(deal.address, region_code, city=deal.city)
|
||||
centroid = centroids.get(key) if key is not None else None
|
||||
|
||||
if centroid is None:
|
||||
stats.no_street_match += 1
|
||||
# Track the raw key (or a sentinel) so the dry-run report can show
|
||||
# which streets we're missing. Empty key → '<no-street-parsed>'.
|
||||
stats.unmatched_streets[key or "<no-street-parsed>"] += 1
|
||||
stats.unmatched_streets["/".join(key[1:]) if key else "<no-street-parsed>"] += 1
|
||||
stats.processed += 1
|
||||
if dry_run and i % _LOG_EVERY == 0:
|
||||
logger.info(
|
||||
|
|
@ -423,7 +662,13 @@ def _run_backfill(
|
|||
else:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
_update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
|
||||
_update_deal_coords(
|
||||
db,
|
||||
deal_id=deal.id,
|
||||
lat=centroid.lat,
|
||||
lon=centroid.lon,
|
||||
region_code=region_code,
|
||||
)
|
||||
# Per-row commit so resume picks up exactly where we crashed.
|
||||
db.commit()
|
||||
stats.geocoded += 1
|
||||
|
|
@ -454,7 +699,7 @@ def _run_backfill(
|
|||
|
||||
def _report_dry_run(
|
||||
stats: Stats,
|
||||
centroids: dict[str, Centroid],
|
||||
centroids: dict[tuple[int, str, str], Centroid],
|
||||
*,
|
||||
total_deals_null: int,
|
||||
candidates: int,
|
||||
|
|
@ -475,7 +720,7 @@ def _report_dry_run(
|
|||
|
||||
logger.info("─" * 60)
|
||||
logger.info("DRY-RUN SUMMARY (no DB writes)")
|
||||
logger.info("distinct streets with a house centroid: %d", distinct_streets)
|
||||
logger.info("distinct (region, НП, улица) keys with a house centroid: %d", distinct_streets)
|
||||
logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
|
||||
logger.info("deals matched to a centroid: %d", matched)
|
||||
logger.info("deals with no street match: %d", stats.no_street_match)
|
||||
|
|
@ -486,7 +731,7 @@ def _report_dry_run(
|
|||
projected,
|
||||
match_rate * 100.0,
|
||||
)
|
||||
logger.info("top-10 unmatched deal streets (by row count):")
|
||||
logger.info("top-10 unmatched deal keys НП/улица (by row count):")
|
||||
for street, cnt in stats.unmatched_streets.most_common(10):
|
||||
logger.info(" %6d %s", cnt, street)
|
||||
logger.info("─" * 60)
|
||||
|
|
@ -497,13 +742,15 @@ def _report_dry_run(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_deals_null(db: Session) -> int:
|
||||
"""Full count of deals WHERE lat IS NULL — denominator for projection."""
|
||||
def _count_deals_null(db: Session, region_code: int = _DEFAULT_REGION_CODE) -> int:
|
||||
"""Full count of deals WHERE lat IS NULL в этом регионе — знаменатель."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT count(*) AS n FROM deals "
|
||||
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0"
|
||||
)
|
||||
"WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) > 0 "
|
||||
" AND " + _region_predicate()
|
||||
),
|
||||
{"rc": region_code},
|
||||
).first()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
|
@ -527,6 +774,25 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
default=f"deals_geo_{date.today().isoformat()}",
|
||||
help="Log batch label (does not affect DB filters — logs only).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--region-code",
|
||||
type=int,
|
||||
default=_DEFAULT_REGION_CODE,
|
||||
choices=sorted(REGIONS),
|
||||
help=(
|
||||
"Регион прогона (default %(default)s). Отбирает и обновляет ТОЛЬКО "
|
||||
"строки этого региона; входит в ключ join'а."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-spread-km",
|
||||
type=float,
|
||||
default=_DEFAULT_MAX_SPREAD_KM,
|
||||
help=(
|
||||
"Порог разброса домов внутри ключа (default %(default)s км). Ведро "
|
||||
"с большим разбросом — склейка нескольких НП, оно выбрасывается."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
|
|
@ -543,30 +809,47 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"""CLI entry point. Returns the number of deals geocoded this run."""
|
||||
args = _parse_args(argv)
|
||||
logger.info(
|
||||
"starting batch=%s limit=%s dry_run=%s",
|
||||
"starting batch=%s region=%s limit=%s max_spread_km=%s dry_run=%s",
|
||||
args.batch,
|
||||
args.region_code,
|
||||
args.limit,
|
||||
args.max_spread_km,
|
||||
args.dry_run,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
centroids = _build_centroid_map(db)
|
||||
logger.info("built centroid map: %d distinct streets", len(centroids))
|
||||
centroids = _build_centroid_map(
|
||||
db, region_code=args.region_code, max_spread_km=args.max_spread_km
|
||||
)
|
||||
logger.info(
|
||||
"built centroid map: %d distinct keys (region %s)", len(centroids), args.region_code
|
||||
)
|
||||
if not centroids:
|
||||
logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
|
||||
logger.warning(
|
||||
"no house centroids for region %s — houses has no geocoded rows there; "
|
||||
"nothing to do",
|
||||
args.region_code,
|
||||
)
|
||||
return 0
|
||||
|
||||
deals = _select_deals_without_coords(db, args.limit)
|
||||
deals = _select_deals_without_coords(db, args.limit, region_code=args.region_code)
|
||||
logger.info("loaded deals without coords: %d", len(deals))
|
||||
if not deals:
|
||||
logger.info("nothing to do — no deals with lat IS NULL and an address")
|
||||
return 0
|
||||
|
||||
stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
|
||||
stats = _run_backfill(
|
||||
db,
|
||||
deals,
|
||||
centroids,
|
||||
batch=args.batch,
|
||||
dry_run=args.dry_run,
|
||||
region_code=args.region_code,
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
total_null = _count_deals_null(db)
|
||||
total_null = _count_deals_null(db, region_code=args.region_code)
|
||||
_report_dry_run(
|
||||
stats,
|
||||
centroids,
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@
|
|||
"calibration": {
|
||||
"high": {
|
||||
"coverage_pct": 75.0,
|
||||
"mape_pct": 25.94,
|
||||
"mape_pct": 19.97,
|
||||
"n": 8,
|
||||
"n_covered": 6
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 87.41,
|
||||
"mape_pct": 14.06,
|
||||
"coverage_pct": 83.67,
|
||||
"mape_pct": 15.64,
|
||||
"n": 1562,
|
||||
"n_covered": 1076
|
||||
"n_covered": 1030
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 73.33,
|
||||
"mape_pct": 18.64,
|
||||
"coverage_pct": 70.0,
|
||||
"mape_pct": 26.53,
|
||||
"n": 30,
|
||||
"n_covered": 22
|
||||
"n_covered": 21
|
||||
}
|
||||
},
|
||||
"confidence_order": [
|
||||
|
|
@ -26,111 +26,111 @@
|
|||
],
|
||||
"expected_sold": {
|
||||
"overall": {
|
||||
"mape_pct": 14.28,
|
||||
"median_bias_pct": -1.61,
|
||||
"mape_pct": 15.92,
|
||||
"median_bias_pct": 2.87,
|
||||
"n": 1269,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.08,
|
||||
"p75_pct": 14.6
|
||||
"p25_pct": -10.94,
|
||||
"p75_pct": 22.81
|
||||
},
|
||||
"per_area_bucket": {
|
||||
"0 <30": {
|
||||
"mape_pct": 16.51,
|
||||
"median_bias_pct": -3.13,
|
||||
"mape_pct": 19.76,
|
||||
"median_bias_pct": -1.02,
|
||||
"n": 161,
|
||||
"p25_pct": -14.08,
|
||||
"p75_pct": 22.87
|
||||
"p25_pct": -16.45,
|
||||
"p75_pct": 34.7
|
||||
},
|
||||
"1 30-44": {
|
||||
"mape_pct": 14.06,
|
||||
"median_bias_pct": -2.33,
|
||||
"mape_pct": 15.64,
|
||||
"median_bias_pct": 4.37,
|
||||
"n": 503,
|
||||
"p25_pct": -14.49,
|
||||
"p75_pct": 12.58
|
||||
"p25_pct": -10.56,
|
||||
"p75_pct": 22.86
|
||||
},
|
||||
"2 44-62": {
|
||||
"mape_pct": 12.48,
|
||||
"median_bias_pct": -0.39,
|
||||
"mape_pct": 13.93,
|
||||
"median_bias_pct": 4.13,
|
||||
"n": 379,
|
||||
"p25_pct": -11.23,
|
||||
"p75_pct": 14.65
|
||||
"p25_pct": -8.12,
|
||||
"p75_pct": 23.5
|
||||
},
|
||||
"3 62-85": {
|
||||
"mape_pct": 15.24,
|
||||
"median_bias_pct": -1.94,
|
||||
"mape_pct": 15.97,
|
||||
"median_bias_pct": -2.33,
|
||||
"n": 179,
|
||||
"p25_pct": -14.98,
|
||||
"p75_pct": 15.27
|
||||
"p25_pct": -11.99,
|
||||
"p75_pct": 18.18
|
||||
},
|
||||
"4 >=85": {
|
||||
"mape_pct": 20.88,
|
||||
"median_bias_pct": -10.36,
|
||||
"mape_pct": 20.46,
|
||||
"median_bias_pct": -4.97,
|
||||
"n": 47,
|
||||
"p25_pct": -24.08,
|
||||
"p75_pct": 13.38
|
||||
"p25_pct": -26.03,
|
||||
"p75_pct": 7.15
|
||||
}
|
||||
},
|
||||
"per_rooms": {
|
||||
"0": {
|
||||
"label": "студия",
|
||||
"mape_pct": 16.51,
|
||||
"median_bias_pct": -3.13,
|
||||
"mape_pct": 19.76,
|
||||
"median_bias_pct": -1.02,
|
||||
"n": 161,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.08,
|
||||
"p75_pct": 22.87
|
||||
"p25_pct": -16.45,
|
||||
"p75_pct": 34.7
|
||||
},
|
||||
"1": {
|
||||
"label": "1к",
|
||||
"mape_pct": 14.06,
|
||||
"median_bias_pct": -2.33,
|
||||
"mape_pct": 15.64,
|
||||
"median_bias_pct": 4.37,
|
||||
"n": 503,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.49,
|
||||
"p75_pct": 12.58
|
||||
"p25_pct": -10.56,
|
||||
"p75_pct": 22.86
|
||||
},
|
||||
"2": {
|
||||
"label": "2к",
|
||||
"mape_pct": 12.48,
|
||||
"median_bias_pct": -0.39,
|
||||
"mape_pct": 13.93,
|
||||
"median_bias_pct": 4.13,
|
||||
"n": 379,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -11.23,
|
||||
"p75_pct": 14.65
|
||||
"p25_pct": -8.12,
|
||||
"p75_pct": 23.5
|
||||
},
|
||||
"3": {
|
||||
"label": "3к",
|
||||
"mape_pct": 15.24,
|
||||
"median_bias_pct": -1.94,
|
||||
"mape_pct": 15.97,
|
||||
"median_bias_pct": -2.33,
|
||||
"n": 179,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.98,
|
||||
"p75_pct": 15.27
|
||||
"p25_pct": -11.99,
|
||||
"p75_pct": 18.18
|
||||
},
|
||||
"4": {
|
||||
"label": "4+",
|
||||
"mape_pct": 20.88,
|
||||
"median_bias_pct": -10.36,
|
||||
"mape_pct": 20.46,
|
||||
"median_bias_pct": -4.97,
|
||||
"n": 47,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -24.08,
|
||||
"p75_pct": 13.38
|
||||
"p25_pct": -26.03,
|
||||
"p75_pct": 7.15
|
||||
}
|
||||
},
|
||||
"per_segment": {
|
||||
"бизнес": {
|
||||
"mape_pct": 16.86,
|
||||
"median_bias_pct": -16.84,
|
||||
"mape_pct": 19.58,
|
||||
"median_bias_pct": -19.58,
|
||||
"n": 144,
|
||||
"p25_pct": -25.83,
|
||||
"p75_pct": -9.36
|
||||
"p25_pct": -27.32,
|
||||
"p75_pct": -12.79
|
||||
},
|
||||
"комфорт": {
|
||||
"mape_pct": 12.14,
|
||||
"median_bias_pct": -5.9,
|
||||
"mape_pct": 10.69,
|
||||
"median_bias_pct": -5.78,
|
||||
"n": 425,
|
||||
"p25_pct": -16.66,
|
||||
"p75_pct": 4.9
|
||||
"p25_pct": -15.98,
|
||||
"p75_pct": 4.54
|
||||
},
|
||||
"премиум": {
|
||||
"mape_pct": null,
|
||||
|
|
@ -140,18 +140,18 @@
|
|||
"p75_pct": null
|
||||
},
|
||||
"эконом": {
|
||||
"mape_pct": 15.17,
|
||||
"median_bias_pct": 6.26,
|
||||
"mape_pct": 20.22,
|
||||
"median_bias_pct": 18.98,
|
||||
"n": 697,
|
||||
"p25_pct": -6.89,
|
||||
"p75_pct": 28.97
|
||||
"p25_pct": 0.96,
|
||||
"p75_pct": 42.31
|
||||
},
|
||||
"элит": {
|
||||
"mape_pct": 31.18,
|
||||
"median_bias_pct": -31.18,
|
||||
"mape_pct": 36.11,
|
||||
"median_bias_pct": -36.11,
|
||||
"n": 3,
|
||||
"p25_pct": -46.94,
|
||||
"p75_pct": -28.1
|
||||
"p25_pct": -44.21,
|
||||
"p75_pct": -32.72
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -162,9 +162,9 @@
|
|||
},
|
||||
"range_coverage": {
|
||||
"overall": {
|
||||
"coverage_pct": 87.0,
|
||||
"coverage_pct": 83.29,
|
||||
"n": 1269,
|
||||
"n_covered": 1104
|
||||
"n_covered": 1057
|
||||
},
|
||||
"per_confidence": {
|
||||
"high": {
|
||||
|
|
@ -173,14 +173,14 @@
|
|||
"n_covered": 6
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 87.41,
|
||||
"coverage_pct": 83.67,
|
||||
"n": 1231,
|
||||
"n_covered": 1076
|
||||
"n_covered": 1030
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 73.33,
|
||||
"coverage_pct": 70.0,
|
||||
"n": 30,
|
||||
"n_covered": 22
|
||||
"n_covered": 21
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
911
tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html
vendored
Normal file
911
tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html
vendored
Normal 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®ion=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>
|
||||
120
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json
vendored
Normal file
120
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue