Compare commits
144 commits
fix/browse
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baed8f75a3 | |||
| dbac5d4e1c | |||
| f71a438495 | |||
| e5212e52ab | |||
| 98f587add6 | |||
| 7c52622a76 | |||
| a82239b931 | |||
| 1265e69412 | |||
| 8b9b541736 | |||
| a1c7f8ea92 | |||
| d633c0d1c3 | |||
| da5fd24435 | |||
| f0d98934bd | |||
| 21f9c29598 | |||
| 9b818c2029 | |||
| fd454dec4b | |||
| 8bd16e6ad0 | |||
| 7da94f183f | |||
| 9e337855da | |||
| 5b6be30894 | |||
| 71e551d57d | |||
| a6a1b7cdfc | |||
| 9c71349eed | |||
| 4c11a7dab5 | |||
| f9bebdf40f | |||
| e942acaf12 | |||
| 3a8513cd68 | |||
| 77fdeefc50 | |||
| 6eaa31ac66 | |||
| 5af29ee9a4 | |||
| 92af3491ae | |||
| d76411b45f | |||
| c14119a20b | |||
| 084cb79f99 | |||
| 86629dc9f4 | |||
| 372328b726 | |||
| 145a3a7944 | |||
| 6375b191e4 | |||
| 2b73961af5 | |||
| cd504c1eb9 | |||
| f9045673e1 | |||
| 4ccf701df6 | |||
| 21fe24419a | |||
| 33182e7df9 | |||
| 89b4a67508 | |||
| 8def34028c | |||
| b74b02ea8b | |||
| be1403289a | |||
| 30d40e5747 | |||
| 3ee5244491 | |||
| 6003ddedad | |||
| bdc169af8b | |||
| 7350287be7 | |||
| 85d455e387 | |||
| 3d9eaa2704 | |||
| 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 | |||
| 92295971fe | |||
| 808a4fb4cb | |||
| d2a31dff08 | |||
| eade88bc66 | |||
| 6be2bf9aaa | |||
| fb02e41d77 | |||
| a6c86e88ea | |||
| 19b6081242 | |||
| 7218c2094c | |||
| facdbb2566 | |||
| e42e370ac9 | |||
| 8e5f097c5e | |||
| ec85ea1861 | |||
| e5db8dd80f | |||
| e2b1a8adee | |||
| bc0ef9536e | |||
| ceb2e5972d | |||
| 31c31f8f82 | |||
| 1fe383f935 | |||
| 26a7f1c163 | |||
| 883c741a63 | |||
| 0ff2703032 | |||
| 45bd47fdd7 | |||
| 08216e4c43 | |||
| 56f0cdbdf7 | |||
| 4dffb2d1fb | |||
| 9f11b8ed69 | |||
| 71d60ff13e | |||
| f1f0d297cf |
193 changed files with 12179 additions and 2163 deletions
|
|
@ -233,6 +233,10 @@ jobs:
|
||||||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||||
- '.forgejo/workflows/deploy-metrics.yml'
|
- '.forgejo/workflows/deploy-metrics.yml'
|
||||||
- 'docker-compose.metrics.yml'
|
- 'docker-compose.metrics.yml'
|
||||||
|
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
||||||
|
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
||||||
|
# экспортёра) без этой строки прошла бы мимо гейта.
|
||||||
|
- 'docker-compose.metrics-agent.yml'
|
||||||
# #3443: тот же класс, третий раз. Гейт
|
# #3443: тот же класс, третий раз. Гейт
|
||||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||||
|
|
@ -256,10 +260,12 @@ jobs:
|
||||||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||||
# не бежали ни разу с момента написания.
|
# не бежали ни разу с момента написания.
|
||||||
#
|
#
|
||||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
||||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
||||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
||||||
# нужен, в отличие от tradein-лэйна.
|
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
||||||
|
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
||||||
|
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
||||||
#
|
#
|
||||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||||
|
|
@ -290,15 +296,14 @@ jobs:
|
||||||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||||
#
|
#
|
||||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
||||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||||
docker run -d --name "$CI_PG" \
|
docker run -d --name "$CI_PG" \
|
||||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||||
postgres:16
|
postgis/postgis:16-3.4
|
||||||
|
|
||||||
ready=""
|
ready=""
|
||||||
for _ in $(seq 1 45); do
|
for _ in $(seq 1 45); do
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,16 @@ jobs:
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||||
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
||||||
|
# #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и
|
||||||
|
# аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок
|
||||||
|
# METRICS_WATCHDOG_PING_BLOCK ниже.
|
||||||
|
METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }}
|
||||||
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
||||||
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
||||||
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
||||||
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
||||||
with:
|
with:
|
||||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
|
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET,METRICS_WATCHDOG_PING_URL
|
||||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
@ -193,6 +197,24 @@ jobs:
|
||||||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и
|
||||||
|
# аналоги) — заменяет регулярные сообщения в Telegram, см.
|
||||||
|
# комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок
|
||||||
|
# целиком, а не значение — та же причина, что у
|
||||||
|
# METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в
|
||||||
|
# receiver'е не деградирует, а валит amtool check-config
|
||||||
|
# целиком, то есть роняет ВЕСЬ алертинг из-за одного
|
||||||
|
# необязательного получателя. Секрет пока не заведён —
|
||||||
|
# деградация корректна: receiver остаётся без `*_configs` и
|
||||||
|
# молча ничего никуда не шлёт, amtool это пропускает.
|
||||||
|
if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then
|
||||||
|
METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}")
|
||||||
|
echo "Watchdog: внешний deadman-пинг настроен."
|
||||||
|
else
|
||||||
|
METRICS_WATCHDOG_PING_BLOCK=""
|
||||||
|
echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)."
|
||||||
|
fi
|
||||||
|
|
||||||
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
||||||
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
||||||
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
||||||
|
|
@ -221,7 +243,8 @@ jobs:
|
||||||
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
||||||
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
||||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
|
METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \
|
||||||
|
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \
|
||||||
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
||||||
> ops/metrics/alertmanager/alertmanager.yml
|
> ops/metrics/alertmanager/alertmanager.yml
|
||||||
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
||||||
|
|
@ -388,6 +411,15 @@ jobs:
|
||||||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||||
fi
|
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: СНАЧАЛА проверить, потом применять ──────────────────
|
||||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||||
|
|
@ -406,15 +438,6 @@ jobs:
|
||||||
fi
|
fi
|
||||||
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` их не
|
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||||
# перечитывает ────────────────────────────────────────────────
|
# перечитывает ────────────────────────────────────────────────
|
||||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||||
|
|
@ -424,27 +447,41 @@ jobs:
|
||||||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||||
#
|
#
|
||||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
||||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
||||||
# после `git reset --hard` подхватывается без пересоздания
|
# файла: после `git reset --hard` за путём в контейнере остаётся
|
||||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
||||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
||||||
# никто не звал сам reload.
|
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
||||||
|
# --web.enable-lifecycle включён в compose ради этого шага.
|
||||||
#
|
#
|
||||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
||||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
||||||
#
|
# контейнером того же образа. `docker exec` в работающий проверял
|
||||||
# Плюс юнит-тесты правил (#3493): синтаксически верное правило может
|
# бы тот самый старый инод, а не то, что сейчас применится.
|
||||||
# врать по смыслу — `count(x == 1) == 0` от пустого вектора не
|
# Синтаксически верное правило может врать по смыслу —
|
||||||
# срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
|
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
||||||
# экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
|
# отсюда test rules.
|
||||||
# одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
|
if docker run --rm --entrypoint sh \
|
||||||
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
||||||
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \
|
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
||||||
&& docker run --rm --entrypoint promtool \
|
-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
|
||||||
-v /opt/gendesign/ops/metrics/prometheus:/work:ro \
|
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
gendesign-metrics docker-compose.metrics.yml \
|
||||||
test rules /work/tests/infra_test.yml; then
|
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":"[^"]*"')"
|
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
|
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||||
|
|
@ -470,6 +507,30 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
agent-apps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -561,6 +622,21 @@ jobs:
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
[ "$(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; }
|
|| { 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:
|
agent-infra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: server
|
needs: server
|
||||||
|
|
@ -630,3 +706,10 @@ jobs:
|
||||||
|
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
[ "$(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; }
|
|| { 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
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ on:
|
||||||
# исполнял бы старую версию — молча и без единого сигнала.
|
# исполнял бы старую версию — молча и без единого сигнала.
|
||||||
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
||||||
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
# запускаемый по 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). Глоб закрывает класс целиком.
|
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
||||||
|
|
|
||||||
|
|
@ -1910,17 +1910,19 @@ def trigger_ekburg_permits(
|
||||||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
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
|
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
|
||||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
|
||||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
|
||||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
|
||||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
|
||||||
_WAF_COOLDOWN_GUARD_MSG = (
|
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
|
||||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
_DOMRF_BLOCK_GUARD_MSG = (
|
||||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
|
||||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
|
||||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
|
||||||
"передай i_understand_waf_risk=true в теле запроса."
|
"не снимает, запуск отсюда даст только отказы. Передавай "
|
||||||
|
"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,
|
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
|
|
||||||
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||||
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||||
объектов до next beat fire).
|
свежесозданных объектов).
|
||||||
|
|
||||||
- max_objects=None → дефолтный лимит таска (300).
|
- max_objects=None → дефолтный лимит таска (300).
|
||||||
- max_objects=3 → smoke-тест.
|
- max_objects=3 → smoke-тест.
|
||||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
|
|
||||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||||
re-trigger без явного подтверждения оператора запрещён.
|
запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
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
|
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-тест.
|
- max_flats=3 → smoke-тест.
|
||||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
|
|
||||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||||
того же WAF-бана.
|
блокировкой StormWall (условие — #3307).
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
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
|
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,74 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
|
||||||
_GEO_PRICE_MIN_LOTS: int = 10
|
_GEO_PRICE_MIN_LOTS: int = 10
|
||||||
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
||||||
|
|
||||||
|
_GEO_RADIUS_PRICE_SQL = text("""
|
||||||
|
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
||||||
|
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
||||||
|
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
||||||
|
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
||||||
|
-- CTE через проекты ближних ЖК. НЕ через
|
||||||
|
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
||||||
|
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
||||||
|
-- 1.76M (~6.4 s на request-path analyze_parcel). Дедуп до price-фильтра:
|
||||||
|
-- цена объективна по физлоту (последний снапшот).
|
||||||
|
-- #3583: дедуп по проекту в LATERAL + premise_kind='квартира' → Index Only Scan
|
||||||
|
-- objective_lots_physflat_covering_v2_idx без сортировки. Общий DISTINCT ON по
|
||||||
|
-- всем снапшотам проектов шёл external merge на диск: прод-EXPLAIN 17.09, 16
|
||||||
|
-- проектов, 690 мс против 62 мс. Апартаменты на проде 17.09 есть у 6 проектов из
|
||||||
|
-- complex_sources, у всех complex без координат → в радиус не попадают, фильтр
|
||||||
|
-- медиану не меняет (замер на 570 участках: 0 отличий).
|
||||||
|
WITH nearby_cx AS (
|
||||||
|
-- #3583: complex → проект Объектива из complex_sources (source='objective',
|
||||||
|
-- 1:1), а НЕ из objective_lots.complex_id. Тот проставлен один раз миграцией 76,
|
||||||
|
-- а еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
|
||||||
|
-- переписывает project_name и не трогает complex_id → под id ближнего ЖК
|
||||||
|
-- лежат лоты чужих (прод 17.09: 236 354 из 303 677 строк). Тот же дефект,
|
||||||
|
-- что #2962 в competitors.py, и та же сверка имени: связь fuzzy, у «ЖК VEER
|
||||||
|
-- PARK» стоит 'Clever Park' в 11.7 км, у «ЖК Графит» — 'Гранит'.
|
||||||
|
SELECT c.id, cs.source_id AS project_name
|
||||||
|
FROM complexes c
|
||||||
|
JOIN complex_sources cs
|
||||||
|
ON cs.complex_id = c.id
|
||||||
|
AND cs.source = 'objective'
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT regexp_replace(lower(c.canonical_name), '[^0-9a-zа-яё]', '', 'g') AS cx_key,
|
||||||
|
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
||||||
|
) k
|
||||||
|
WHERE c.latitude IS NOT NULL
|
||||||
|
AND c.longitude IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326
|
||||||
|
)::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
AND ( k.cx_key LIKE '%' || k.project_key || '%'
|
||||||
|
OR k.project_key LIKE '%' || k.cx_key || '%')
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT l.price_per_m2_rub, nc.id AS complex_id
|
||||||
|
FROM nearby_cx nc
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT DISTINCT ON (ol.corpus_name, ol.section, ol.floor, ol.lot_number)
|
||||||
|
ol.price_per_m2_rub
|
||||||
|
FROM objective_lots ol
|
||||||
|
WHERE ol.project_name = nc.project_name
|
||||||
|
AND ol.premise_kind = 'квартира'
|
||||||
|
ORDER BY ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||||
|
ol.snapshot_date DESC, ol.id DESC
|
||||||
|
) l
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (
|
||||||
|
ORDER BY price_per_m2_rub
|
||||||
|
) AS median,
|
||||||
|
count(*) AS n,
|
||||||
|
count(DISTINCT complex_id) AS n_complexes
|
||||||
|
FROM latest
|
||||||
|
WHERE price_per_m2_rub IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
||||||
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
||||||
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
||||||
|
|
@ -3562,7 +3630,7 @@ def analyze_parcel(
|
||||||
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
|
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
|
||||||
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
|
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
|
||||||
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
|
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
|
||||||
# complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py).
|
# complexes (latitude/longitude), проект — через complex_sources (#3583, как #2962).
|
||||||
geo_radius_price: dict[str, Any]
|
geo_radius_price: dict[str, Any]
|
||||||
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
|
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
|
||||||
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
|
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
|
||||||
|
|
@ -3573,60 +3641,7 @@ def analyze_parcel(
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
grp_row = (
|
grp_row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
_GEO_RADIUS_PRICE_SQL,
|
||||||
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
|
||||||
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
|
||||||
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
|
||||||
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
|
||||||
-- CTE через complex_id ближних ЖК. НЕ через
|
|
||||||
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
|
||||||
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
|
||||||
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
|
|
||||||
-- geo-index по complexes → Nested Loop bitmap
|
|
||||||
-- objective_lots_complex_idx по ~186 ЖК → ~120 ms
|
|
||||||
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
|
|
||||||
-- цена объективна по физлоту (последний снапшот).
|
|
||||||
WITH nearby_cx AS (
|
|
||||||
SELECT c.id
|
|
||||||
FROM complexes c
|
|
||||||
WHERE c.latitude IS NOT NULL
|
|
||||||
AND c.longitude IS NOT NULL
|
|
||||||
AND ST_DWithin(
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_MakePoint(c.longitude, c.latitude),
|
|
||||||
4326
|
|
||||||
)::geography,
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_MakePoint(
|
|
||||||
CAST(:lon AS float),
|
|
||||||
CAST(:lat AS float)
|
|
||||||
), 4326
|
|
||||||
)::geography,
|
|
||||||
CAST(:radius_m AS float)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
latest AS (
|
|
||||||
SELECT DISTINCT ON (
|
|
||||||
ol.project_name, ol.corpus_name, ol.section,
|
|
||||||
ol.floor, ol.lot_number
|
|
||||||
)
|
|
||||||
ol.price_per_m2_rub,
|
|
||||||
ol.complex_id
|
|
||||||
FROM objective_lots ol
|
|
||||||
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
|
|
||||||
ORDER BY ol.project_name, ol.corpus_name, ol.section,
|
|
||||||
ol.floor, ol.lot_number,
|
|
||||||
ol.snapshot_date DESC, ol.id DESC
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (
|
|
||||||
ORDER BY price_per_m2_rub
|
|
||||||
) AS median,
|
|
||||||
count(*) AS n,
|
|
||||||
count(DISTINCT complex_id) AS n_complexes
|
|
||||||
FROM latest
|
|
||||||
WHERE price_per_m2_rub IS NOT NULL
|
|
||||||
"""),
|
|
||||||
{
|
{
|
||||||
"lon": centroid_lon,
|
"lon": centroid_lon,
|
||||||
"lat": centroid_lat,
|
"lat": centroid_lat,
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
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 в радиусе (до группировки)
|
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||||
|
|
|
||||||
|
|
@ -587,19 +587,6 @@ class NSPDBulkClient:
|
||||||
)
|
)
|
||||||
return results
|
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 ───────────────────────────────────────────
|
# ── 4. list_objects_in_building ───────────────────────────────────────────
|
||||||
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
||||||
# Готов для per-building помещения/парковка фазы.
|
# Готов для per-building помещения/парковка фазы.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
@ -260,10 +259,8 @@ async def harvest_quarter(
|
||||||
update_progress(done_progress)
|
update_progress(done_progress)
|
||||||
|
|
||||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||||
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
|
||||||
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
|
||||||
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
|
||||||
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
|
||||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||||
if stats_features:
|
if stats_features:
|
||||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
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)
|
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||||
db.commit()
|
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 по всем
|
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||||
|
|
@ -679,7 +657,7 @@ def upsert_features(
|
||||||
cat = feature.category_id
|
cat = feature.category_id
|
||||||
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
||||||
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
||||||
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
|
# квартала. Зеркалит _grid_walk_category:
|
||||||
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
||||||
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
||||||
try:
|
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 цифр.
|
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
||||||
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
||||||
|
|
||||||
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
|
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
|
||||||
_RE_ACT_NUMBER = re.compile(
|
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
|
||||||
r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
|
||||||
re.UNICODE | re.IGNORECASE,
|
# даёт обрезать чужой номер: из «№ 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})")
|
_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)
|
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(
|
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||||
normalized, require_act_context=True
|
normalized, require_act_context=True
|
||||||
)
|
)
|
||||||
|
|
@ -289,9 +295,26 @@ def extract_izyatie_records(
|
||||||
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_number(text: str) -> str | None:
|
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
|
"""Извлекает номер акта.
|
||||||
m = _RE_ACT_NUMBER.search(text)
|
|
||||||
|
Без 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:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return re.sub(r"\s+", "", m.group(1))
|
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)
|
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:
|
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
"""Извлекает дату акта «от 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):
|
m = _act_date_match(text, require_act_context=require_act_context)
|
||||||
if require_act_context and not _act_context_matches(text, m.start()):
|
if m is None:
|
||||||
continue
|
return None
|
||||||
day, month, year = m.group(1), m.group(2), m.group(3)
|
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
||||||
try:
|
|
||||||
# Валидируем диапазоны.
|
|
||||||
d, mo, y = int(day), int(month), int(year)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
|
||||||
return f"{y:04d}-{mo:02d}-{d:02d}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_purpose(text: str) -> str | None:
|
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-РП и т.п.
|
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
||||||
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
||||||
|
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
|
||||||
|
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
|
||||||
|
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
|
||||||
|
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
|
||||||
|
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
|
||||||
_RE_ACT_NUMBER = re.compile(
|
_RE_ACT_NUMBER = re.compile(
|
||||||
r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
|
||||||
re.UNICODE,
|
re.UNICODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1109,9 +1109,6 @@ def get_best_layouts(
|
||||||
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
|
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]
|
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
|
||||||
raw_objects_total_in_radius = len(all_obj_ids)
|
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 / filter из request (и к obj_ids, и к строкам для группировки)
|
||||||
exclude_set = set(request.exclude_competitor_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)]
|
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
|
||||||
|
|
||||||
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
|
# Группируем отфильтрованные 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)
|
complex_groups = group_radius_objects(id_row_dicts)
|
||||||
|
|
||||||
if not all_obj_ids:
|
if not all_obj_ids:
|
||||||
return _empty_response(
|
return _empty_response(
|
||||||
radius_km=request.radius_km,
|
radius_km=request.radius_km,
|
||||||
time_window=request.time_window,
|
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,
|
raw_objects_total=raw_objects_total_in_radius,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -344,21 +344,26 @@ _COMPETITORS_SQL = text("""
|
||||||
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
||||||
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
||||||
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
|
-- пре-фильтрован (радиус участка применяется ниже, в финальном 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)
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
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
|
FROM latest_obj o
|
||||||
JOIN complexes c
|
JOIN complexes c
|
||||||
ON c.latitude IS NOT NULL
|
ON c.latitude IS NOT NULL
|
||||||
AND c.longitude IS NOT NULL
|
AND c.longitude IS NOT NULL
|
||||||
AND c.canonical_name 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(
|
AND ST_DWithin(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.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(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_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
|
WHERE o.comm_name IS NOT NULL
|
||||||
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
||||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||||
|
|
@ -376,7 +395,13 @@ _COMPETITORS_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.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 (
|
mapped AS (
|
||||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||||
|
|
@ -384,15 +409,18 @@ _COMPETITORS_SQL = text("""
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
UNION
|
UNION
|
||||||
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
|
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
|
||||||
-- (у комплекса может быть несколько корпус-project_name → velocity
|
-- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена).
|
||||||
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
|
|
||||||
SELECT nc.obj_id,
|
SELECT nc.obj_id,
|
||||||
ol.project_name AS objective_complex_name
|
nc.project_name AS objective_complex_name
|
||||||
FROM nearest_cx nc
|
FROM nearest_cx nc
|
||||||
JOIN objective_lots ol
|
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
|
||||||
ON ol.complex_id = nc.complex_id
|
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
|
||||||
AND ol.project_name IS NOT NULL
|
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
||||||
|
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
||||||
|
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
||||||
|
-- гео-фильтра — 3 с против 0.4 с.
|
||||||
|
WHERE nc.name_ok
|
||||||
),
|
),
|
||||||
velocity AS (
|
velocity AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text("""
|
||||||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
|
||||||
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
|
||||||
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
|
||||||
|
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
|
||||||
|
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
|
||||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
|
|
@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
nearest_cx AS (
|
nearest_cx AS (
|
||||||
SELECT DISTINCT ON (o.obj_id)
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
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
|
FROM domrf_kn_objects o
|
||||||
JOIN complexes c
|
JOIN complexes c
|
||||||
ON c.latitude IS NOT NULL
|
ON c.latitude IS NOT NULL
|
||||||
AND c.longitude IS NOT NULL
|
AND c.longitude IS NOT NULL
|
||||||
AND c.canonical_name 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(
|
AND ST_DWithin(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.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(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_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)
|
WHERE o.obj_id = ANY(:obj_ids)
|
||||||
AND o.latitude IS NOT NULL
|
AND o.latitude IS NOT NULL
|
||||||
AND o.longitude IS NOT NULL
|
AND o.longitude IS NOT NULL
|
||||||
|
|
@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.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 (
|
gapfill_price AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
ol.price_per_m2_rub AS price_per_m2_rub
|
ol.price_per_m2_rub AS price_per_m2_rub
|
||||||
FROM nearest_cx nc
|
FROM nearest_cx nc
|
||||||
JOIN objective_lots ol
|
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
|
AND ol.price_per_m2_rub IS NOT NULL
|
||||||
|
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
|
||||||
|
WHERE nc.name_ok
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.obj_id,
|
p.obj_id,
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
|
||||||
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
||||||
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
||||||
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
|
# 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 логировал
|
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
|
||||||
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
||||||
#
|
#
|
||||||
|
|
@ -346,11 +346,13 @@ def build_beat_schedule() -> dict:
|
||||||
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
||||||
# за ~5 недель полного обновления.
|
# за ~5 недель полного обновления.
|
||||||
#
|
#
|
||||||
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
|
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
|
||||||
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
|
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
|
||||||
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
|
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
|
||||||
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
|
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
|
||||||
# cooldown 24-48h (проверить через targeted test).
|
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
|
||||||
|
# Включать только после решения по прокси и принятого по числу строк
|
||||||
|
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
|
||||||
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
||||||
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||||
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
||||||
|
|
@ -362,11 +364,11 @@ def build_beat_schedule() -> dict:
|
||||||
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
||||||
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
||||||
#
|
#
|
||||||
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
|
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
|
||||||
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||||
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
||||||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||||
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
|
||||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||||
# напр. четверг 04:00 МСК.
|
# напр. четверг 04:00 МСК.
|
||||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
# 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:
|
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||||
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
||||||
count = 0
|
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
|
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
||||||
try:
|
try:
|
||||||
with db.begin_nested(): # SAVEPOINT per-row
|
with db.begin_nested(): # SAVEPOINT per-row
|
||||||
|
db.execute(_DELETE_STALE_PARSE_SQL, row)
|
||||||
db.execute(upsert_sql, row)
|
db.execute(upsert_sql, row)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует
|
||||||
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
||||||
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
||||||
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
||||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
|
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
|
||||||
VPS IP; включается вручную после проверки targeted-тестом.
|
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
|
||||||
|
включать после прокси и принятого kn-прогона (#3307).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -116,7 +117,7 @@ def scrape_kn_catalog_flats(
|
||||||
|
|
||||||
Concurrency:
|
Concurrency:
|
||||||
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
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).
|
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
||||||
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Selector logic:
|
||||||
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
||||||
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
||||||
|
|
||||||
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
|
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не всё под complex_id (#3583).
|
||||||
|
|
||||||
|
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
||||||
|
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
||||||
|
не трогает complex_id: под id ближнего ЖК лежат лоты чужих. Тот же дефект, что #2962
|
||||||
|
в competitors.py.
|
||||||
|
|
||||||
|
Тест герметичный и прогоняет НАСТОЯЩИЙ `_GEO_RADIUS_PRICE_SQL`: временные таблицы
|
||||||
|
затеняют боевые в пределах сессии. Нужен Postgres с PostGIS (ST_DWithin по geography).
|
||||||
|
В CI он есть, и там тест не пропускается: без PostGIS падает с настоящей причиной.
|
||||||
|
Пропуск разрешён только вне CI и объявлен в skip_allowlist.txt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn() -> str:
|
||||||
|
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
||||||
|
return (
|
||||||
|
raw
|
||||||
|
if raw.startswith("postgresql+")
|
||||||
|
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _postgis_reachable() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||||
|
with eng.connect() as c:
|
||||||
|
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
||||||
|
return True, ""
|
||||||
|
return False, "нет расширения postgis"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
_DB_OK, _DB_ERR = _postgis_reachable()
|
||||||
|
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA = [
|
||||||
|
"""CREATE TEMP TABLE complexes (
|
||||||
|
id bigint, canonical_name text, latitude double precision,
|
||||||
|
longitude double precision) ON COMMIT DROP""",
|
||||||
|
"""CREATE TEMP TABLE complex_sources (
|
||||||
|
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
||||||
|
"""CREATE TEMP TABLE objective_lots (
|
||||||
|
id bigint, project_name text, corpus_name text, section text, floor int,
|
||||||
|
lot_number text, snapshot_date date, premise_kind text, complex_id bigint,
|
||||||
|
price_per_m2_rub numeric) ON COMMIT DROP""",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Участок в (56.840, 60.600), радиус 3 км. Малахит — в ~10 км, вне радиуса.
|
||||||
|
_DATA = [
|
||||||
|
"""INSERT INTO complexes VALUES
|
||||||
|
(10, 'ЖК Мичуринский', 56.841, 60.601),
|
||||||
|
(20, 'ЖК VEER PARK', 56.845, 60.605),
|
||||||
|
(30, 'СтудияПарк', 56.835, 60.595),
|
||||||
|
(40, 'ЖК Малахит', 56.930, 60.600)""",
|
||||||
|
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
||||||
|
"""INSERT INTO complex_sources VALUES
|
||||||
|
(10, 'objective', 'Мичуринский'),
|
||||||
|
(20, 'objective', 'Clever Park'),
|
||||||
|
(30, 'objective', 'Студия Парк'),
|
||||||
|
(40, 'objective', 'Малахит')""",
|
||||||
|
# Под complex_id=10 лежат свой лот и три лота чужого «Малахита» с устаревшим
|
||||||
|
# complex_id; у новых лотов complex_id NULL. Лот «Мичуринский/1/1/3/3» в двух
|
||||||
|
# снапшотах: в медиану идёт последний (120 тыс.), а не старый (50 тыс.).
|
||||||
|
"""INSERT INTO objective_lots VALUES
|
||||||
|
(1, 'Мичуринский', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 100000),
|
||||||
|
(2, 'Мичуринский', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 110000),
|
||||||
|
(3, 'Мичуринский', '1', '1', 3, '3', '2026-08-01', 'квартира', NULL, 50000),
|
||||||
|
(4, 'Мичуринский', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 120000),
|
||||||
|
(5, 'Малахит', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 300000),
|
||||||
|
(6, 'Малахит', '1', '1', 2, '2', '2026-05-10', 'квартира', 10, 300000),
|
||||||
|
(7, 'Малахит', '1', '1', 3, '3', '2026-05-10', 'квартира', 10, 300000),
|
||||||
|
(8, 'Clever Park', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 500000),
|
||||||
|
(9, 'Clever Park', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 500000),
|
||||||
|
(10, 'Clever Park', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 500000),
|
||||||
|
(11, 'Студия Парк', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 90000),
|
||||||
|
(12, 'Студия Парк', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 95000)""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def row() -> dict[str, float]:
|
||||||
|
from app.api.v1.parcels import _GEO_PRICE_RADIUS_M, _GEO_RADIUS_PRICE_SQL
|
||||||
|
|
||||||
|
session = sessionmaker(bind=create_engine(_dsn()))()
|
||||||
|
try:
|
||||||
|
for stmt in _SCHEMA + _DATA:
|
||||||
|
session.execute(text(stmt))
|
||||||
|
r = (
|
||||||
|
session.execute(
|
||||||
|
_GEO_RADIUS_PRICE_SQL,
|
||||||
|
{"lon": 60.600, "lat": 56.840, "radius_m": _GEO_PRICE_RADIUS_M},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
return {k: float(v) for k, v in r.items()}
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_median_counts_own_projects_of_nearby_complexes(row) -> None:
|
||||||
|
"""Лоты 90/95 тыс. («Студия Парк») и 100/110/120 тыс. («Мичуринский») → медиана 100 тыс.
|
||||||
|
|
||||||
|
По устаревшему complex_id было бы 100 + три «Малахита» по 300 тыс. → 300 тыс.;
|
||||||
|
без сверки имени добавились бы три лота «Clever Park» по 500 тыс. → 115 тыс.
|
||||||
|
"""
|
||||||
|
assert row == {"median": 100000.0, "n": 5.0, "n_complexes": 2.0}
|
||||||
|
|
@ -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 бьют по тому же
|
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
||||||
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
|
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
|
||||||
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
|
для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в
|
||||||
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
|
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор
|
||||||
smoke-тест/re-trigger не углубил бан.
|
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
|
||||||
|
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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"
|
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:
|
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.id = task_id
|
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})
|
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
detail = resp.json()["detail"]
|
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||||
assert "2443" in detail
|
|
||||||
assert "i_understand_waf_risk" in detail
|
|
||||||
mock_apply_async.assert_not_called()
|
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})
|
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
detail = resp.json()["detail"]
|
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||||
assert "2443" in detail
|
|
||||||
assert "i_understand_waf_risk" in detail
|
|
||||||
mock_apply_async.assert_not_called()
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None:
|
||||||
# После исключения obj_id=20 список пуст → пустой ответ
|
# После исключения obj_id=20 список пуст → пустой ответ
|
||||||
assert body["top_layouts"] == []
|
assert body["top_layouts"] == []
|
||||||
assert body["data_quality"]["confidence"] == "low"
|
assert body["data_quality"]["confidence"] == "low"
|
||||||
# objects_total_in_radius = 1 (до исключения)
|
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
|
||||||
assert body["data_quality"]["objects_total_in_radius"] == 1
|
# число obj_id в радиусе до исключения — raw_objects_total
|
||||||
|
assert body["data_quality"]["objects_total_in_radius"] == 0
|
||||||
|
assert body["data_quality"]["raw_objects_total"] == 1
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
|
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
|
||||||
|
|
||||||
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
||||||
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
||||||
|
|
@ -16,9 +16,7 @@
|
||||||
реально ушло бы в сеть.
|
реально ушло бы в сеть.
|
||||||
|
|
||||||
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
||||||
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у
|
бы с оригиналом на первой же правке.
|
||||||
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
|
|
||||||
настоящие сетевые проверки.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -34,7 +32,6 @@ import pytest
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
SENDERS = {
|
SENDERS = {
|
||||||
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
"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'
|
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)}"',
|
f'ARGS_DUMP="{_posix(dump)}"',
|
||||||
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
||||||
'TELEGRAM_CHAT_ID="-1004443088679"',
|
'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:
|
if topic is not None:
|
||||||
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
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")
|
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
||||||
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
||||||
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
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, который не может дозваться, —
|
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
||||||
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
||||||
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
||||||
|
|
@ -51,7 +53,6 @@ import pytest
|
||||||
# backend/tests/ops/<этот файл> → корень репозитория
|
# backend/tests/ops/<этот файл> → корень репозитория
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
UPTIME = "ops/uptime-healthcheck.sh"
|
|
||||||
LIB_BACKUP = "ops/lib-backup.sh"
|
LIB_BACKUP = "ops/lib-backup.sh"
|
||||||
|
|
||||||
# См. подробное обоснование shutil.which в
|
# См. подробное обоснование shutil.which в
|
||||||
|
|
@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за
|
||||||
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
||||||
"""Достаёт тело одной функции из скрипта — не весь файл.
|
"""Достаёт тело одной функции из скрипта — не весь файл.
|
||||||
|
|
||||||
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
|
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
|
||||||
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
|
|
||||||
|
|
||||||
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
||||||
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
||||||
|
|
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
|
||||||
return calls, out, err
|
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:
|
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}: доставленный алерт помечен потерянным"
|
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:
|
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} раз"
|
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:
|
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
||||||
"""Транзиентный таймаут не должен трогать запасной канал.
|
"""Транзиентный таймаут не должен трогать запасной канал.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
|
|
@ -179,3 +183,48 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
|
||||||
code, _ = app.do_ack(token)
|
code, _ = app.do_ack(token)
|
||||||
assert code == 404
|
assert code == 404
|
||||||
assert app.sent == []
|
assert app.sent == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
|
||||||
|
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
|
||||||
|
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
|
||||||
|
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
|
||||||
|
|
||||||
|
_LEAK = "leak-probe-3576-VALUE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("request_line", "masked", "lines_with_mask"),
|
||||||
|
[
|
||||||
|
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
|
||||||
|
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
|
||||||
|
# Имя с префиксом и соседний параметр — остальная строка цела.
|
||||||
|
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
|
||||||
|
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
|
||||||
|
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
|
||||||
|
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sekret_iz_query_ne_popadaet_v_log(
|
||||||
|
app: ModuleType,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
request_line: str,
|
||||||
|
masked: str,
|
||||||
|
lines_with_mask: int,
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level(logging.INFO, logger="alert-ack")
|
||||||
|
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
with socket.create_connection(srv.server_address, timeout=5) as sock:
|
||||||
|
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
|
||||||
|
sock.shutdown(socket.SHUT_WR)
|
||||||
|
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
|
||||||
|
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
|
||||||
|
assert sum(masked in m for m in messages) == lines_with_mask, messages
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,9 @@
|
||||||
переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
|
переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
|
||||||
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
|
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
|
||||||
развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты».
|
развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты».
|
||||||
Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают
|
Тест ниже закрепляет именно это для единственного оставшегося прямого
|
||||||
ИНФРАСТРУКТУРНУЮ тему, а не общую.
|
получателя — `telegram` (Watchdog с 17.09 больше не шлёт в Telegram вовсе,
|
||||||
|
у него теперь внешний webhook-приёмник `watchdog-ping`, см. шаблон).
|
||||||
|
|
||||||
Тесты рендерят шаблон обоими способами и разбирают результат как YAML —
|
Тесты рендерят шаблон обоими способами и разбирают результат как YAML —
|
||||||
проверяется фактический конфиг, а не наличие нужных слов в тексте.
|
проверяется фактический конфиг, а не наличие нужных слов в тексте.
|
||||||
|
|
@ -43,9 +44,56 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
||||||
|
|
||||||
|
# Заглушки для переменных, которые деплой может подставить. Значение
|
||||||
|
# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт
|
||||||
|
# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение.
|
||||||
|
_DUMMY_VALUES = {
|
||||||
|
"METRICS_TELEGRAM_BOT_TOKEN": "123:ABC",
|
||||||
|
"METRICS_TELEGRAM_CHAT_ID": "-100123",
|
||||||
|
"METRICS_TELEGRAM_ONCALL": "",
|
||||||
|
"METRICS_WATCHDOG_PING_BLOCK": "",
|
||||||
|
}
|
||||||
|
|
||||||
def _render(infra_topic_line: str) -> dict:
|
|
||||||
"""Повторяет подстановку деплоя и разбирает результат как YAML.
|
def _envsubst_allowlist() -> set[str]:
|
||||||
|
"""Реальный список переменных, которые деплой передаёт в envsubst.
|
||||||
|
|
||||||
|
Не хардкодим копию списка — #3589 случился именно так: шаблон завёл
|
||||||
|
`${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили,
|
||||||
|
и тест этого не заметил, потому что сам подставлял значение мимо деплоя.
|
||||||
|
"""
|
||||||
|
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
|
||||||
|
text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r"envsubst '([^']+)'", text)
|
||||||
|
assert m, "не нашёл вызов envsubst в деплое"
|
||||||
|
return set(re.findall(r"\$\{(\w+)\}", m.group(1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _template_placeholders() -> set[str]:
|
||||||
|
text = TMPL.read_text(encoding="utf-8")
|
||||||
|
return set(re.findall(r"\$\{(\w+)\}", text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_template_placeholder_is_in_envsubst_allowlist() -> None:
|
||||||
|
"""Регресс #3589: переменная шаблона обязана быть в allow-list envsubst.
|
||||||
|
|
||||||
|
Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список
|
||||||
|
envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО
|
||||||
|
перечисленные переменные — забытая долетает до `amtool check-config`
|
||||||
|
литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то
|
||||||
|
есть роняет ВЕСЬ Alertmanager, а не только Watchdog.
|
||||||
|
"""
|
||||||
|
missing = _template_placeholders() - _envsubst_allowlist()
|
||||||
|
assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict:
|
||||||
|
"""Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML.
|
||||||
|
|
||||||
|
Подставляются только переменные из реального allow-list envsubst деплоя
|
||||||
|
(`_envsubst_allowlist`) — не весь известный тесту набор. Так регресс
|
||||||
|
#3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь:
|
||||||
|
`assert "${" not in rendered` ниже упадёт, если что-то не подставилось.
|
||||||
|
|
||||||
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
||||||
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
||||||
|
|
@ -55,11 +103,18 @@ def _render(infra_topic_line: str) -> dict:
|
||||||
"""
|
"""
|
||||||
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
||||||
text = TMPL.read_text(encoding="utf-8")
|
text = TMPL.read_text(encoding="utf-8")
|
||||||
rendered = (
|
|
||||||
text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
|
values = dict(_DUMMY_VALUES)
|
||||||
.replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
|
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line
|
||||||
.replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
|
if watchdog_ping_block is not None:
|
||||||
)
|
values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block
|
||||||
|
|
||||||
|
allowlist = _envsubst_allowlist()
|
||||||
|
rendered = text
|
||||||
|
for name, value in values.items():
|
||||||
|
if name in allowlist:
|
||||||
|
rendered = rendered.replace("${" + name + "}", value)
|
||||||
|
|
||||||
assert "${" not in rendered, (
|
assert "${" not in rendered, (
|
||||||
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
||||||
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
||||||
|
|
@ -67,6 +122,37 @@ def _render(infra_topic_line: str) -> dict:
|
||||||
return yaml.safe_load(rendered)
|
return yaml.safe_load(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None:
|
||||||
|
"""Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив.
|
||||||
|
|
||||||
|
`watchdog-ping` остаётся без единого `*_configs` — валидный receiver,
|
||||||
|
Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда
|
||||||
|
не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел.
|
||||||
|
"""
|
||||||
|
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="")
|
||||||
|
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||||
|
assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига"
|
||||||
|
watchdog = receivers["watchdog-ping"]
|
||||||
|
assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs"
|
||||||
|
assert "telegram" in receivers and "telegram-clients" in receivers, (
|
||||||
|
"остальной алертинг не должен пострадать из-за пустого watchdog-секрета"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_receiver_with_secret_gets_webhook() -> None:
|
||||||
|
"""Секрет задан — Watchdog реально пингует внешний deadman-приёмник."""
|
||||||
|
block = (
|
||||||
|
" webhook_configs:\n"
|
||||||
|
' - url: "https://hc-ping.com/dummy"\n'
|
||||||
|
" send_resolved: false"
|
||||||
|
)
|
||||||
|
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block)
|
||||||
|
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||||
|
hooks = receivers["watchdog-ping"].get("webhook_configs") or []
|
||||||
|
assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy"
|
||||||
|
assert hooks[0].get("send_resolved") is False
|
||||||
|
|
||||||
|
|
||||||
def _telegram_configs(cfg: dict) -> list[dict]:
|
def _telegram_configs(cfg: dict) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for r in cfg.get("receivers", []):
|
for r in cfg.get("receivers", []):
|
||||||
|
|
@ -76,16 +162,16 @@ def _telegram_configs(cfg: dict) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
def test_topic_lands_in_every_telegram_receiver() -> None:
|
def test_topic_lands_in_every_telegram_receiver() -> None:
|
||||||
"""Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
"""Единственный прямой получатель адресует ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
||||||
|
|
||||||
Получателей два — `telegram` и `telegram-heartbeat`. До разделения тем оба
|
До разделения тем `telegram` и `telegram-heartbeat` брали топик из одной
|
||||||
брали топик из одной переменной с клиентскими инцидентами, и тема «метрики»
|
переменной с клиентскими инцидентами, и тема «метрики» (245) оставалась
|
||||||
(245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив»
|
пустой. С 17.09 (устранение шума Watchdog) прямой Telegram-получатель
|
||||||
будет капать мимо, и это заметят не сразу — сюда же попадёт и весь
|
остался один — `telegram`; Watchdog теперь пингует внешний
|
||||||
инфраструктурный шум.
|
deadman-приёмник вебхуком (`watchdog-ping`, без topic вовсе — не Telegram).
|
||||||
"""
|
"""
|
||||||
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
|
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
|
||||||
assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}"
|
assert len(cfgs) >= 1, f"ожидался хотя бы один получатель telegram, найдено {len(cfgs)}"
|
||||||
for c in cfgs:
|
for c in cfgs:
|
||||||
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"
|
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None:
|
||||||
"""
|
"""
|
||||||
text = TEMPLATE.read_text(encoding="utf-8")
|
text = TEMPLATE.read_text(encoding="utf-8")
|
||||||
block = text[text.index("- name: telegram-clients") :]
|
block = text[text.index("- name: telegram-clients") :]
|
||||||
block = block[: block.index("- name: telegram-heartbeat")]
|
block = block[: block.index("- name: watchdog-ping")]
|
||||||
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
|
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
|
||||||
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
|
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
|
||||||
# У прочих приёмников прямой путь сохранён.
|
# У прочих приёмников прямой путь сохранён.
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в
|
||||||
|
|
||||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||||
в этом же workflow — только для Prometheus починка не пересоздание
|
в этом же workflow. `POST /-/reload` лечит правила — они подключены
|
||||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает
|
||||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
за тем же путём СТАРЫЙ инод (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 вызывается
|
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
|
||||||
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан
|
||||||
подтверждён сменой lastConfigTime.
|
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
|
||||||
|
Grafana перечитываются, и отказ этого шага роняет деплой.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
|
||||||
|
|
||||||
|
|
||||||
def _text() -> str:
|
def _text() -> str:
|
||||||
return WORKFLOW.read_text(encoding="utf-8")
|
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:
|
def test_promtool_checks_config_and_rules() -> None:
|
||||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||||
text = _text()
|
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:
|
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||||
text = _text()
|
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)
|
else_pos = text.index("else", guard_start)
|
||||||
reload_pos = text.index("localhost:9090/-/reload")
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
assert guard_start < reload_pos < else_pos, (
|
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:
|
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||||
text = _text()
|
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)
|
else_pos = text.index("else", guard_start)
|
||||||
fi_pos = text.index("fi", else_pos)
|
fi_pos = text.index("fi", else_pos)
|
||||||
else_branch = text[else_pos:fi_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, (
|
assert "exit 1" in verify_block, (
|
||||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
"если 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)
|
resp = get_best_layouts(db, CAD_NUM, req)
|
||||||
|
|
||||||
assert resp.top_layouts == []
|
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 ──────────────────────
|
# ── Тест: 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()
|
db = MagicMock()
|
||||||
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
|
# grid-walk фаза корректно пропускается (тест про
|
||||||
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
||||||
# db.execute().mappings().first() в этом тесте.
|
# db.execute().mappings().first() в этом тесте.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# phase_state = None → начинаем с нуля
|
# phase_state = None → начинаем с нуля
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
# grid-walk фаза пропускается (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
# grid-walk фаза пропускается (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
# grid-walk фаза пропускается (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
# grid-walk фаза пропускается (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
# grid-walk фаза пропускается (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
||||||
db = _mock_db_grid_bbox()
|
db = _mock_db_grid_bbox()
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
||||||
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
|
|
||||||
|
|
||||||
progress_states: list[dict[str, Any]] = []
|
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"
|
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 ─────────────────────
|
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,29 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
|
tests/sql/test_2464_area_bin_unknown.py::test_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_total_lots_preserved
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
||||||
|
|
||||||
|
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
|
||||||
|
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
|
||||||
|
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
|
||||||
|
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
|
||||||
|
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
|
||||||
|
# только для машины без базы.
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
|
||||||
|
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
|
||||||
|
|
||||||
|
# ── #3583: гео-радиусная цена участка (complex_sources → project_name) ────────
|
||||||
|
# Тот же случай, что #2962: гоняет НАСТОЯЩИЙ parcels._GEO_RADIUS_PRICE_SQL на
|
||||||
|
# временных таблицах, нужен PostGIS. В CI идёт и пропуститься не может (CI=true
|
||||||
|
# выключает skipif). Запись только для машины без базы.
|
||||||
|
tests/api/v1/test_3583_geo_radius_price_project_bridge.py::test_median_counts_own_projects_of_nearby_complexes
|
||||||
|
|
||||||
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||||
|
|
|
||||||
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, ДОК)]
|
||||||
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)"
|
||||||
|
)
|
||||||
|
|
@ -1919,17 +1919,17 @@ export interface paths {
|
||||||
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
*
|
*
|
||||||
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||||
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
* Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||||
* объектов до next beat fire).
|
* свежесозданных объектов).
|
||||||
*
|
*
|
||||||
* - max_objects=None → дефолтный лимит таска (300).
|
* - max_objects=None → дефолтный лимит таска (300).
|
||||||
* - max_objects=3 → smoke-тест.
|
* - max_objects=3 → smoke-тест.
|
||||||
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
*
|
*
|
||||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||||
* re-trigger без явного подтверждения оператора запрещён.
|
* запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
@ -1960,9 +1960,9 @@ export interface paths {
|
||||||
* - max_flats=3 → smoke-тест.
|
* - max_flats=3 → smoke-тест.
|
||||||
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
*
|
*
|
||||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||||
* того же WAF-бана.
|
* блокировкой StormWall (условие — #3307).
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@
|
||||||
# script says so loudly instead of the silence that let backups break for
|
# script says so loudly instead of the silence that let backups break for
|
||||||
# weeks undetected before (#71).
|
# weeks undetected before (#71).
|
||||||
#
|
#
|
||||||
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
|
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
|
||||||
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
|
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
|
||||||
# Telegram once a backup is already known to be stale.
|
|
||||||
#
|
#
|
||||||
# Usage (cron — one line per sentinel, run more often than the backup itself
|
# 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
|
# 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" \
|
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
||||||
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
#
|
#
|
||||||
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
|
# Alert channel: notify() in ops/lib-backup.sh — creds in
|
||||||
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
|
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
|
||||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
|
# 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
|
# 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).
|
# 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
|
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ч.
|
# Сторож пропущенных прогонов для 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) ──────────────────────────────────────────
|
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
||||||
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
||||||
|
|
@ -33,11 +37,11 @@
|
||||||
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
||||||
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
# 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ч — как у остальных:
|
# Сторож пропущенных прогонов для бэкапа волта. Порог 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) — именно их
|
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
||||||
|
|
|
||||||
93
ops/forgejo/docker-compose.yml
Normal file
93
ops/forgejo/docker-compose.yml
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Source-of-truth copy of the Forgejo compose file.
|
||||||
|
#
|
||||||
|
# Forgejo is NOT part of the automated deploy pipeline (deploy.yml only
|
||||||
|
# manages /opt/gendesign via `git reset --hard origin/main` on the main and
|
||||||
|
# obsidian stacks). Forgejo lives separately at /home/gendesign/forgejo on
|
||||||
|
# the VM and is a plain directory there — NOT a git checkout — so changes
|
||||||
|
# here do not auto-apply. Sync manually:
|
||||||
|
#
|
||||||
|
# scp ops/forgejo/docker-compose.yml gendesign:/home/gendesign/forgejo/docker-compose.yml
|
||||||
|
# ssh gendesign "cd /home/gendesign/forgejo && docker compose up -d --force-recreate forgejo"
|
||||||
|
#
|
||||||
|
# `up -d --force-recreate` (not `restart`) is required: Forgejo generates
|
||||||
|
# app.ini from the FORGEJO__* env vars via /usr/local/bin/environment-to-ini
|
||||||
|
# at container start, and `restart` does not re-read `environment:` from a
|
||||||
|
# changed compose file (see docker-compose pitfall #1 in devops CLAUDE.md).
|
||||||
|
#
|
||||||
|
# Config keys verified 2026-09-16 against the running image
|
||||||
|
# (codeberg.org/forgejo/forgejo:10, Forgejo 10.0.3+gitea-1.22.0) by
|
||||||
|
# extracting Go struct tags from the binary (`strings` on
|
||||||
|
# /app/gitea/gitea) and by a dry-run of environment-to-ini in a scratch
|
||||||
|
# dir inside the container (no prod files touched). Do not re-derive these
|
||||||
|
# from memory — a wrong key is silently ignored (empty section) and
|
||||||
|
# creates a false sense of safety.
|
||||||
|
#
|
||||||
|
# Dotted section names ([cron.archive_cleanup]) must be encoded as
|
||||||
|
# `_0x2E_` in the env var per the container's own
|
||||||
|
# `environment-to-ini --help` (confirmed empirically, see PR description).
|
||||||
|
services:
|
||||||
|
forgejo:
|
||||||
|
image: codeberg.org/forgejo/forgejo:10
|
||||||
|
container_name: forgejo
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
USER_UID: 1000
|
||||||
|
USER_GID: 1000
|
||||||
|
FORGEJO__database__DB_TYPE: postgres
|
||||||
|
FORGEJO__database__HOST: infra-postgres:5432
|
||||||
|
FORGEJO__database__NAME: forgejo
|
||||||
|
FORGEJO__database__USER: forgejo
|
||||||
|
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASS}
|
||||||
|
FORGEJO__server__DOMAIN: git.gendsgn.ru
|
||||||
|
FORGEJO__server__ROOT_URL: https://git.gendsgn.ru/
|
||||||
|
FORGEJO__server__SSH_PORT: 2222
|
||||||
|
FORGEJO__server__SSH_LISTEN_PORT: 22
|
||||||
|
FORGEJO__server__START_SSH_SERVER: "false"
|
||||||
|
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||||
|
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||||
|
FORGEJO__actions__ENABLED: "true"
|
||||||
|
FORGEJO__actions__DEFAULT_ACTIONS_URL: "github"
|
||||||
|
# Actions Log/artifact retention — was unset (Forgejo defaults), which
|
||||||
|
# let CI run logs/artifacts accumulate indefinitely. Artifact
|
||||||
|
# retention checked against .forgejo/workflows + .github/workflows on
|
||||||
|
# 2026-09-16: no workflow uploads/downloads artifacts today, so 14d
|
||||||
|
# cannot break a cross-job dependency. Revisit this comment if a
|
||||||
|
# workflow starts using actions/upload-artifact.
|
||||||
|
FORGEJO__actions__LOG_RETENTION_DAYS: "30"
|
||||||
|
FORGEJO__actions__ARTIFACT_RETENTION_DAYS: "14"
|
||||||
|
# Repo-archive cache cleanup — was entirely absent (no
|
||||||
|
# [cron.archive_cleanup] section), so it ran on Forgejo's own default
|
||||||
|
# schedule (once every 24h, deleting archives older than 24h). That
|
||||||
|
# let an external crawler hitting /<owner>/<repo>/archive/<ref>
|
||||||
|
# balloon the cache to ~48GB/145GB disk before the daily sweep caught
|
||||||
|
# up (see PR #3534, which closed the path in Caddy as the primary
|
||||||
|
# fix). This is the second line of defense if that Caddy rule is ever
|
||||||
|
# removed: run hourly, evict anything older than 1h.
|
||||||
|
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__ENABLED: "true"
|
||||||
|
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__RUN_AT_START: "true"
|
||||||
|
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__SCHEDULE: "@every 1h"
|
||||||
|
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__OLDER_THAN: "1h"
|
||||||
|
FORGEJO__security__INSTALL_LOCK: "true"
|
||||||
|
volumes:
|
||||||
|
- ./data/forgejo:/data
|
||||||
|
# Container log cap. Measured 2026-09-17: this container's json log had
|
||||||
|
# grown to 3.35 GiB in 23 days (~150 MB/day) with no rotation at all,
|
||||||
|
# the single largest log on the host by two orders of magnitude. Docker's
|
||||||
|
# json-file driver never rotates unless told to, and there is no
|
||||||
|
# /etc/docker/daemon.json on this VM to set a global default. 50m x 3
|
||||||
|
# caps this container at 150 MB; recreating it also drops the old
|
||||||
|
# unbounded file.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "3"
|
||||||
|
ports:
|
||||||
|
- "2222:22"
|
||||||
|
networks:
|
||||||
|
- gendesign_default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gendesign_default:
|
||||||
|
external: true
|
||||||
|
name: gendesign_default
|
||||||
|
|
@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY=
|
||||||
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
||||||
#TELEGRAM_BOT_TOKEN=
|
#TELEGRAM_BOT_TOKEN=
|
||||||
#TELEGRAM_CHAT_ID=
|
#TELEGRAM_CHAT_ID=
|
||||||
|
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||||
|
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||||
|
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||||
|
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||||
|
#TELEGRAM_TOPIC_ID=245
|
||||||
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
||||||
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@
|
||||||
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
||||||
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_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) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
||||||
# the policy's Resource path exactly
|
# the policy's Resource path exactly
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,8 @@
|
||||||
#
|
#
|
||||||
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
# 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
|
# 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
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
|
||||||
# /etc/default/gendesign-uptime — deliberately a separate config so backup
|
# Without the first two set, ops/check-backup-staleness.sh still logs, just
|
||||||
# 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
|
|
||||||
# doesn't send a Telegram alert.
|
# doesn't send a Telegram alert.
|
||||||
|
|
||||||
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
||||||
|
|
@ -41,9 +36,14 @@
|
||||||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
||||||
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_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_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
#TELEGRAM_CHAT_ID=123456789
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
|
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||||
|
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||||
|
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||||
|
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||||
|
#TELEGRAM_TOPIC_ID=245
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
#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
|
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
||||||
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
||||||
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
||||||
# notify() when Telegram vars are actually set — curl is already a hard
|
# notify() when Telegram vars are actually set).
|
||||||
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
|
|
||||||
# new dependency).
|
|
||||||
#
|
#
|
||||||
# Load with (script computes its own dir so this works regardless of cron's
|
# Load with (script computes its own dir so this works regardless of cron's
|
||||||
# CWD or which repo subdir the caller lives in):
|
# CWD or which repo subdir the caller lives in):
|
||||||
|
|
@ -18,14 +16,8 @@
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||||
|
|
||||||
# --- notify ------------------------------------------------------------
|
# --- notify ------------------------------------------------------------
|
||||||
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
|
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
|
||||||
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
|
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# No-op (logs only) when unset — this is the extension point: to wire a
|
# 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
|
# different channel later, edit ONLY this function; every caller in this repo
|
||||||
|
|
@ -153,12 +145,9 @@ sentinel_age_hours() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- transition-tracked alert state --------------------------------------
|
# --- transition-tracked alert state --------------------------------------
|
||||||
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
|
# A flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||||
# flat "<label> <status>" file, one line per label, so repeated runs alert
|
|
||||||
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
||||||
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
# 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() {
|
backup_prev_status() {
|
||||||
local state_file="$1" label="$2" v
|
local state_file="$1" label="$2" v
|
||||||
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -76,6 +77,16 @@ TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
||||||
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
||||||
API = "https://api.telegram.org/bot{}/{}"
|
API = "https://api.telegram.org/bot{}/{}"
|
||||||
|
|
||||||
|
# Значения секретных query-параметров в логе — `***` (#3576). GlitchTip шлёт
|
||||||
|
# секрет только в `?secret=`, а строка запроса целиком уходит в access-log. То
|
||||||
|
# же выражение, что у бэкенда МЕРЫ (tradein-mvp/backend/app/core/log_scrub.py,
|
||||||
|
# #3154) и у Alloy (#3354); импортировать нельзя — сервис без зависимостей.
|
||||||
|
_SENSITIVE_QUERY = re.compile(
|
||||||
|
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
||||||
|
r"[^&\s\"'<>]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
||||||
_PENDING: dict[str, dict] = {}
|
_PENDING: dict[str, dict] = {}
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
@ -319,7 +330,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
||||||
log.info("%s %s", self.address_string(), fmt % args)
|
# Сюда сходятся и строка доступа (log_request), и ошибки разбора запроса
|
||||||
|
# (log_error: «Bad request syntax ('POST /glitchtip?secret=…')») — маскируем здесь.
|
||||||
|
log.info("%s %s", self.address_string(), _SENSITIVE_QUERY.sub(r"\1***", fmt % args))
|
||||||
|
|
||||||
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
|
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
|
||||||
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
|
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
|
||||||
# инцидент не равны по срочности, а смешанные в одной теме они обучают
|
# инцидент не равны по срочности, а смешанные в одной теме они обучают
|
||||||
# пролистывать обе. Поэтому `telegram` и `telegram-heartbeat` ниже адресуют
|
# пролистывать обе. Поэтому `telegram` ниже адресует ИНФРАСТРУКТУРНУЮ тему
|
||||||
# ИНФРАСТРУКТУРНУЮ тему (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя
|
# (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя `telegram-clients` в этом
|
||||||
# `telegram-clients` в этом списке нет: он не шлёт в Telegram напрямую, а
|
# списке нет: он не шлёт в Telegram напрямую, а вебхуком уходит в alert-ack, и
|
||||||
# вебхуком уходит в alert-ack, и тему адресует сам, своей переменной
|
# тему адресует сам, своей переменной METRICS_TELEGRAM_TOPIC_ID. `watchdog-ping`
|
||||||
# METRICS_TELEGRAM_TOPIC_ID.
|
# тоже не шлёт в Telegram вовсе — см. комментарий у его маршрута ниже.
|
||||||
|
|
||||||
global:
|
global:
|
||||||
resolve_timeout: 5m
|
resolve_timeout: 5m
|
||||||
|
|
@ -35,14 +35,35 @@ route:
|
||||||
repeat_interval: 6h
|
repeat_interval: 6h
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
# Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
|
# Watchdog — «сторож сторожа», горит ВСЕГДА по построению (`vector(1)`,
|
||||||
# это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
|
# см. infra.yml). Раньше уходил в Telegram раз в 12ч — 14 сообщений в
|
||||||
- receiver: telegram-heartbeat
|
# неделю ни о чём, и именно они приучили пролистывать инфра-тему: 17.09
|
||||||
|
# настоящий DiskWillFillIn24h утонул между Watchdog и вечно горящим
|
||||||
|
# NoActiveCeleryWorkers, диск дошёл до 84% незамеченным.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ ВНЕШНИЙ DEADMAN-ПРИЁМНИК, А НЕ «РЕЖЕ» И НЕ ОТДЕЛЬНАЯ ТЕМА.
|
||||||
|
# Увеличенный интервал по-прежнему кладёт человеку регулярное сообщение —
|
||||||
|
# просто реже, и его тоже рано или поздно начнут пролистывать. Отдельная
|
||||||
|
# техническая тема — это ещё один chat_id/topic_id и ещё один канал,
|
||||||
|
# за которым НАДО СПЕЦИАЛЬНО следить, то есть тот же человеческий цикл,
|
||||||
|
# сдвинутый в другое место. Внешний deadman-приёмник (healthchecks.io и
|
||||||
|
# аналоги) устроен наоборот: Alertmanager молча шлёт HTTP-пинг на каждый
|
||||||
|
# Watchdog, и пока пинги идут — сервис МОЛЧИТ. Он заговорит (email/свой
|
||||||
|
# alert) только когда пинг ПЕРЕСТАНЕТ приходить, то есть ровно когда
|
||||||
|
# канал доставки умер, — это и есть смысл «сторожа сторожа», без единого
|
||||||
|
# штатного сообщения человеку. Полностью выключать эту проверку нельзя —
|
||||||
|
# remove бы всей ветки Watchdog это и сделал.
|
||||||
|
#
|
||||||
|
# METRICS_WATCHDOG_PING_URL пока НЕ заведён на хосте (нужен аккаунт
|
||||||
|
# healthchecks.io/аналога) — до тех пор webhook будет молча падать по
|
||||||
|
# DNS/сети, Alertmanager это тихо ретраит; человека это не касается ни
|
||||||
|
# раньше, ни теперь.
|
||||||
|
- receiver: watchdog-ping
|
||||||
matchers:
|
matchers:
|
||||||
- alertname = "Watchdog"
|
- alertname = "Watchdog"
|
||||||
group_wait: 0s
|
group_wait: 0s
|
||||||
group_interval: 12h
|
group_interval: 5m
|
||||||
repeat_interval: 12h
|
repeat_interval: 5m
|
||||||
|
|
||||||
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
|
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
|
||||||
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
|
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
|
||||||
|
|
@ -63,6 +84,20 @@ route:
|
||||||
group_wait: 10s
|
group_wait: 10s
|
||||||
repeat_interval: 30m
|
repeat_interval: 30m
|
||||||
|
|
||||||
|
# Эскалация по длительности (AlertFiringTooLong, prometheus/rules/infra.yml)
|
||||||
|
# — сигнал о том, что какую-то другую тревогу не заметили или на неё
|
||||||
|
# забили дольше 6 часов. Она НЕ про клиентский инцидент, но обязана быть
|
||||||
|
# заметнее обычной инфраструктуры, поэтому уходит в ту же тему, где
|
||||||
|
# владелец бывает чаще, а не смешивается с общим потоком severity=critical
|
||||||
|
# ниже. Матчим по имени, а не по host="apps": исходная тревога может
|
||||||
|
# быть про любой хост, и врать в лейбле не стоит (см. инвариант host
|
||||||
|
# у alert:app в infra.yml).
|
||||||
|
- receiver: telegram-clients
|
||||||
|
matchers:
|
||||||
|
- alertname = "AlertFiringTooLong"
|
||||||
|
group_wait: 10s
|
||||||
|
repeat_interval: 30m
|
||||||
|
|
||||||
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
|
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
|
||||||
- receiver: telegram
|
- receiver: telegram
|
||||||
matchers:
|
matchers:
|
||||||
|
|
@ -117,14 +152,17 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
||||||
- url: "http://alert-ack:8080/alertmanager"
|
- url: "http://alert-ack:8080/alertmanager"
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
|
|
||||||
- name: telegram-heartbeat
|
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
|
||||||
telegram_configs:
|
# Watchdog выше. Блок целиком (не значение) подставляется деплоем в
|
||||||
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
# METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у
|
||||||
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
|
# METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет
|
||||||
${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
# условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит
|
||||||
api_url: "https://api.telegram.org"
|
# ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный
|
||||||
parse_mode: HTML
|
# Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает
|
||||||
send_resolved: false
|
# уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url
|
||||||
message: |
|
# (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится:
|
||||||
⚪ <b>Мониторинг жив</b> — сторож отчитался, канал доставки работает.
|
# непустой ключ с пустым значением или литералом плейсхолдера амtool валит
|
||||||
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
|
# целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не
|
||||||
|
# только Watchdog.
|
||||||
|
- name: watchdog-ping
|
||||||
|
${METRICS_WATCHDOG_PING_BLOCK}
|
||||||
|
|
|
||||||
|
|
@ -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 } },
|
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -63,40 +63,85 @@ groups:
|
||||||
- name: host
|
- name: host
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
|
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ
|
||||||
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
|
# (host=infra). Проценты на дисках разного размера значат разное —
|
||||||
|
# host=apps держит /-раздел ~910 ГБ, те же 85 % там это ещё ~135 ГБ
|
||||||
|
# запаса, а на infra (145 ГБ) — почти ничего. Порог переведён в
|
||||||
|
# АБСОЛЮТНЫЕ ГБ свободного места: он одинаково осмыслен на любом диске,
|
||||||
|
# потому что напрямую отвечает на вопрос «сколько времени есть до нуля
|
||||||
|
# при текущей скорости утечки», а не «какая доля занята».
|
||||||
|
#
|
||||||
|
# `mountpoint="/"` — намеренное сужение с прежнего «все fstype кроме
|
||||||
|
# tmpfs/overlay»: /boot и /boot/efi по обоим хостам меньше 1 ГБ
|
||||||
|
# целиком, с порогом 30/15 ГБ они бы горели ПОСТОЯННО (проверено живыми
|
||||||
|
# метриками 16.09 — infra:/boot/efi 98 МБ, apps:/boot 553 МБ). Данные
|
||||||
|
# приложения и Postgres лежат на "/", туда и целится алерт.
|
||||||
- alert: DiskSpaceLow
|
- alert: DiskSpaceLow
|
||||||
expr: |
|
expr: |
|
||||||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||||||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
|
/ 1073741824 < 30
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Диск занят больше 85 %"
|
summary: "Свободно на диске меньше 30 ГБ"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ."
|
||||||
|
|
||||||
- alert: DiskSpaceCritical
|
- alert: DiskSpaceCritical
|
||||||
expr: |
|
expr: |
|
||||||
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||||||
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
|
/ 1073741824 < 15
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Диск почти кончился"
|
summary: "Свободно на диске меньше 15 ГБ"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается."
|
||||||
|
|
||||||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
|
# Скорость, а не уровень. Ловит именно ту утечку, что была 16.09: пока
|
||||||
|
# DiskSpaceLow/Critical ещё не сработали (места вагон), но оно тает
|
||||||
|
# быстрее ~1 ГБ/ч устойчиво — это уже течь, а не органический рост.
|
||||||
|
# `deriv()` — линейная регрессия по 15-минутному окну (не мгновенная
|
||||||
|
# разница двух точек, ту дёргает шум). Знак минус спереди и деление
|
||||||
|
# переносят результат из "Б/с, отрицательное при убыли" в "ГБ/ч,
|
||||||
|
# положительное когда тает" — чтобы $value в тексте читался нормально
|
||||||
|
# (не "-2.1 ГБ/ч"). `for: 30m` поверх 15-минутного окна регрессии
|
||||||
|
# требует НЕПРЕРЫВНОЙ убыли около часа, чтобы не будить на разовый
|
||||||
|
# скачок (бэкап, ротация логов).
|
||||||
|
- alert: DiskSpaceDepletingFast
|
||||||
|
expr: |
|
||||||
|
-deriv(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[15m])
|
||||||
|
* 3600 / 1073741824 > 1
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Диск пустеет быстрее ГБ в час"
|
||||||
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободное место убывает на {{ printf \"%.1f\" $value }} ГБ/час устойчиво последние ~30 минут — независимо от того, сколько места осталось сейчас."
|
||||||
|
|
||||||
|
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в
|
||||||
|
# стену — но роль другая, чем у DiskSpaceDepletingFast выше. Тот ловит
|
||||||
|
# БЫСТРУЮ (>1 ГБ/ч) утечку рано, за счёт короткого 15-минутного окна.
|
||||||
|
# Этот ловит УМЕРЕННУЮ утечку (может быть медленнее 1 ГБ/ч), которую
|
||||||
|
# короткое окно не поймает, но которая всё равно приведёт к нулю в
|
||||||
|
# течение суток при текущем уровне занятости — окно 6h усредняет шум
|
||||||
|
# ценой более позднего срабатывания. На одной и той же быстрой утечке
|
||||||
|
# оба правила могут сработать (сначала это, следом то) — это
|
||||||
|
# ЗАДУМАННАЯ эскалация двумя разными сигналами (скорость сейчас →
|
||||||
|
# подтверждённый тренд на сутки), а не дублирующее письмо: тексты и
|
||||||
|
# время срабатывания разные. DiskSpaceLow/Critical выше добавляют
|
||||||
|
# третий, независимый от скорости сигнал — «места мало» само по себе,
|
||||||
|
# даже если утечки нет и ничего не тает быстро.
|
||||||
- alert: DiskWillFillIn24h
|
- alert: DiskWillFillIn24h
|
||||||
expr: |
|
expr: |
|
||||||
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
|
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600))
|
||||||
|
/ 1073741824 > 0
|
||||||
for: 30m
|
for: 30m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "По текущему темпу диск кончится за сутки"
|
summary: "По текущему темпу диск кончится за сутки"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ."
|
||||||
|
|
||||||
- alert: MemoryPressure
|
- alert: MemoryPressure
|
||||||
expr: |
|
expr: |
|
||||||
|
|
@ -215,6 +260,40 @@ groups:
|
||||||
- name: app
|
- name: app
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
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 внутри знаменателя — тот же приём, что у
|
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
||||||
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
||||||
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
||||||
|
|
@ -408,3 +487,44 @@ groups:
|
||||||
annotations:
|
annotations:
|
||||||
summary: "WAL пишется быстрее 100 МБ/час"
|
summary: "WAL пишется быстрее 100 МБ/час"
|
||||||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
||||||
|
|
||||||
|
# ── Эскалация ──────────────────────────────────────────────────────────────
|
||||||
|
# Симптом 17.09: DiskWillFillIn24h пришёл вовремя и утонул между Watchdog
|
||||||
|
# (10080 интервалов firing за 7 суток — горит всегда по построению) и
|
||||||
|
# NoActiveCeleryWorkers (6229 интервалов) в общей ленте; диск дошёл до 84%
|
||||||
|
# незамеченным. Alertmanager сам по длительности не эскалирует — это
|
||||||
|
# правило Prometheus поверх служебной метрики ALERTS_FOR_STATE (unix-время
|
||||||
|
# входа тревоги в pending/firing, см. Robust Perception "The
|
||||||
|
# ALERTS_FOR_STATE metric").
|
||||||
|
- name: escalation
|
||||||
|
interval: 60s
|
||||||
|
rules:
|
||||||
|
# ИСКЛЮЧЕНИЯ В `alertname!~` ОБЯЗАТЕЛЬНЫ, а не для порядка:
|
||||||
|
# - Watchdog горит всегда по построению (`vector(1)` выше) — без
|
||||||
|
# исключения это правило унаследовало бы его вечный firing и стало
|
||||||
|
# ВТОРЫМ таким сигналом, то есть тем самым шумом, который лечим.
|
||||||
|
# - Сама AlertFiringTooLong — иначе, однажды сработав, она бы никогда
|
||||||
|
# не погасла: собственная ALERTS_FOR_STATE тоже старше порога, и
|
||||||
|
# правило продлевало бы себя бесконечно.
|
||||||
|
# Что это НЕ значит: если NoActiveCeleryWorkers (её чинит параллельная
|
||||||
|
# правка, здесь не трогаем) продолжит гореть дольше 6 часов, эта
|
||||||
|
# тревога сработает сразу после мержа — это ожидаемо и верно: она
|
||||||
|
# огонь реального незакрытого инцидента, а не вечная по построению.
|
||||||
|
#
|
||||||
|
# `label_replace(..., "stuck_alertname", "$1", "alertname", "(.+)")`
|
||||||
|
# ОБЯЗАТЕЛЕН, а не косметика: `alertname` — зарезервированный лейбл,
|
||||||
|
# Prometheus молча перезаписывает его именем ЭТОГО правила
|
||||||
|
# (AlertFiringTooLong) на выходе, каким бы ни было значение в expr.
|
||||||
|
# Без копии в `stuck_alertname` текст сообщения называл бы саму себя
|
||||||
|
# виновником, а не исходную тревогу.
|
||||||
|
- alert: AlertFiringTooLong
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
(time() - ALERTS_FOR_STATE{alertname!~"Watchdog|AlertFiringTooLong"}) > 6*3600,
|
||||||
|
"stuck_alertname", "$1", "alertname", "(.+)"
|
||||||
|
)
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "{{ $labels.stuck_alertname }}{{ if $labels.host }} ({{ $labels.host }}){{ end }} непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,140 @@ tests:
|
||||||
exp_annotations:
|
exp_annotations:
|
||||||
summary: "Ни одного живого воркера Celery"
|
summary: "Ни одного живого воркера Celery"
|
||||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
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 может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||||
|
|
||||||
|
# Эскалация по длительности. `promtool test rules` держит одну общую шкалу
|
||||||
|
# времени и TSDB на весь файл — к 6.5 часам к этому моменту «зависшими»
|
||||||
|
# (input series предыдущих сценариев кончились, но absent()-условия по ним
|
||||||
|
# продолжают гореть) оказываются и другие тестовые тревоги файла, не только
|
||||||
|
# NoActiveCeleryWorkers из этого блока. Список ниже — ровно то, что
|
||||||
|
# реально вернул promtool (проверено запуском, не придумано): 8 тревог,
|
||||||
|
# держащихся дольше 6 часов. ГЛАВНАЯ ПРОВЕРКА в этом списке — то, чего в
|
||||||
|
# нём НЕТ: ни Watchdog (горит вечно с t=0 точно так же, но исключён
|
||||||
|
# матчером), ни сама AlertFiringTooLong (иначе была бы там на восьмое
|
||||||
|
# место и продлевала бы себя бесконечно). В 3 часа — рано, эскалации
|
||||||
|
# ещё быть не должно вовсе.
|
||||||
|
- interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="celery",host="apps"}'
|
||||||
|
values: '1x420'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 3h
|
||||||
|
alertname: AlertFiringTooLong
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 6h30m
|
||||||
|
alertname: AlertFiringTooLong
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: MeraBackendDown
|
||||||
|
host: apps
|
||||||
|
job: app
|
||||||
|
app: mera
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "MeraBackendDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: HostAgentDown
|
||||||
|
host: apps
|
||||||
|
job: node
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "HostAgentDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: RemoteWriteStalled
|
||||||
|
host: apps
|
||||||
|
job: node
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "RemoteWriteStalled (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: CadvisorDown
|
||||||
|
job: cadvisor
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "CadvisorDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: QueueExporterDown
|
||||||
|
job: redis
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "QueueExporterDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: TradeInBackgroundContainerMissing
|
||||||
|
name: tradein-scraper
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: TradeInBackgroundContainerMissing
|
||||||
|
name: tradein-tgbot
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
stuck_alertname: NoActiveCeleryWorkers
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Тревога держится дольше 6 часов"
|
||||||
|
description: "NoActiveCeleryWorkers непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
||||||
|
|
|
||||||
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 для
|
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
||||||
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
# 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}"
|
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"
|
|
||||||
|
|
@ -1257,8 +1257,11 @@ async def start_avito_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_imv=payload.enrich_imv,
|
enrich_imv=payload.enrich_imv,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
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:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1349,8 +1352,11 @@ async def start_cian_city_sweep(
|
||||||
detail_top_n=payload.detail_top_n,
|
detail_top_n=payload.detail_top_n,
|
||||||
enrich_houses=payload.enrich_houses,
|
enrich_houses=payload.enrich_houses,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
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:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1492,8 +1498,11 @@ async def start_cian_full_load(
|
||||||
resume_run_id=payload.resume_run_id,
|
resume_run_id=payload.resume_run_id,
|
||||||
secondary_only=payload.secondary_only,
|
secondary_only=payload.secondary_only,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
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:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1595,8 +1604,11 @@ async def start_yandex_full_load(
|
||||||
concurrency=payload.concurrency,
|
concurrency=payload.concurrency,
|
||||||
resume_run_id=payload.resume_run_id,
|
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)
|
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:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1663,8 +1675,11 @@ async def start_yandex_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_address=payload.enrich_address,
|
enrich_address=payload.enrich_address,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
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:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
_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):
|
class GlitchTipAttachment(BaseModel):
|
||||||
|
|
@ -234,19 +243,21 @@ async def glitchtip_webhook(
|
||||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
||||||
client = get_telegram_client()
|
client = get_telegram_client()
|
||||||
try:
|
try:
|
||||||
await client.send_message(
|
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
|
||||||
chat_id=settings.telegram_alerts_chat_id,
|
await client.send_message(
|
||||||
text=text,
|
chat_id=settings.telegram_alerts_chat_id,
|
||||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
text=text,
|
||||||
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||||
# может легально висеть воркерные минуты ретраев.
|
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
||||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
# может легально висеть воркерные минуты ретраев.
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
)
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
except TelegramError:
|
)
|
||||||
|
except (TelegramError, TimeoutError):
|
||||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
||||||
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
|
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
|
||||||
|
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
|
||||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
||||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
||||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,16 @@
|
||||||
|
|
||||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||||
телефон + явное согласие на обработку персональных данных. Persist в
|
телефон + явное согласие на обработку персональных данных. Persist в
|
||||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
trade_in_leads.
|
||||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
|
||||||
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
Уведомление ответственному (#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` раньше только проверялся на
|
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||||
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||||
|
|
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from uuid import UUID
|
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 pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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.api.v1.trade_in import _assert_estimate_access
|
||||||
from app.core.config import settings
|
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.observability.metrics import LEADS
|
||||||
|
from app.services.tgbot.shared import get_telegram_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
|
||||||
return value
|
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")
|
@router.post("/lead")
|
||||||
async def create_trade_in_lead(
|
async def create_trade_in_lead(
|
||||||
payload: TradeInLeadInput,
|
payload: TradeInLeadInput,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||||
|
|
@ -185,6 +232,15 @@ async def create_trade_in_lead(
|
||||||
_CONSENT_POLICY_VERSION,
|
_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 {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"created_at": row["created_at"].isoformat(),
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.schemas.trade_in import (
|
||||||
CoverageProbeInput,
|
CoverageProbeInput,
|
||||||
CoverageProbeResponse,
|
CoverageProbeResponse,
|
||||||
DkpCorridor,
|
DkpCorridor,
|
||||||
|
ExposureWindow,
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
|
|
@ -390,6 +391,7 @@ async def _try_revive_dead_estimate(
|
||||||
),
|
),
|
||||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||||
)
|
)
|
||||||
|
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -418,6 +420,10 @@ async def _try_revive_dead_estimate(
|
||||||
ratio_basis = :ratio_basis,
|
ratio_basis = :ratio_basis,
|
||||||
relaxations = CAST(:relaxations_json AS jsonb),
|
relaxations = CAST(:relaxations_json AS jsonb),
|
||||||
reliability = :reliability,
|
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()
|
revival_completed_at = NOW()
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
"""
|
"""
|
||||||
|
|
@ -452,6 +458,10 @@ async def _try_revive_dead_estimate(
|
||||||
"ratio_basis": result.ratio_basis,
|
"ratio_basis": result.ratio_basis,
|
||||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||||
"reliability": result.reliability,
|
"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(
|
db.execute(
|
||||||
|
|
@ -678,7 +688,8 @@ def load_estimate(
|
||||||
expected_sold_price, expected_sold_range_low,
|
expected_sold_price, expected_sold_range_low,
|
||||||
expected_sold_range_high, expected_sold_per_m2,
|
expected_sold_range_high, expected_sold_per_m2,
|
||||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||||
relaxations, reliability
|
relaxations, reliability,
|
||||||
|
est_days_p25, est_days_p50, est_days_p75, est_days_n
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
AND {ESTIMATE_READABLE_SQL}
|
AND {ESTIMATE_READABLE_SQL}
|
||||||
|
|
@ -718,6 +729,7 @@ def load_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
|
analog_tier_from_explanation,
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -842,6 +854,19 @@ def load_estimate(
|
||||||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||||
# ответ AttributeError'ом.
|
# ответ AttributeError'ом.
|
||||||
market_percentile=getattr(row, "market_percentile", None),
|
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,
|
period_months=12,
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
|
|
@ -896,6 +921,10 @@ def load_estimate(
|
||||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||||
search_radius_m=search_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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
|
||||||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
@ -378,17 +384,6 @@ class Settings(BaseSettings):
|
||||||
dadata_api_token: str | None = None
|
dadata_api_token: str | None = None
|
||||||
dadata_api_secret: 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): цена обязана опираться на
|
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||||
|
|
@ -408,25 +403,6 @@ class Settings(BaseSettings):
|
||||||
# False ⇒ прежнее поведение целиком, без релиза.
|
# False ⇒ прежнее поведение целиком, без релиза.
|
||||||
estimate_require_local_evidence: bool = True
|
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 ─────
|
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||||
|
|
@ -521,128 +497,13 @@ class Settings(BaseSettings):
|
||||||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||||
estimate_hedonic_factor_min: float = 0.75
|
estimate_hedonic_factor_min: float = 0.75
|
||||||
estimate_hedonic_factor_max: float = 1.30
|
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²)
|
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 ────────────────────────────────
|
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||||
estimate_price_trend_max_age_months: int = 6
|
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 ──
|
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||||||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||||||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||||||
|
|
@ -711,6 +572,16 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||||
estimate_unknown_attr_penalty_enabled: bool = False
|
estimate_unknown_attr_penalty_enabled: bool = False
|
||||||
estimate_unknown_attr_penalty_min_n: int = 5
|
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): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||||
|
|
@ -725,20 +596,6 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||||||
estimate_is_apartments_filter_enabled: bool = False
|
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 ────────────────────────
|
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||||||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||||||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||||||
|
|
@ -763,26 +620,7 @@ class Settings(BaseSettings):
|
||||||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||||
avito_imv_use_browser_fetcher: bool = False
|
avito_imv_use_browser_fetcher: bool = False
|
||||||
|
|
||||||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
||||||
# 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
|
|
||||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||||
|
|
@ -971,7 +809,7 @@ class Settings(BaseSettings):
|
||||||
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
# регион 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)
|
# регион 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),
|
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
||||||
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
|
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
|
||||||
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
||||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
||||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
||||||
|
|
@ -1194,14 +1032,6 @@ class Settings(BaseSettings):
|
||||||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||||||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="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) ────────────────────────────
|
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||||||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||||||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||||||
|
|
@ -1221,25 +1051,19 @@ class Settings(BaseSettings):
|
||||||
cian_login_success_cookie: str = "DMIR_AUTH"
|
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||||
cian_login_wait_ms: int = 4000
|
cian_login_wait_ms: int = 4000
|
||||||
|
|
||||||
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||||
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
|
||||||
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
# True = curl_cffi через settings.scraper_proxy_url.
|
||||||
# → 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.
|
|
||||||
#
|
#
|
||||||
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
|
||||||
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
|
||||||
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
|
||||||
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
|
||||||
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
|
||||||
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
# environment'ом сервиса scraper, а tradein-backend (ручка админки
|
||||||
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
|
||||||
# других окружений вслепую.
|
|
||||||
avito_detail_backfill_use_curl: bool = Field(
|
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. Зависший
|
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
|
||||||
|
|
||||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
||||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
||||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
|
||||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
ценовой шкале. None если для дома нет свежей IMV-записи.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -163,9 +163,13 @@ class DkpCorridor(BaseModel):
|
||||||
[low,high]×slack — добавляется текстовая пометка.
|
[low,high]×slack — добавляется текстовая пометка.
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
None / count=0 если по улице нет сопоставимых сделок.
|
||||||
|
|
||||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
|
||||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
|
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
|
||||||
|
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
|
||||||
|
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
|
||||||
|
из коридора, а поле обязано быть верным и на GET-rehydrate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
count: int # число ДКП-сделок в выборке
|
||||||
|
|
@ -192,10 +196,13 @@ class DkpCorridor(BaseModel):
|
||||||
def advisory_only(self) -> bool:
|
def advisory_only(self) -> bool:
|
||||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||||
|
|
||||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
|
||||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
цену — зона n=1..9 на экране была неотличима от работающей.
|
||||||
|
|
||||||
|
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
|
||||||
|
обе страховки выключены якорем, а не порогом (#3466).
|
||||||
|
|
||||||
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||||
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||||
|
|
@ -207,9 +214,9 @@ class DkpCorridor(BaseModel):
|
||||||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
автоматически (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):
|
class PriceTrendPoint(BaseModel):
|
||||||
|
|
@ -223,6 +230,21 @@ class PriceTrendPoint(BaseModel):
|
||||||
ppm2: int # медиана ₽/м² за месяц
|
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):
|
class AggregatedEstimate(BaseModel):
|
||||||
estimate_id: UUID
|
estimate_id: UUID
|
||||||
median_price_rub: int
|
median_price_rub: int
|
||||||
|
|
@ -285,7 +307,11 @@ class AggregatedEstimate(BaseModel):
|
||||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||||
# абсолютный timestamp самого свежего парсинга аналогов
|
# абсолютный timestamp самого свежего парсинга аналогов
|
||||||
last_scraped_at: datetime | None = None
|
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
|
cian_valuation: CianValuationSummary | None = None
|
||||||
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
|
||||||
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
|
||||||
|
|
@ -344,6 +370,9 @@ class AggregatedEstimate(BaseModel):
|
||||||
# "city" — radius Tier W: широкий fallback
|
# "city" — radius Tier W: широкий fallback
|
||||||
# null — нет данных / оценка не построена
|
# null — нет данных / оценка не построена
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет 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
|
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ class BlockRatioBreaker:
|
||||||
ratio_threshold: float
|
ratio_threshold: float
|
||||||
safety_min: int
|
safety_min: int
|
||||||
snapshot_size: int
|
snapshot_size: int
|
||||||
|
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
|
||||||
|
# площадки, рвут прогон. 0 — критерий выключен.
|
||||||
|
infra_streak_limit: int = 0
|
||||||
_window: deque[bool] = field(init=False, repr=False)
|
_window: deque[bool] = field(init=False, repr=False)
|
||||||
_consecutive_blocks: int = field(default=0, init=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)
|
_pure_block_run: bool = field(default=True, init=False)
|
||||||
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
||||||
|
|
||||||
|
|
@ -106,10 +110,16 @@ class BlockRatioBreaker:
|
||||||
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
||||||
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
||||||
не считают.
|
не считают.
|
||||||
|
|
||||||
|
Свой предохранитель у такого отказа — серия infra_streak_limit (#3439): без
|
||||||
|
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
|
||||||
|
1600, status='done'). Серию гасит любой ответ площадки — успех, её блок, 404.
|
||||||
"""
|
"""
|
||||||
if kind != BAN_KIND_PLATFORM:
|
if kind != BAN_KIND_PLATFORM:
|
||||||
|
self._consecutive_infra += 1
|
||||||
self.record_failure()
|
self.record_failure()
|
||||||
return
|
return
|
||||||
|
self._consecutive_infra = 0
|
||||||
self._consecutive_blocks += 1
|
self._consecutive_blocks += 1
|
||||||
self._window.append(True)
|
self._window.append(True)
|
||||||
|
|
||||||
|
|
@ -117,6 +127,7 @@ class BlockRatioBreaker:
|
||||||
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
||||||
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
||||||
self._flush_streak()
|
self._flush_streak()
|
||||||
|
self._consecutive_infra = 0
|
||||||
self._pure_block_run = False
|
self._pure_block_run = False
|
||||||
self._window.append(False)
|
self._window.append(False)
|
||||||
|
|
||||||
|
|
@ -131,8 +142,11 @@ class BlockRatioBreaker:
|
||||||
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
||||||
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
||||||
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
||||||
задачи -- своя калибровка, свои ограничения прокси-пула)."""
|
задачи -- своя калибровка, свои ограничения прокси-пула).
|
||||||
return
|
|
||||||
|
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
|
||||||
|
значит наш тракт жив."""
|
||||||
|
self._consecutive_infra = 0
|
||||||
|
|
||||||
def _flush_streak(self) -> None:
|
def _flush_streak(self) -> None:
|
||||||
if self._consecutive_blocks:
|
if self._consecutive_blocks:
|
||||||
|
|
@ -142,9 +156,11 @@ class BlockRatioBreaker:
|
||||||
def abort_reason(self) -> str | None:
|
def abort_reason(self) -> str | None:
|
||||||
"""Какой критерий требует обрыва прямо сейчас, или 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-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
|
||||||
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
||||||
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
||||||
|
|
@ -176,6 +192,11 @@ class BlockRatioBreaker:
|
||||||
should_abort(), так что в логи не попадает.
|
should_abort(), так что в логи не попадает.
|
||||||
"""
|
"""
|
||||||
reason = self.abort_reason()
|
reason = self.abort_reason()
|
||||||
|
if reason == "infra_streak":
|
||||||
|
return (
|
||||||
|
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
|
||||||
|
"без единого ответа площадки"
|
||||||
|
)
|
||||||
if reason == "ratio":
|
if reason == "ratio":
|
||||||
return (
|
return (
|
||||||
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from typing import Any, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
IMVAddressNotFoundError,
|
IMVAddressNotFoundError,
|
||||||
IMVAuthError,
|
IMVAuthError,
|
||||||
|
|
@ -55,7 +56,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
|
from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings
|
||||||
from app.core.db import SessionLocal, run_db_thread
|
from app.core.db import SessionLocal, run_db_thread
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
|
|
@ -63,6 +64,7 @@ from app.schemas.trade_in import (
|
||||||
AvitoImvSummary,
|
AvitoImvSummary,
|
||||||
CianValuationSummary,
|
CianValuationSummary,
|
||||||
DkpCorridor,
|
DkpCorridor,
|
||||||
|
ExposureWindow,
|
||||||
PriceTrendPoint,
|
PriceTrendPoint,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
|
|
@ -283,6 +285,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier).
|
||||||
|
# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок
|
||||||
|
# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние.
|
||||||
|
# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём,
|
||||||
|
# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не
|
||||||
|
# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка
|
||||||
|
# ?id= показывала бы такой коридор как работающий.
|
||||||
|
# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся
|
||||||
|
# радиусные тиры на GET или подпись начнут переписывать мимо этой константы.
|
||||||
|
ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"}
|
||||||
|
ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"}
|
||||||
|
|
||||||
|
|
||||||
|
def analog_tier_from_explanation(explanation: str | None) -> str | None:
|
||||||
|
"""Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None.
|
||||||
|
|
||||||
|
Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того
|
||||||
|
же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой
|
||||||
|
путь цены — коридор там клампит. None = радиусный путь либо старая формулировка.
|
||||||
|
"""
|
||||||
|
for tier, label in ANCHOR_TIER_LABELS.items():
|
||||||
|
if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""):
|
||||||
|
return ANCHOR_API_TIERS[tier]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def rehydrate_search_radius_m(
|
def rehydrate_search_radius_m(
|
||||||
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|
@ -316,7 +344,7 @@ def rehydrate_search_radius_m(
|
||||||
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
||||||
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
||||||
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
||||||
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor
|
# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor
|
||||||
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
||||||
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
||||||
|
|
||||||
|
|
@ -416,6 +444,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
|
||||||
("премиум", float("inf")),
|
("премиум", float("inf")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
|
||||||
|
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
|
||||||
|
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
|
||||||
|
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
|
||||||
|
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
|
||||||
|
# число = правка здесь + регрессионный гейт бэктеста
|
||||||
|
# (tests/test_backtest_regression_gate.py).
|
||||||
|
|
||||||
|
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
|
||||||
|
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
|
||||||
|
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
|
||||||
|
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
|
||||||
|
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
|
||||||
|
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
|
||||||
|
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
|
||||||
|
WIDE_CORRIDOR_THRESHOLD = 1.2
|
||||||
|
|
||||||
|
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
|
||||||
|
# метаданные — median/expected_sold/ranges не трогают.
|
||||||
|
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
|
||||||
|
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
|
||||||
|
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
|
||||||
|
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||||
|
MANUAL_REVIEW_ELITE_PPM2 = 250_000
|
||||||
|
|
||||||
|
# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на
|
||||||
|
# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует
|
||||||
|
# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов:
|
||||||
|
# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||||
|
# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет).
|
||||||
|
QUARTER_INDEX_MIN_N_DEALS = 10
|
||||||
|
# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога —
|
||||||
|
# локация квартала уже в медиане → skip.
|
||||||
|
QUARTER_MATCH_SKIP_RATIO = 0.6
|
||||||
|
# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают
|
||||||
|
# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия.
|
||||||
|
QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0
|
||||||
|
QUARTER_INDEX_SMALL_N_THRESHOLD = 50
|
||||||
|
# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные
|
||||||
|
# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал.
|
||||||
|
QUARTER_INDEX_FACTOR_MIN = 0.6
|
||||||
|
QUARTER_INDEX_FACTOR_MAX = 1.8
|
||||||
|
|
||||||
|
# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline
|
||||||
|
# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок
|
||||||
|
# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в
|
||||||
|
# app.core.config (его же читает DkpCorridor.advisory_only, #3452).
|
||||||
|
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack)
|
||||||
|
# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) →
|
||||||
|
# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без
|
||||||
|
# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию.
|
||||||
|
CORRIDOR_CLAMP_SLACK = 0.40
|
||||||
|
# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n):
|
||||||
|
# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10
|
||||||
|
# коридора: достаточно мягко для эконома, ловит явный undershoot.
|
||||||
|
RADIUS_FLOOR_FACTOR = 0.8
|
||||||
|
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо
|
||||||
|
# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх.
|
||||||
|
OUTLIER_SMALL_N_THRESHOLD = 15
|
||||||
|
OUTLIER_TUKEY_K_SMALL = 1.0
|
||||||
|
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
|
||||||
|
# для trade-in физически невозможен, это артефакт high-price бакета.
|
||||||
|
|
||||||
|
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
|
||||||
|
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
|
||||||
|
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
|
||||||
|
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
|
||||||
|
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
|
||||||
|
IMV_BLEND_WEIGHT = 0.5
|
||||||
|
IMV_BLEND_THRESHOLD = 1.15
|
||||||
|
|
||||||
|
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
|
||||||
|
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2
|
||||||
|
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
|
||||||
|
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
|
||||||
|
# по ppm²), hard guardrail и tighter FSD-диапазон.
|
||||||
|
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||||
|
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
|
||||||
|
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||||
|
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
|
||||||
|
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
|
||||||
|
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
|
||||||
|
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
|
||||||
|
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
|
||||||
|
SB_FLOOR_SIGMA = 0.25
|
||||||
|
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||||
|
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||||
|
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
|
||||||
|
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
|
||||||
|
SB_MAD_K_SMALL_N = 2.5
|
||||||
|
SB_SMALL_N_THRESHOLD = 10
|
||||||
|
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
|
||||||
|
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
|
||||||
|
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
|
||||||
|
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
|
||||||
|
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
|
||||||
|
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
|
||||||
|
# FSD<0.15) проходят без изменений.
|
||||||
|
SB_GATE_MIN_N = 3
|
||||||
|
SB_GATE_MAX_FSD = 0.20
|
||||||
|
|
||||||
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||||
|
|
@ -1988,15 +2117,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float:
|
||||||
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
|
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
|
||||||
|
|
||||||
|
|
||||||
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
|
# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут
|
||||||
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
|
# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с
|
||||||
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
|
# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей
|
||||||
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
|
# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в
|
||||||
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
|
# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities
|
||||||
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
|
# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS
|
||||||
|
# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а
|
||||||
|
# deals.city хранит е-формы («Артемовский»).
|
||||||
|
_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset(
|
||||||
|
name.lower().replace("ё", "е")
|
||||||
|
for name in CITY_DISPLAY_NAMES.values()
|
||||||
|
if (region := regions_mod.region_by_city(name)) is None or region.code == 66
|
||||||
|
)
|
||||||
|
|
||||||
|
# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для
|
||||||
|
# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких
|
||||||
|
# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил»
|
||||||
|
# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ
|
||||||
|
# именам городов, но сохраняем ту же дисциплину сортировки, что и
|
||||||
|
# geocoder._OBLAST_CITY_RE).
|
||||||
_TARGET_CITY_RE = re.compile(
|
_TARGET_CITY_RE = re.compile(
|
||||||
r"\b(?:"
|
r"\b(?:"
|
||||||
+ "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True))
|
+ "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True))
|
||||||
+ r")\b"
|
+ r")\b"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2004,8 +2147,8 @@ _TARGET_CITY_RE = re.compile(
|
||||||
def _resolve_target_city(address_text: str | None) -> str | None:
|
def _resolve_target_city(address_text: str | None) -> str | None:
|
||||||
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
|
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
|
||||||
|
|
||||||
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
|
Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города
|
||||||
границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
|
||||||
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
|
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
|
||||||
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
|
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
|
||||||
важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key).
|
важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key).
|
||||||
|
|
@ -2013,7 +2156,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
|
||||||
Returns None если ни один город не распознан (адрес вне известного
|
Returns None если ни один город не распознан (адрес вне известного
|
||||||
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
||||||
city-фильтра. NB: коридор НЕ чисто advisory — при
|
city-фильтра. NB: коридор НЕ чисто advisory — при
|
||||||
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor +
|
n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor +
|
||||||
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
||||||
"""
|
"""
|
||||||
if not address_text:
|
if not address_text:
|
||||||
|
|
@ -2223,7 +2366,7 @@ def _fetch_dkp_corridor(
|
||||||
latest_deal = dd
|
latest_deal = dd
|
||||||
ppm2_values = sorted(adjusted)
|
ppm2_values = sorted(adjusted)
|
||||||
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||||
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
|
||||||
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||||
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||||
scope = "street"
|
scope = "street"
|
||||||
|
|
@ -2592,7 +2735,7 @@ def _fetch_anchor_comps(
|
||||||
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
||||||
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
||||||
"""
|
"""
|
||||||
min_comps = settings.estimate_sb_min_comps
|
min_comps = SB_MIN_COMPS
|
||||||
|
|
||||||
# ── Tier A: same building ────────────────────────────────────────────────
|
# ── Tier A: same building ────────────────────────────────────────────────
|
||||||
street, base_no, letter = _normalize_building_key(address)
|
street, base_no, letter = _normalize_building_key(address)
|
||||||
|
|
@ -2975,12 +3118,8 @@ def _compute_same_building_anchor(
|
||||||
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
||||||
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
||||||
effective_mad_k = mad_k
|
effective_mad_k = mad_k
|
||||||
if (
|
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
|
||||||
tier != "A"
|
effective_mad_k = SB_MAD_K_SMALL_N
|
||||||
and settings.estimate_sb_mad_k_small_n < mad_k
|
|
||||||
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
|
|
||||||
):
|
|
||||||
effective_mad_k = settings.estimate_sb_mad_k_small_n
|
|
||||||
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
||||||
if len(surviving_idx) < min_comps:
|
if len(surviving_idx) < min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -3394,7 +3533,7 @@ def _price_from_inputs(
|
||||||
"""
|
"""
|
||||||
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
|
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
|
||||||
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
||||||
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
# по кросс-постам.
|
||||||
listings = _dedup_cross_source(listings)
|
listings = _dedup_cross_source(listings)
|
||||||
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
||||||
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
||||||
|
|
@ -3591,32 +3730,28 @@ def _price_from_inputs(
|
||||||
area_target=area_m2,
|
area_target=area_m2,
|
||||||
rooms_target=rooms,
|
rooms_target=rooms,
|
||||||
tier=anchor_tier or "",
|
tier=anchor_tier or "",
|
||||||
sigma=settings.estimate_sb_area_sigma,
|
sigma=SB_AREA_SIGMA,
|
||||||
rooms_boost=settings.estimate_sb_rooms_match_boost,
|
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||||
floor_target=floor,
|
floor_target=floor,
|
||||||
total_floors_target=total_floors,
|
total_floors_target=total_floors,
|
||||||
floor_sigma=settings.estimate_sb_floor_sigma,
|
floor_sigma=SB_FLOOR_SIGMA,
|
||||||
min_comps=settings.estimate_sb_min_comps,
|
min_comps=SB_MIN_COMPS,
|
||||||
mad_k=settings.estimate_sb_mad_k,
|
mad_k=SB_MAD_K,
|
||||||
)
|
)
|
||||||
|
|
||||||
# #1795 шаг 3: гейт Tier C.
|
# #1795 шаг 3: гейт Tier C.
|
||||||
if (
|
if anchor is not None and anchor_tier == "C":
|
||||||
anchor is not None
|
|
||||||
and anchor_tier == "C"
|
|
||||||
and settings.estimate_anchor_tier_c_corridor_mult > 0
|
|
||||||
):
|
|
||||||
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
||||||
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
||||||
else:
|
else:
|
||||||
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
||||||
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
|
gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
|
||||||
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
||||||
logger.info(
|
logger.info(
|
||||||
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
||||||
" → keep radius median (anchor suppressed)",
|
" → keep radius median (anchor suppressed)",
|
||||||
int(anchor["anchor_ppm2"]),
|
int(anchor["anchor_ppm2"]),
|
||||||
settings.estimate_anchor_tier_c_corridor_mult,
|
ANCHOR_TIER_C_CORRIDOR_MULT,
|
||||||
int(gate_threshold),
|
int(gate_threshold),
|
||||||
)
|
)
|
||||||
anchor = None
|
anchor = None
|
||||||
|
|
@ -3624,10 +3759,7 @@ def _price_from_inputs(
|
||||||
# #audit-1: low-confidence gate.
|
# #audit-1: low-confidence gate.
|
||||||
if anchor is not None:
|
if anchor is not None:
|
||||||
gate_low = anchor["confidence"] == "low"
|
gate_low = anchor["confidence"] == "low"
|
||||||
gate_thin = (
|
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
|
||||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
|
||||||
)
|
|
||||||
if gate_low or gate_thin:
|
if gate_low or gate_thin:
|
||||||
logger.info(
|
logger.info(
|
||||||
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
||||||
|
|
@ -3667,13 +3799,13 @@ def _price_from_inputs(
|
||||||
anchor_comps_used = anchor.get("comps") or anchor_comps
|
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||||
est_ppm2 = anchor["anchor_ppm2"]
|
est_ppm2 = anchor["anchor_ppm2"]
|
||||||
# PREMIUM GUARDRAIL (hard).
|
# PREMIUM GUARDRAIL (hard).
|
||||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
|
||||||
if est_ppm2 < floor_ppm2:
|
if est_ppm2 < floor_ppm2:
|
||||||
est_ppm2 = floor_ppm2
|
est_ppm2 = floor_ppm2
|
||||||
new_ppm2 = est_ppm2 * repair_coef
|
new_ppm2 = est_ppm2 * repair_coef
|
||||||
point = int(new_ppm2 * area_m2)
|
point = int(new_ppm2 * area_m2)
|
||||||
# FSD-диапазон.
|
# FSD-диапазон.
|
||||||
half = settings.estimate_fsd_k * anchor["fsd"]
|
half = FSD_K * anchor["fsd"]
|
||||||
new_range_low = int(point * max(0.0, 1.0 - half))
|
new_range_low = int(point * max(0.0, 1.0 - half))
|
||||||
new_range_high = int(point * (1.0 + half))
|
new_range_high = int(point * (1.0 + half))
|
||||||
# Спред комплов.
|
# Спред комплов.
|
||||||
|
|
@ -3699,10 +3831,10 @@ def _price_from_inputs(
|
||||||
range_low = new_range_low
|
range_low = new_range_low
|
||||||
range_high = new_range_high
|
range_high = new_range_high
|
||||||
confidence = anchor["confidence"]
|
confidence = anchor["confidence"]
|
||||||
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
|
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
|
||||||
# #695: explanation описывает ИМЕННО якорные комплы.
|
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
|
||||||
explanation = (
|
explanation = (
|
||||||
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
|
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
|
||||||
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
||||||
) + repair_note
|
) + repair_note
|
||||||
# #695 (QA fixup): n_analogs по anchor-популяции.
|
# #695 (QA fixup): n_analogs по anchor-популяции.
|
||||||
|
|
@ -3726,7 +3858,7 @@ def _price_from_inputs(
|
||||||
# #1871 P2: split-дома wide-corridor disclosure.
|
# #1871 P2: split-дома wide-corridor disclosure.
|
||||||
if anchor_tier == "A" and median_price > 0:
|
if anchor_tier == "A" and median_price > 0:
|
||||||
corridor_pct = (range_high - range_low) / median_price
|
corridor_pct = (range_high - range_low) / median_price
|
||||||
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
|
||||||
confidence = _downgrade_confidence(confidence)
|
confidence = _downgrade_confidence(confidence)
|
||||||
explanation = (explanation or "") + (
|
explanation = (explanation or "") + (
|
||||||
" Очень широкий ценовой диапазон по дому (вероятно, дом "
|
" Очень широкий ценовой диапазон по дому (вероятно, дом "
|
||||||
|
|
@ -3830,8 +3962,8 @@ def _price_from_inputs(
|
||||||
area=area_m2,
|
area=area_m2,
|
||||||
anchor_total=anchor_total,
|
anchor_total=anchor_total,
|
||||||
anchor_higher=anchor_higher,
|
anchor_higher=anchor_higher,
|
||||||
weight=settings.estimate_imv_blend_weight,
|
weight=IMV_BLEND_WEIGHT,
|
||||||
threshold=settings.estimate_imv_blend_threshold,
|
threshold=IMV_BLEND_THRESHOLD,
|
||||||
market_count=(
|
market_count=(
|
||||||
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||||
),
|
),
|
||||||
|
|
@ -3843,7 +3975,7 @@ def _price_from_inputs(
|
||||||
median_price,
|
median_price,
|
||||||
new_median,
|
new_median,
|
||||||
anchor_used,
|
anchor_used,
|
||||||
settings.estimate_imv_blend_weight,
|
IMV_BLEND_WEIGHT,
|
||||||
range_high,
|
range_high,
|
||||||
new_range_high,
|
new_range_high,
|
||||||
)
|
)
|
||||||
|
|
@ -3911,15 +4043,15 @@ def _price_from_inputs(
|
||||||
|
|
||||||
# Bimodal/nominal guard (Guard-4).
|
# Bimodal/nominal guard (Guard-4).
|
||||||
if (
|
if (
|
||||||
target_qi > settings.estimate_quarter_index_max_for_small_n
|
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
|
||||||
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
|
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: bimodal guard triggered "
|
"quarter_index: bimodal guard triggered "
|
||||||
"(index=%.3f n=%d < %d) for %s — no-op",
|
"(index=%.3f n=%d < %d) for %s — no-op",
|
||||||
target_qi,
|
target_qi,
|
||||||
target_n_deals,
|
target_n_deals,
|
||||||
settings.estimate_quarter_index_small_n_threshold,
|
QUARTER_INDEX_SMALL_N_THRESHOLD,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3944,11 +4076,11 @@ def _price_from_inputs(
|
||||||
if lot_quarters_for_guard2
|
if lot_quarters_for_guard2
|
||||||
else 0.0
|
else 0.0
|
||||||
)
|
)
|
||||||
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||||
same_quarter_ratio,
|
same_quarter_ratio,
|
||||||
settings.estimate_quarter_match_skip_ratio,
|
QUARTER_MATCH_SKIP_RATIO,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -4001,8 +4133,8 @@ def _price_from_inputs(
|
||||||
base_range_high=range_high,
|
base_range_high=range_high,
|
||||||
target_index=target_qi,
|
target_index=target_qi,
|
||||||
avg_analog_index=avg_analog_index,
|
avg_analog_index=avg_analog_index,
|
||||||
min_factor=settings.estimate_quarter_index_factor_min,
|
min_factor=QUARTER_INDEX_FACTOR_MIN,
|
||||||
max_factor=settings.estimate_quarter_index_factor_max,
|
max_factor=QUARTER_INDEX_FACTOR_MAX,
|
||||||
)
|
)
|
||||||
analogs_with_qi = sum(
|
analogs_with_qi = sum(
|
||||||
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
||||||
|
|
@ -4024,7 +4156,7 @@ def _price_from_inputs(
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
||||||
|
|
||||||
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
||||||
slack = settings.estimate_corridor_clamp_slack
|
slack = CORRIDOR_CLAMP_SLACK
|
||||||
if dkp_raw is not None and median_ppm2 > 0:
|
if dkp_raw is not None and median_ppm2 > 0:
|
||||||
old_ppm2 = median_ppm2
|
old_ppm2 = median_ppm2
|
||||||
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
||||||
|
|
@ -4036,7 +4168,7 @@ def _price_from_inputs(
|
||||||
corridor_count=dkp_raw["count"],
|
corridor_count=dkp_raw["count"],
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=settings.estimate_corridor_clamp_min_n,
|
min_n=CORRIDOR_CLAMP_MIN_N,
|
||||||
)
|
)
|
||||||
if clamped:
|
if clamped:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4059,9 +4191,9 @@ def _price_from_inputs(
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("low_ppm2", 0) > 0
|
and dkp_raw.get("low_ppm2", 0) > 0
|
||||||
and median_ppm2 > 0
|
and median_ppm2 > 0
|
||||||
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
|
and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N
|
||||||
):
|
):
|
||||||
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
|
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR
|
||||||
if median_ppm2 < radius_floor_ppm2:
|
if median_ppm2 < radius_floor_ppm2:
|
||||||
floor_factor = radius_floor_ppm2 / median_ppm2
|
floor_factor = radius_floor_ppm2 / median_ppm2
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4069,7 +4201,7 @@ def _price_from_inputs(
|
||||||
" → lifting (factor=%.3f)",
|
" → lifting (factor=%.3f)",
|
||||||
int(median_ppm2),
|
int(median_ppm2),
|
||||||
dkp_raw["low_ppm2"],
|
dkp_raw["low_ppm2"],
|
||||||
settings.estimate_radius_floor_factor,
|
RADIUS_FLOOR_FACTOR,
|
||||||
int(radius_floor_ppm2),
|
int(radius_floor_ppm2),
|
||||||
floor_factor,
|
floor_factor,
|
||||||
)
|
)
|
||||||
|
|
@ -4089,7 +4221,7 @@ def _price_from_inputs(
|
||||||
|
|
||||||
if asking_to_sold_ratio is not None and median_price > 0:
|
if asking_to_sold_ratio is not None and median_price > 0:
|
||||||
effective_ratio = asking_to_sold_ratio
|
effective_ratio = asking_to_sold_ratio
|
||||||
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
|
if effective_ratio > 1.0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
||||||
effective_ratio,
|
effective_ratio,
|
||||||
|
|
@ -4132,7 +4264,7 @@ def _price_from_inputs(
|
||||||
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
|
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
|
||||||
# the headline so the calibrated PI range below wraps the clamped point.
|
# the headline so the calibrated PI range below wraps the clamped point.
|
||||||
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
|
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
|
||||||
if settings.estimate_expected_sold_le_asking and expected_sold_price:
|
if expected_sold_price:
|
||||||
expected_sold_price = min(expected_sold_price, median_price)
|
expected_sold_price = min(expected_sold_price, median_price)
|
||||||
if expected_sold_per_m2:
|
if expected_sold_per_m2:
|
||||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
||||||
|
|
@ -4214,7 +4346,7 @@ def _price_from_inputs(
|
||||||
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
||||||
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
||||||
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
||||||
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
|
# own MAD-clip drops comps below SB_MIN_COMPS), it returns None
|
||||||
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
||||||
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
||||||
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
||||||
|
|
@ -5166,14 +5298,14 @@ async def estimate_quality(
|
||||||
return _lookup_quarter_index(
|
return _lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
quarter_cad_number=q,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
||||||
return _lookup_quarter_indexes(
|
return _lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
quarter_cad_numbers=qs,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Deterministic pricing orchestration ──────────────────────────────────
|
# ── Deterministic pricing orchestration ──────────────────────────────────
|
||||||
|
|
@ -5311,10 +5443,11 @@ async def estimate_quality(
|
||||||
estimate_id = uuid4()
|
estimate_id = uuid4()
|
||||||
|
|
||||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
|
||||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
# Зона n=1..9 на экране неотличима от работающего коридора, поэтому
|
||||||
# попадание в неё пишется явной строкой: число оценок за сутки —
|
# попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный
|
||||||
|
# коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки —
|
||||||
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
||||||
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
||||||
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
||||||
|
|
@ -5322,7 +5455,7 @@ async def estimate_quality(
|
||||||
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||||
estimate_id,
|
estimate_id,
|
||||||
dkp_corridor.count,
|
dkp_corridor.count,
|
||||||
settings.estimate_corridor_clamp_min_n,
|
CORRIDOR_CLAMP_MIN_N,
|
||||||
(dkp_raw or {}).get("scope", "unknown"),
|
(dkp_raw or {}).get("scope", "unknown"),
|
||||||
target_city or "unknown",
|
target_city or "unknown",
|
||||||
)
|
)
|
||||||
|
|
@ -5385,6 +5518,19 @@ async def estimate_quality(
|
||||||
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
|
||||||
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
source_counts = _source_counts([lot.source for lot in analogs_lots])
|
||||||
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
freshness_pre = _compute_freshness_minutes(metadata_lots)
|
||||||
|
# #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц
|
||||||
|
# покажет то же окно, что в момент расчёта, а не пересчёт по новой истории.
|
||||||
|
exposure_window = _exposure_window(
|
||||||
|
await run_db_thread(
|
||||||
|
_fetch_exposure_days,
|
||||||
|
db,
|
||||||
|
lat=geo.lat,
|
||||||
|
lon=geo.lon,
|
||||||
|
rooms=payload.rooms,
|
||||||
|
area=payload.area_m2,
|
||||||
|
radius_m=search_radius_m,
|
||||||
|
)
|
||||||
|
)
|
||||||
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
# DaData enrichment (PR Q1) — заполняется только если service отработал.
|
||||||
# При DaData = None все колонки идут в DB как NULL (graceful).
|
# При DaData = None все колонки идут в DB как NULL (graceful).
|
||||||
dadata_metro_json = (
|
dadata_metro_json = (
|
||||||
|
|
@ -5414,6 +5560,7 @@ async def estimate_quality(
|
||||||
expected_sold_range_high, expected_sold_per_m2,
|
expected_sold_range_high, expected_sold_per_m2,
|
||||||
asking_to_sold_ratio, ratio_basis,
|
asking_to_sold_ratio, ratio_basis,
|
||||||
relaxations, reliability,
|
relaxations, reliability,
|
||||||
|
est_days_p25, est_days_p50, est_days_p75, est_days_n,
|
||||||
created_by,
|
created_by,
|
||||||
expires_at,
|
expires_at,
|
||||||
consent, client_ip, consent_policy_version, consent_text_snapshot
|
consent, client_ip, consent_policy_version, consent_text_snapshot
|
||||||
|
|
@ -5437,6 +5584,7 @@ async def estimate_quality(
|
||||||
:expected_sold_range_high, :expected_sold_per_m2,
|
:expected_sold_range_high, :expected_sold_per_m2,
|
||||||
:asking_to_sold_ratio, :ratio_basis,
|
:asking_to_sold_ratio, :ratio_basis,
|
||||||
CAST(:relaxations_json AS jsonb), :reliability,
|
CAST(:relaxations_json AS jsonb), :reliability,
|
||||||
|
:est_days_p25, :est_days_p50, :est_days_p75, :est_days_n,
|
||||||
:created_by,
|
:created_by,
|
||||||
:expires_at,
|
:expires_at,
|
||||||
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
:consent, CAST(:client_ip AS inet), :consent_policy_version,
|
||||||
|
|
@ -5497,6 +5645,11 @@ async def estimate_quality(
|
||||||
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
# пропадал при открытии оценки по сохранённой ссылке (?id=).
|
||||||
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
|
||||||
"reliability": reliability,
|
"reliability": reliability,
|
||||||
|
# #2898: окно пишем целиком или NULL целиком (n < порога).
|
||||||
|
"est_days_p25": exposure_window.p25_days if exposure_window else None,
|
||||||
|
"est_days_p50": exposure_window.p50_days if exposure_window else None,
|
||||||
|
"est_days_p75": exposure_window.p75_days if exposure_window else None,
|
||||||
|
"est_days_n": exposure_window.n if exposure_window else None,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
**_estimate_consent_persist_fields(require_consent, client_ip),
|
**_estimate_consent_persist_fields(require_consent, client_ip),
|
||||||
|
|
@ -5573,10 +5726,8 @@ async def estimate_quality(
|
||||||
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
||||||
# None только если нет аналогов (median=0, insufficient_data=True).
|
# None только если нет аналогов (median=0, insufficient_data=True).
|
||||||
if median_price > 0:
|
if median_price > 0:
|
||||||
if anchor_tier == "A":
|
if anchor_tier in ANCHOR_API_TIERS:
|
||||||
api_analog_tier: str | None = "same_building"
|
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
|
||||||
elif anchor_tier == "C":
|
|
||||||
api_analog_tier = "micro_radius"
|
|
||||||
elif analog_tier == "W":
|
elif analog_tier == "W":
|
||||||
api_analog_tier = "city"
|
api_analog_tier = "city"
|
||||||
else:
|
else:
|
||||||
|
|
@ -5641,7 +5792,6 @@ async def estimate_quality(
|
||||||
confidence,
|
confidence,
|
||||||
range_low,
|
range_low,
|
||||||
range_high,
|
range_high,
|
||||||
settings,
|
|
||||||
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
||||||
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
||||||
asking_ppm2=int(median_ppm2),
|
asking_ppm2=int(median_ppm2),
|
||||||
|
|
@ -5670,7 +5820,8 @@ async def estimate_quality(
|
||||||
data_freshness_minutes=freshness_min,
|
data_freshness_minutes=freshness_min,
|
||||||
last_scraped_at=last_scraped_at,
|
last_scraped_at=last_scraped_at,
|
||||||
price_trend=price_trend,
|
price_trend=price_trend,
|
||||||
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
|
exposure_window=exposure_window,
|
||||||
|
est_days_on_market=exposure_window.p50_days if exposure_window else None,
|
||||||
cian_valuation=(
|
cian_valuation=(
|
||||||
CianValuationSummary(
|
CianValuationSummary(
|
||||||
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
||||||
|
|
@ -5781,8 +5932,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
|
||||||
None) — premium_building флаг не выставится, даже если дом объективно элитный по
|
None) — premium_building флаг не выставится, даже если дом объективно элитный по
|
||||||
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
|
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
|
||||||
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
|
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
|
||||||
`estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и
|
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price ≥ порога) и
|
||||||
`estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от
|
`MANUAL_REVIEW_ELITE_PPM2` (asking ₽/м² ≥ порога) — независимо от
|
||||||
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
|
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
|
||||||
почти всегда подхватывается value-based триггером той же функции.
|
почти всегда подхватывается value-based триггером той же функции.
|
||||||
"""
|
"""
|
||||||
|
|
@ -5819,7 +5970,6 @@ def _manual_review(
|
||||||
confidence: str,
|
confidence: str,
|
||||||
range_low: int | None,
|
range_low: int | None,
|
||||||
range_high: int | None,
|
range_high: int | None,
|
||||||
settings: Settings,
|
|
||||||
asking_ppm2: int | None = None,
|
asking_ppm2: int | None = None,
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
||||||
|
|
@ -5849,15 +5999,13 @@ def _manual_review(
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if premium_building:
|
if premium_building:
|
||||||
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
||||||
high_value = settings.estimate_manual_review_high_value_rub
|
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
|
||||||
if expected_sold_price and expected_sold_price >= high_value:
|
|
||||||
reasons.append("высокая стоимость (≥20 млн ₽)")
|
reasons.append("высокая стоимость (≥20 млн ₽)")
|
||||||
if confidence == "low":
|
if confidence == "low":
|
||||||
reasons.append("низкая уверенность оценки")
|
reasons.append("низкая уверенность оценки")
|
||||||
wide_ratio = settings.estimate_manual_review_wide_range_ratio
|
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
|
||||||
if range_low and range_high and range_high / range_low >= wide_ratio:
|
|
||||||
reasons.append("широкий диапазон цены")
|
reasons.append("широкий диапазон цены")
|
||||||
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
|
if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
||||||
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
||||||
|
|
@ -5865,23 +6013,88 @@ def _manual_review(
|
||||||
return (bool(reasons), reasons)
|
return (bool(reasons), reasons)
|
||||||
|
|
||||||
|
|
||||||
def _estimate_days_on_market(
|
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был
|
||||||
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
|
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок
|
||||||
) -> int | None:
|
# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция
|
||||||
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
|
# снятых объявлений — house_placement_history.exposure_days.
|
||||||
|
#
|
||||||
|
# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка
|
||||||
|
# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%.
|
||||||
|
# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109).
|
||||||
|
EXPOSURE_WINDOW_MIN_N = 30
|
||||||
|
EXPOSURE_WINDOW_MONTHS = 24
|
||||||
|
|
||||||
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
|
|
||||||
(наши парсеры не всегда его отдают — честно показываем «нет данных»).
|
def _exposure_window(days: list[int]) -> ExposureWindow | None:
|
||||||
|
"""Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N.
|
||||||
|
|
||||||
|
method="inclusive" — та же линейная интерполяция, что percentile_cont в
|
||||||
|
sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу.
|
||||||
"""
|
"""
|
||||||
values = [
|
if len(days) < EXPOSURE_WINDOW_MIN_N:
|
||||||
int(lot["days_on_market"])
|
|
||||||
for lot in (*listings, *deals)
|
|
||||||
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
|
|
||||||
]
|
|
||||||
if len(values) < 3:
|
|
||||||
return None
|
return None
|
||||||
values.sort()
|
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive")
|
||||||
return values[len(values) // 2]
|
return ExposureWindow(
|
||||||
|
p25_days=int(p25 + 0.5),
|
||||||
|
p50_days=int(p50 + 0.5),
|
||||||
|
p75_days=int(p75 + 0.5),
|
||||||
|
n=len(days),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_exposure_days(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
lat: float | None,
|
||||||
|
lon: float | None,
|
||||||
|
rooms: int,
|
||||||
|
area: float,
|
||||||
|
radius_m: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE
|
||||||
|
в домах в радиусе подбора аналогов. Best-effort: ошибка → [] (окна нет).
|
||||||
|
|
||||||
|
ponytail: без дедупа avito_imv/yandex_valuation — прод 17.09: у 926 из 9482
|
||||||
|
yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет.
|
||||||
|
"""
|
||||||
|
if lat is None or lon is None or not area:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
# SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию,
|
||||||
|
# в которой следом идёт INSERT оценки.
|
||||||
|
with db.begin_nested():
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT h.exposure_days
|
||||||
|
FROM house_placement_history h
|
||||||
|
JOIN houses ho ON ho.id = h.house_id
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||||
|
)
|
||||||
|
AND h.rooms = :rooms
|
||||||
|
AND h.area_m2 BETWEEN :area_min AND :area_max
|
||||||
|
AND h.exposure_days IS NOT NULL
|
||||||
|
AND h.removed_date IS NOT NULL
|
||||||
|
AND h.removed_date <= CURRENT_DATE
|
||||||
|
AND h.removed_date > CURRENT_DATE
|
||||||
|
- make_interval(months => CAST(:months AS integer))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"radius": radius_m,
|
||||||
|
"rooms": rooms,
|
||||||
|
"area_min": area * (1 - AREA_TOLERANCE),
|
||||||
|
"area_max": area * (1 + AREA_TOLERANCE),
|
||||||
|
"months": EXPOSURE_WINDOW_MONTHS,
|
||||||
|
},
|
||||||
|
).all()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("exposure_window lookup failed (graceful): %s", exc)
|
||||||
|
return []
|
||||||
|
return [int(r[0]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
||||||
|
|
@ -6551,8 +6764,9 @@ def _fetch_analogs(
|
||||||
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
||||||
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
||||||
|
|
||||||
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
|
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
|
||||||
Если ≥5 результатов → возвращаем; tier='H'.
|
±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5)
|
||||||
|
результатов → возвращаем; tier='H'.
|
||||||
Пропускается если year_built или total_floors неизвестны.
|
Пропускается если year_built или total_floors неизвестны.
|
||||||
|
|
||||||
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
||||||
|
|
@ -6747,12 +6961,12 @@ def _fetch_analogs(
|
||||||
)
|
)
|
||||||
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
||||||
|
|
||||||
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
|
# ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─
|
||||||
if year_built is not None and total_floors is not None:
|
if year_built is not None and total_floors is not None:
|
||||||
year_min = year_built - 15
|
year_min = year_built - 15
|
||||||
year_max = year_built + 15
|
year_max = year_built + 15
|
||||||
tf_min = math.floor(total_floors * 0.7)
|
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
|
||||||
tf_max = math.ceil(total_floors * 1.3)
|
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
|
||||||
|
|
||||||
tier_h_rows = (
|
tier_h_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -6860,7 +7074,7 @@ def _fetch_analogs(
|
||||||
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
||||||
)
|
)
|
||||||
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
||||||
if len(tier_h) >= 5:
|
if len(tier_h) >= settings.estimate_tier_h_min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
||||||
year_built,
|
year_built,
|
||||||
|
|
@ -7328,15 +7542,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
|
||||||
)
|
)
|
||||||
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
||||||
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
||||||
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
|
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
|
||||||
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
|
||||||
|
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
|
||||||
|
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
|
||||||
|
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
||||||
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
||||||
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
|
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
|
||||||
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
|
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
|
||||||
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
|
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
|
||||||
_DEDUP_STREET_NAME_RE = re.compile(
|
_DEDUP_STREET_NAME_RE = re.compile(
|
||||||
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
|
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
|
||||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
|
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
|
||||||
|
|
@ -7348,10 +7565,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
|
||||||
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
|
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
|
||||||
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
|
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
|
||||||
r"(?:^|[,\s])"
|
r"(?:^|[,\s])"
|
||||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
||||||
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
|
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица
|
||||||
|
# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в
|
||||||
|
# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков»
|
||||||
|
# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков»
|
||||||
|
# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома.
|
||||||
|
_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile(
|
||||||
|
rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
|
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
|
||||||
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
|
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
|
||||||
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
|
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
|
||||||
|
|
@ -7537,6 +7763,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
||||||
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
||||||
# «Минометчиков» должны схлопываться в один канонический токен.
|
# «Минометчиков» должны схлопываться в один канонический токен.
|
||||||
s = s.replace("ё", "е")
|
s = s.replace("ё", "е")
|
||||||
|
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
|
||||||
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
||||||
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
||||||
m = _DEDUP_STREET_NAME_RE.search(s)
|
m = _DEDUP_STREET_NAME_RE.search(s)
|
||||||
|
|
@ -7655,8 +7882,7 @@ def _union_find_phys_dedup(
|
||||||
не дедупятся — остаются уникальными.
|
не дедупятся — остаются уникальными.
|
||||||
|
|
||||||
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
||||||
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
|
вызывающий — _dedup_cross_source). include_price=False — display-only ключ без
|
||||||
_dedup_cross_source). include_price=False — display-only ключ без
|
|
||||||
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
||||||
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
||||||
пары для merge (require_diff_source) — без цены единственный оставшийся
|
пары для merge (require_diff_source) — без цены единственный оставшийся
|
||||||
|
|
@ -7777,13 +8003,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
||||||
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
||||||
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
||||||
из выборки — по нему нет независимой ценовой точки). Флаг
|
из выборки — по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
|
||||||
estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно
|
снят (#2378) — бэктест #1966 OFF vs ON был
|
||||||
для frozen backtest regression gate). Ключ включает price_bucket
|
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%), дедуп меняет
|
||||||
|
только честность счёта n_analogs. Ключ включает price_bucket
|
||||||
(_lot_dedup_components include_price=True по умолчанию) — см.
|
(_lot_dedup_components include_price=True по умолчанию) — см.
|
||||||
_union_find_phys_dedup.
|
_union_find_phys_dedup.
|
||||||
"""
|
"""
|
||||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
if len(lots) < 2:
|
||||||
return lots
|
return lots
|
||||||
return _union_find_phys_dedup(lots, include_price=True)
|
return _union_find_phys_dedup(lots, include_price=True)
|
||||||
|
|
||||||
|
|
@ -7829,9 +8056,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
||||||
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
||||||
|
|
||||||
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
|
Независим от _dedup_cross_source: тот управляет статистикой
|
||||||
статистикой (median/n_analogs), этот — только честностью UI-карточек и
|
(median/n_analogs), этот — только честностью UI-карточек. НЕ трогает
|
||||||
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
|
|
||||||
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
||||||
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
||||||
"""
|
"""
|
||||||
|
|
@ -7843,10 +8069,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
||||||
|
|
||||||
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
|
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
|
||||||
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных
|
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных
|
||||||
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
||||||
тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение).
|
тянут радиусную медиану вверх.
|
||||||
"""
|
"""
|
||||||
if len(lots) < 5:
|
if len(lots) < 5:
|
||||||
return lots # на маленькой выборке нечего фильтровать
|
return lots # на маленькой выборке нечего фильтровать
|
||||||
|
|
@ -7856,11 +8082,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
tukey_k = 1.5
|
tukey_k = 1.5
|
||||||
if (
|
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
|
||||||
settings.estimate_outlier_tukey_k_small < 1.5
|
tukey_k = OUTLIER_TUKEY_K_SMALL
|
||||||
and len(prices) < settings.estimate_outlier_small_n_threshold
|
|
||||||
):
|
|
||||||
tukey_k = settings.estimate_outlier_tukey_k_small
|
|
||||||
|
|
||||||
q1 = _percentile(prices, 0.25)
|
q1 = _percentile(prices, 0.25)
|
||||||
q3 = _percentile(prices, 0.75)
|
q3 = _percentile(prices, 0.75)
|
||||||
|
|
|
||||||
|
|
@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
|
||||||
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
|
||||||
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
|
# пропущенный дом лучше неверно приписанного). Предикат `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(
|
_MATCH_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH gar_scope AS (
|
WITH gar_scope AS (
|
||||||
SELECT
|
SELECT
|
||||||
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
|
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
|
||||||
FROM gar_house_flats
|
FROM gar_house_flats
|
||||||
WHERE flat_count > 0
|
WHERE flat_count > 0
|
||||||
AND tradein_canon_addr(norm_address) <> ''
|
AND tradein_canon_addr(norm_address) <> ''
|
||||||
|
|
@ -622,15 +628,15 @@ _MATCH_SQL = text(
|
||||||
),
|
),
|
||||||
gar_ranked AS (
|
gar_ranked AS (
|
||||||
SELECT
|
SELECT
|
||||||
canon, house_guid, flat_count,
|
canon, house_guid, flat_count, region_code,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY canon ORDER BY flat_count DESC, house_guid
|
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
|
||||||
) AS rn,
|
) AS rn,
|
||||||
COUNT(*) OVER (PARTITION BY canon) AS canon_hits
|
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
|
||||||
FROM gar_scope
|
FROM gar_scope
|
||||||
),
|
),
|
||||||
gar_pick AS (
|
gar_pick AS (
|
||||||
SELECT canon, house_guid, flat_count
|
SELECT canon, house_guid, flat_count, region_code
|
||||||
FROM gar_ranked
|
FROM gar_ranked
|
||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
|
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
|
||||||
|
|
@ -644,10 +650,33 @@ _MATCH_SQL = text(
|
||||||
WHERE tradein_canon_addr(
|
WHERE tradein_canon_addr(
|
||||||
COALESCE(h.short_address, h.full_address, h.address)
|
COALESCE(h.short_address, h.full_address, h.address)
|
||||||
) = gp.canon
|
) = gp.canon
|
||||||
|
AND CAST(h.region_code AS text) = gp.region_code
|
||||||
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
|
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(
|
def match_houses_to_gar(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -666,11 +695,14 @@ def match_houses_to_gar(
|
||||||
"""
|
"""
|
||||||
if isinstance(city_filter, CityFilterAutoType):
|
if isinstance(city_filter, CityFilterAutoType):
|
||||||
city_filter = default_city_filter_for_region(region_code)
|
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})
|
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
|
||||||
matched = result.rowcount
|
matched = result.rowcount
|
||||||
logger.info(
|
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,
|
matched,
|
||||||
|
cleared,
|
||||||
region_code,
|
region_code,
|
||||||
city_filter,
|
city_filter,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
|
||||||
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
|
Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские
|
||||||
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
|
гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город,
|
||||||
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
|
отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый
|
||||||
(`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не
|
(`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом
|
||||||
резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и
|
случае добавляется СЮДА, и гейт хинта не создаёт новой связности.
|
||||||
гейт хинта не создаёт новой связности.
|
(`estimator._resolve_target_city` дополнительно знает города развёрток,
|
||||||
|
#2583 H1 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||||
|
|
||||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||||
|
|
|
||||||
|
|
@ -25,30 +25,31 @@ WHAT this is:
|
||||||
«улица Вайнера, 66» share one cluster_key. Rows whose canon is NULL/blank are never clustered
|
«улица Вайнера, 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.
|
(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. «Ленина
|
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
|
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-canon houses
|
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
|
>250 m away — or with NULL geom on either side — are left as separate rows (conservative).
|
||||||
FIAS pass deliberately SKIPS this guard: a shared ФИАС/ГАР UUID IS the building identity and
|
The FIAS pass used to skip this guard (#2187, «a shared ФИАС UUID IS the building identity»).
|
||||||
strictly outranks geo-proximity, so same-fias rows merge even with NULL geom on a side or
|
That premise is false: house_fias_id is DaData's answer to OUR address string (KEY section
|
||||||
>250 m apart (the geom-first keeper rule simultaneously repairs the broken coordinate).
|
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
|
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
|
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
|
||||||
(`_mapping_sql`) — no 300-line copy-paste:
|
(`_mapping_sql`) — no 300-line copy-paste:
|
||||||
1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
|
1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
|
||||||
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
|
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
|
||||||
cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
|
cluster. Catches duplicates the canon MISSES: посёлок truncation (Кедровка/Шувакиш
|
||||||
«Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
|
colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse
|
||||||
colliding with a same-named ЕКБ street).
|
(«Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»).
|
||||||
2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
|
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
|
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
|
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
|
slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to
|
||||||
CANON pass ONLY (#2187): the FIAS pass merges on UUID identity regardless of geom — a
|
BOTH passes (#2690; the fias-pass exemption of #2187 is reverted — see GEO GUARD).
|
||||||
боевой прогон 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).
|
|
||||||
|
|
||||||
IDENTITY CARRY-OVER (each pass, BEFORE deleting losers): the keeper's NULL identity / geo-QC
|
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
|
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
|
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).
|
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
|
distance_m is recorded on BOTH passes. It is what exposed the fias pass merging without a
|
||||||
asymmetry — merge allowed without a proximity check — was invisible in data before; now
|
proximity check (#2690, 2026-09-05): «how many merges happened beyond N metres, on which key»
|
||||||
«how many merges happened beyond N metres, on which key» is one query.
|
is one query.
|
||||||
|
|
||||||
KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
|
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
|
#2690 asked for a cluster key that does not come from the normalized address, so that two
|
||||||
|
|
@ -224,8 +225,8 @@ _KEEPER_ORDER = f"""
|
||||||
# cluster key differs, parametrised into `_mapping_sql`, so there is NO copy-paste of the pipeline.
|
# cluster key differs, parametrised into `_mapping_sql`, so there is NO copy-paste of the pipeline.
|
||||||
#
|
#
|
||||||
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
|
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
|
||||||
# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
|
# duplicates the canon misses — посёлок truncation (Кедровка/Шувакиш) and, until migration 322
|
||||||
# truncation (Кедровка/Шувакиш). Only non-null fias clusters.
|
# (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters.
|
||||||
_FIAS_KEY_EXPR = """
|
_FIAS_KEY_EXPR = """
|
||||||
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
|
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
|
||||||
THEN 'fias:' || lower(house_fias_id)
|
THEN 'fias:' || lower(house_fias_id)
|
||||||
|
|
@ -311,35 +312,15 @@ def _ranked_cte(cluster_key_case: str) -> str:
|
||||||
)"""
|
)"""
|
||||||
|
|
||||||
|
|
||||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
def _mapping_sql(cluster_key_case: str) -> str:
|
||||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
"""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
|
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
|
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).
|
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.
|
||||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
|
||||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
|
||||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
|
||||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
|
||||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
|
||||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
|
||||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
`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"""
|
return f"""
|
||||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||||
{_ranked_cte(cluster_key_case)}
|
{_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 / 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.
|
-- 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
|
-- distance_m is what showed the fias pass merging 362 km apart while it had no guard (#2690).
|
||||||
-- the case where nothing else records the distance, and #2690 had no way to ask
|
|
||||||
-- «how many merges happened at distances the guard would have blocked» from data.
|
|
||||||
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
|
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
|
||||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||||
END AS distance_m
|
END AS distance_m
|
||||||
FROM ranked
|
FROM ranked
|
||||||
WHERE rn > 1
|
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 (
|
AND NOT (
|
||||||
NULLIF(loser_fias, '') IS NOT NULL
|
NULLIF(loser_fias, '') IS NOT NULL
|
||||||
AND NULLIF(keeper_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))
|
_BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
|
||||||
# Fias-pass mapping — same pipeline, clustered by the ФИАС building UUID (runs first). Geo guard
|
# Fias-pass mapping — same pipeline and same guards, clustered by the ФИАС UUID (runs first).
|
||||||
# OFF (#2187): a shared ГАР UUID IS the building identity and outranks proximity — same-fias rows
|
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR))
|
||||||
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
|
|
||||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
|
|
||||||
|
|
||||||
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|
@ -870,7 +855,6 @@ def _run_merge_pass(
|
||||||
*,
|
*,
|
||||||
build_sql: Any,
|
build_sql: Any,
|
||||||
pass_label: str,
|
pass_label: str,
|
||||||
geo_guard: bool,
|
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
run_id: int | None,
|
run_id: int | None,
|
||||||
initiator: str,
|
initiator: str,
|
||||||
|
|
@ -911,7 +895,7 @@ def _run_merge_pass(
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"initiator": initiator,
|
"initiator": initiator,
|
||||||
"merge_pass": pass_label,
|
"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:
|
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 /
|
1. FIAS pass — cluster by lower(NULLIF(house_fias_id, '')) (catches slash-collapse /
|
||||||
посёлок canon bugs the address canon misses).
|
посёлок canon bugs the address canon misses).
|
||||||
2. CANON pass — cluster by canonical address (250 m geo guard + cross-fias anti-over-merge
|
2. CANON pass — cluster by canonical address.
|
||||||
guard).
|
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
|
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.
|
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
|
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,
|
db,
|
||||||
build_sql=_BUILD_MAPPING_SQL_FIAS,
|
build_sql=_BUILD_MAPPING_SQL_FIAS,
|
||||||
pass_label="fias",
|
pass_label="fias",
|
||||||
geo_guard=False,
|
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
initiator=initiator,
|
initiator=initiator,
|
||||||
|
|
@ -1068,7 +1051,6 @@ def merge_duplicate_houses(
|
||||||
db,
|
db,
|
||||||
build_sql=_BUILD_MAPPING_SQL,
|
build_sql=_BUILD_MAPPING_SQL,
|
||||||
pass_label="canon",
|
pass_label="canon",
|
||||||
geo_guard=True,
|
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
initiator=initiator,
|
initiator=initiator,
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,23 @@ async def _job_landing_showcase_deals(
|
||||||
counters = await loop.run_in_executor(
|
counters = await loop.run_in_executor(
|
||||||
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
|
||||||
)
|
)
|
||||||
ctx.runs.mark_done(db, run_id, counters)
|
# #3511: пустая витрина — не успех. Результатного ключа кита
|
||||||
|
# (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных
|
||||||
|
# судит её по статусу, и `done` с written=0 гасил часы свежести так же,
|
||||||
|
# как удачный прогон, — а страница тем временем теряла таблицу целиком.
|
||||||
|
if not counters.get("written"):
|
||||||
|
logger.error(
|
||||||
|
"landing_showcase_deals run_id=%d: витрина пуста (written=0, "
|
||||||
|
"considered=%s, eligible=%s) — прогон неуспешен",
|
||||||
|
run_id,
|
||||||
|
counters.get("considered"),
|
||||||
|
counters.get("eligible"),
|
||||||
|
)
|
||||||
|
ctx.runs.mark_failed(
|
||||||
|
db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
|
||||||
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
|
||||||
|
|
@ -528,6 +544,9 @@ async def _job_domclick_city_sweep(
|
||||||
region_code=kit_resolve_region_code(params),
|
region_code=kit_resolve_region_code(params),
|
||||||
resume_run_id=kit_pick_resume(db, run_id),
|
resume_run_id=kit_pick_resume(db, run_id),
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
|
watchdog_sec=(
|
||||||
|
int(params["watchdog_sec"]) if params.get("watchdog_sec") is not None else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ reap_stale_leases) — она рассчитана на долгоживущие
|
||||||
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
||||||
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
||||||
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
||||||
и без мутаций.
|
и без мутаций пула. Единственная запись — атрибуция прогона (`scrape_runs.proxy_id`, #3404),
|
||||||
|
своей короткой сессией, см. `resolve_proxy_url`.
|
||||||
|
|
||||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||||
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
|
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
|
||||||
|
|
@ -69,7 +70,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings as _settings
|
from app.core.config import settings as _settings
|
||||||
from app.core.db import SessionLocal as _SessionLocal
|
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__)
|
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),
|
_safe_label(candidate.id, candidate.label, candidate.url),
|
||||||
candidate.ban_count,
|
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
|
return candidate.url
|
||||||
|
|
||||||
diag = _diagnose_no_candidate(db, source)
|
diag = _diagnose_no_candidate(db, source)
|
||||||
|
|
|
||||||
|
|
@ -275,12 +275,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||||
чужой affinity (#2600).
|
чужой affinity (#2600).
|
||||||
|
|
||||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
|
Fallback НЕ трогает последний пригодный узел выделенной (не-'any') affinity: если
|
||||||
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
||||||
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
||||||
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
|
участвует в fallback, только если его affinity='any' ИЛИ у источника этой affinity
|
||||||
enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
|
без него останется ДРУГОЙ кандидат (EXISTS-подзапрос): узел той же affinity или
|
||||||
affinity целиком.
|
'any', здоровый, с живой арендой порта и не забаненный этим источником (#3299 —
|
||||||
|
до него 'any'-узлы не считались, и единственный выделенный узел не выдавался никому).
|
||||||
|
|
||||||
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
||||||
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
||||||
|
|
@ -401,17 +402,29 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
-- fallback увести последний реально рабочий узел выделенной
|
-- fallback увести последний реально рабочий узел выделенной
|
||||||
-- affinity и обрушить её (два domclick-узла, один забанен
|
-- affinity и обрушить её (два domclick-узла, один забанен
|
||||||
-- domclick'ом → второй уходит под avito → domclick без прокси).
|
-- domclick'ом → второй уходит под avito → domclick без прокси).
|
||||||
|
--
|
||||||
|
-- #3299: вопрос «останется ли у источника sp.provider_affinity
|
||||||
|
-- хоть один кандидат без sp», а не «есть ли ВТОРОЙ узел той же
|
||||||
|
-- привязки». 'any'-узлы основной запрос выдаёт выделенному
|
||||||
|
-- источнику наравне, значит и backup'ом они считаются; прежний
|
||||||
|
-- `= sp.provider_affinity` прятал единственный выделенный узел от
|
||||||
|
-- всех. Здоровье и срок аренды — как в основном запросе.
|
||||||
|
-- leased_by НЕ проверяем: аренда вернётся через минуты, а
|
||||||
|
-- отказ из-за чужой аренды прятал бы узел при любом параллельном
|
||||||
|
-- прогоне (тот же довод, что у защиты в mark_banned).
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM scrape_proxies AS other
|
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.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 other.id <> sp.id
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM scrape_proxy_source_bans b2
|
FROM scrape_proxy_source_bans b2
|
||||||
WHERE b2.proxy_id = other.id
|
WHERE b2.proxy_id = other.id
|
||||||
AND b2.source = other.provider_affinity
|
AND b2.source = sp.provider_affinity
|
||||||
AND b2.banned_until > now()
|
AND b2.banned_until > now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -446,9 +459,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
|
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
|
||||||
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
|
# #3404: покрывает все пути АРЕНДЫ (curl — acquire на каждый вызов, браузер —
|
||||||
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
|
# sticky lease на весь прогон, ре-acquire при ротации узла mid-run). Путь без
|
||||||
# mid-run) — см. attribute_run_proxy docstring.
|
# аренды (proxy_egress.resolve_proxy_url) пишет атрибуцию сам — см. docstring.
|
||||||
attribute_run_proxy(db, run_id, proxy_id)
|
attribute_run_proxy(db, run_id, proxy_id)
|
||||||
if fallback_used:
|
if fallback_used:
|
||||||
logger.warning(
|
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:
|
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
|
||||||
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
|
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
|
||||||
|
|
||||||
Единственный писатель — `acquire()` сразу после выдачи lease'а: покрывает и
|
Два писателя. `acquire()` сразу после выдачи lease'а: покрывает и curl-путь
|
||||||
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
|
(acquire на каждый вызов), и браузерный sticky lease (один acquire на весь прогон),
|
||||||
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
|
и ре-acquire при ротации узла mid-run (`browser_fetcher`,
|
||||||
`_LEASE_ROTATE_AFTER_FAILS`) — то есть смена узла ЗА прогон фиксируется сама,
|
`_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` — ПОСЛЕДНИЙ использованный узел (перезаписывается при
|
`scrape_runs.proxy_id` — ПОСЛЕДНИЙ использованный узел (перезаписывается при
|
||||||
каждой новой выдаче); полная цепочка узлов, если она менялась, — в
|
каждой новой выдаче); полная цепочка узлов, если она менялась, — в
|
||||||
|
|
@ -535,8 +549,7 @@ def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
|
||||||
db.commit()
|
db.commit()
|
||||||
if row is None:
|
if row is None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
|
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already finalized?)",
|
||||||
"finalized?)",
|
|
||||||
run_id,
|
run_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
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)
|
WHERE sp.id <> CAST(:proxy_id AS bigint)
|
||||||
AND sp.enabled
|
AND sp.enabled
|
||||||
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM scrape_proxy_source_bans b
|
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'ом
|
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
|
||||||
-- не считается (иначе защита сочла бы affinity живой, когда она
|
-- не считается (иначе защита сочла бы affinity живой, когда она
|
||||||
-- уже нет).
|
-- уже нет).
|
||||||
|
-- #3299: предикат backup'а — буква в букву как в fallback
|
||||||
|
-- acquire() (там же обоснование): 'any'-узлы в счёт, здоровье
|
||||||
|
-- и срок аренды проверяются.
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM scrape_proxies other
|
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.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 other.id <> sp.id
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM scrape_proxy_source_bans b2
|
FROM scrape_proxy_source_bans b2
|
||||||
WHERE b2.proxy_id = other.id
|
WHERE b2.proxy_id = other.id
|
||||||
AND b2.source = other.provider_affinity
|
AND b2.source = sp.provider_affinity
|
||||||
AND b2.banned_until > now()
|
AND b2.banned_until > now()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Idempotent-логика (на каждый yandex-lot):
|
||||||
- история ПУСТА:
|
- история ПУСТА:
|
||||||
* price_previous задан И != price_rub → seed previous-точку (change_time = now-1d,
|
* price_previous задан И != price_rub → seed previous-точку (change_time = now-1d,
|
||||||
timestamp синтетический — gate реального времени смены не несёт), затем current-точку;
|
timestamp синтетический — gate реального времени смены не несёт), затем current-точку;
|
||||||
|
НО previous, отличающаяся от текущей цены на порядок и больше, не сеется (#3385);
|
||||||
* иначе → только current-точку (now).
|
* иначе → только current-точку (now).
|
||||||
- история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену.
|
- история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену.
|
||||||
- история ЕСТЬ и latest_price == price_rub → skip (цена не менялась).
|
- история ЕСТЬ и latest_price == price_rub → skip (цена не менялась).
|
||||||
|
|
@ -41,6 +42,14 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SOURCE = "yandex"
|
_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.
|
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
|
||||||
_RESOLVE_SQL = text(
|
_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:
|
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend.
|
"""Дописать 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
|
inserted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
seed_rejected = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
|
|
@ -140,20 +156,29 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
{"listing_id": listing_id},
|
{"listing_id": listing_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
|
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история
|
||||||
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
|
# есть» на проде не записала ни одной строки (из 28 558 строк этого
|
||||||
# к предыдущей точке и подтверждения у следующей. На этом пути серия
|
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер
|
||||||
# максимум из двух точек (последняя лежащая в БД + текущая), у
|
# record_listing_price_change (save_listings идёт первым), и сюда
|
||||||
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
|
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории
|
||||||
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
|
# — пары «посев из price.previous → текущая цена», ошибочна нижняя.
|
||||||
# То есть проводка была бы декорацией: ветка, которая по построению
|
|
||||||
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
|
|
||||||
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
|
|
||||||
# DELETE уже вставленной строки. Отдельная задача: #3385.
|
|
||||||
if latest is None:
|
if latest is None:
|
||||||
# История пуста — seed (+ опционально previous-точка).
|
# История пуста — seed (+ опционально previous-точка).
|
||||||
prev = lot.price_previous_rub
|
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(
|
_insert_point(
|
||||||
db,
|
db,
|
||||||
listing_id=listing_id,
|
listing_id=listing_id,
|
||||||
|
|
@ -191,10 +216,11 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
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),
|
len(lots),
|
||||||
inserted,
|
inserted,
|
||||||
skipped,
|
skipped,
|
||||||
|
seed_rejected,
|
||||||
errors,
|
errors,
|
||||||
)
|
)
|
||||||
return inserted
|
return inserted
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
|
||||||
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
||||||
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
||||||
"ABORT -- 1 consecutive blocks".
|
"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
|
from __future__ import annotations
|
||||||
|
|
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
||||||
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
||||||
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
||||||
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
|
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
|
||||||
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
|
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
|
||||||
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
|
# класс бага что #2322/#2310.
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
|
||||||
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
||||||
зовёт эту функцию в том режиме вовсе.
|
зовёт эту функцию в том режиме вовсе.
|
||||||
|
|
||||||
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
|
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
|
||||||
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
|
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
|
||||||
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
|
админки scrape_avito_detail_backfill в tradein-backend, — поэтому режим задаётся
|
||||||
активна. Значение true стоит только у tradein-backend, который добор не запускает.
|
дефолтом кода, а не environment'ом одного сервиса.
|
||||||
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом» —
|
|
||||||
это предсуществующее заблуждение, а не описание текущего прода.
|
|
||||||
|
|
||||||
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
||||||
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
||||||
|
|
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
|
||||||
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
||||||
_threshold (module docstring).
|
_threshold (module docstring).
|
||||||
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
||||||
default 25 (см. комментарий у чтения параметра ниже).
|
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
|
||||||
|
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
|
||||||
|
|
||||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
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),
|
window_size=int(settings.detail_backfill_block_ratio_window),
|
||||||
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
||||||
safety_min=max_consecutive_blocks,
|
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): пачка блоков в
|
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
|
||||||
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
||||||
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
||||||
|
|
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
|
||||||
# config.scraper_proxy_url=None для backconnect-gate'а
|
# config.scraper_proxy_url=None для backconnect-gate'а
|
||||||
# (elif not use_curl own-session path), теряя reconnect-
|
# (elif not use_curl own-session path), теряя reconnect-
|
||||||
# on-403 поведение legacy (settings.scraper_proxy_url
|
# on-403 поведение legacy (settings.scraper_proxy_url
|
||||||
# напрямую). Не влияет на use_curl=True (прод-дефолт) —
|
# напрямую). Не влияет на use_curl=True —
|
||||||
# там reconnect_on_block=False уже гасит backconnect.
|
# там reconnect_on_block=False уже гасит backconnect.
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
origin=serp_origin,
|
origin=serp_origin,
|
||||||
|
|
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
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 -> сколько раз встретилась) --
|
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
|
||||||
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
||||||
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
||||||
|
|
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
|
||||||
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
||||||
if abort_reason is not None:
|
if abort_reason is not None:
|
||||||
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
||||||
|
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
|
||||||
|
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
|
||||||
|
# утверждать про площадку то, чего не было.
|
||||||
|
stop_reason: str | None = None
|
||||||
if no_proxy_stop:
|
if no_proxy_stop:
|
||||||
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
|
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
|
||||||
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
|
|
||||||
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
|
||||||
current_counters["no_proxy_stop"] = 1
|
current_counters["no_proxy_stop"] = 1
|
||||||
runs_mod.mark_failed(
|
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
|
||||||
db,
|
elif abort_reason == "infra_streak":
|
||||||
run_id,
|
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
|
||||||
"пул прокси пуст — к площадке не ходили (#3288)",
|
stop_reason = (
|
||||||
current_counters,
|
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(
|
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",
|
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
|
stop_reason,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
counters.enriched,
|
counters.enriched,
|
||||||
counters.blocked,
|
counters.blocked,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Daily per-source snapshot writer (#570).
|
"""Daily per-source snapshot writer (#570).
|
||||||
|
|
||||||
Берёт текущее состояние listing_sources (последний снимок на canonical listing × source)
|
Берёт текущее состояние 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 цены копится СРАЗУ, независимо от
|
listing_source_events. Так история per-source цены копится СРАЗУ, независимо от
|
||||||
(сейчас DORMANT) скраперов — см. шапку data/sql/079_listing_source_history.sql.
|
(сейчас 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))
|
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
|
||||||
|
|
||||||
|
|
||||||
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
# ── Snapshot upsert: строка только на изменение (#2993) ───────────────────────
|
||||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
# Снимок на (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 в пределах окна свежести на момент снимка.
|
# is_active derived: last_seen_at в пределах окна свежести на момент снимка.
|
||||||
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
|
# payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param).
|
||||||
# run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint).
|
# 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
|
last_seen_at, payload_hash, observed_at, run_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
cur.id,
|
||||||
CURRENT_DATE,
|
CURRENT_DATE,
|
||||||
price_rub,
|
cur.price_rub,
|
||||||
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
cur.is_active,
|
||||||
last_seen_at,
|
cur.last_seen_at,
|
||||||
md5(raw_payload::text),
|
cur.payload_hash,
|
||||||
now(),
|
now(),
|
||||||
CAST(:run_id AS bigint)
|
CAST(:run_id AS bigint)
|
||||||
FROM listing_sources
|
FROM (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
price_rub,
|
||||||
|
(last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
|
||||||
|
last_seen_at,
|
||||||
|
md5(raw_payload::text) AS payload_hash
|
||||||
|
FROM listing_sources
|
||||||
|
) cur
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT s.snapshot_date, s.price_rub, s.is_active, s.last_seen_at, s.payload_hash
|
||||||
|
FROM listing_source_snapshots s
|
||||||
|
WHERE s.listing_source_id = cur.id
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) p ON true
|
||||||
|
WHERE p.snapshot_date IS NULL
|
||||||
|
OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash)
|
||||||
|
IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash)
|
||||||
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
|
ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET
|
||||||
price_rub = EXCLUDED.price_rub,
|
price_rub = EXCLUDED.price_rub,
|
||||||
is_active = EXCLUDED.is_active,
|
is_active = EXCLUDED.is_active,
|
||||||
|
|
@ -108,6 +146,9 @@ _SNAPSHOT_SQL = text(
|
||||||
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||||
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||||
|
# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре
|
||||||
|
# значения равны последнему снимку, а значит ни одно событие ниже сработать
|
||||||
|
# не могло бы — набор событий тот же, что при суточной копии.
|
||||||
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||||
#
|
#
|
||||||
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||||
|
|
@ -249,7 +290,8 @@ def snapshot_listing_sources(
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||||
Два set-based statement'а в одной транзакции:
|
Два 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 сегодняшнего снимка против последнего предыдущего → три события,
|
2. diff сегодняшнего снимка против последнего предыдущего → три события,
|
||||||
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
||||||
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б
|
||||||
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
|
||||||
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
incomplete ⊆ failed — обогащения нет, значит следующий снапшот
|
||||||
(detail_enriched_at IS NULL) возьмёт её снова.
|
(detail_enriched_at IS NULL) возьмёт её снова.
|
||||||
|
|
||||||
|
Вечный недогруз (#3191, 13.09–16.09): карточка, которая не догружается НИКОГДА, без
|
||||||
|
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
|
||||||
|
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
|
||||||
|
недогруз пишется на объявление (миграция 321): сутки его не берём, после
|
||||||
|
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
|
||||||
|
не двигает брейкер — это сведения о карточке, а не о площадке.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -137,6 +144,13 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
|
||||||
OFFER_ID_PATTERN = "^[0-9]+$"
|
OFFER_ID_PATTERN = "^[0-9]+$"
|
||||||
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
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
|
@dataclass
|
||||||
class YandexDetailBackfillResult:
|
class YandexDetailBackfillResult:
|
||||||
|
|
@ -161,6 +175,8 @@ class YandexDetailBackfillResult:
|
||||||
url_from_offer_id: int = 0
|
url_from_offer_id: int = 0
|
||||||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||||||
unenrichable_pending: int = 0
|
unenrichable_pending: int = 0
|
||||||
|
# Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов.
|
||||||
|
incomplete_given_up: int = 0
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, int]:
|
def to_dict(self) -> dict[str, int]:
|
||||||
|
|
@ -172,6 +188,7 @@ class YandexDetailBackfillResult:
|
||||||
"failed": self.failed,
|
"failed": self.failed,
|
||||||
"url_from_offer_id": self.url_from_offer_id,
|
"url_from_offer_id": self.url_from_offer_id,
|
||||||
"unenrichable_pending": self.unenrichable_pending,
|
"unenrichable_pending": self.unenrichable_pending,
|
||||||
|
"incomplete_given_up": self.incomplete_given_up,
|
||||||
"duration_sec": int(self.duration_sec),
|
"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)
|
WHEN source_url ~ CAST(:offer_url_pattern AS text)
|
||||||
THEN source_url
|
THEN source_url
|
||||||
ELSE {CANONICAL_URL_SQL}
|
ELSE {CANONICAL_URL_SQL}
|
||||||
END AS source_url
|
END AS source_url,
|
||||||
|
detail_incomplete_count
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE source = 'yandex'
|
WHERE source = 'yandex'
|
||||||
AND detail_enriched_at IS NULL
|
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)
|
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
|
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||||
LIMIT CAST(:batch_size AS int)
|
LIMIT CAST(:batch_size AS int)
|
||||||
"""
|
"""
|
||||||
|
|
@ -249,6 +276,8 @@ async def run_yandex_detail_backfill(
|
||||||
"batch_size": batch_size,
|
"batch_size": batch_size,
|
||||||
"offer_url_pattern": OFFER_URL_PATTERN,
|
"offer_url_pattern": OFFER_URL_PATTERN,
|
||||||
"offer_id_pattern": OFFER_ID_PATTERN,
|
"offer_id_pattern": OFFER_ID_PATTERN,
|
||||||
|
"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS,
|
||||||
|
"incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -283,6 +312,19 @@ async def run_yandex_detail_backfill(
|
||||||
).one()
|
).one()
|
||||||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||||||
counters.unenrichable_pending = int(pending.unenrichable_pending)
|
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:
|
if counters.url_from_offer_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||||||
|
|
@ -443,26 +485,59 @@ async def run_yandex_detail_backfill(
|
||||||
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
|
||||||
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
|
||||||
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
|
||||||
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
|
# IS NULL) — но не раньше чем через сутки: недогруз пишется на
|
||||||
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
|
# объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт.
|
||||||
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
|
|
||||||
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
|
|
||||||
incomplete_reason = detail_incomplete_reason(
|
incomplete_reason = detail_incomplete_reason(
|
||||||
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
|
||||||
)
|
)
|
||||||
if incomplete_reason is not None:
|
if incomplete_reason is not None:
|
||||||
counters.incomplete += 1
|
counters.incomplete += 1
|
||||||
counters.failed += 1
|
counters.failed += 1
|
||||||
consecutive_none += 1
|
# Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки
|
||||||
|
# всем). Повторный недогруз карточки, уже недогружавшейся
|
||||||
|
# раньше, — сведения о ней, а не о площадке: он брейкер не
|
||||||
|
# двигает, иначе вечные карточки, стоящие в очереди подряд,
|
||||||
|
# рвали бы прогон при каждом повторе (#3191, 16 прогонов
|
||||||
|
# attempted=5 incomplete=5). Первый недогруз двигает, как раньше.
|
||||||
|
prior_incomplete = int(row.get("detail_incomplete_count") or 0)
|
||||||
|
if prior_incomplete == 0:
|
||||||
|
consecutive_none += 1
|
||||||
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
|
||||||
consecutive_blocks = 0
|
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(
|
logger.warning(
|
||||||
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
|
||||||
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
|
"-> недогруженная карточка, отказ: %s (попытка %d/%d, "
|
||||||
|
"consecutive=%d)",
|
||||||
run_id,
|
run_id,
|
||||||
listing_id,
|
listing_id,
|
||||||
source_url,
|
source_url,
|
||||||
incomplete_reason,
|
incomplete_reason,
|
||||||
|
prior_incomplete + 1,
|
||||||
|
INCOMPLETE_MAX_ATTEMPTS,
|
||||||
consecutive_none,
|
consecutive_none,
|
||||||
)
|
)
|
||||||
if consecutive_none >= max_consecutive_blocks:
|
if consecutive_none >= max_consecutive_blocks:
|
||||||
|
|
|
||||||
|
|
@ -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,124 @@
|
||||||
|
-- 322_canon_keep_slash_in_house_number.sql
|
||||||
|
--
|
||||||
|
-- CONTEXT (#2189): канон-ключ tradein_canon_addr последним шагом оставлял только [а-я0-9],
|
||||||
|
-- и дробь в номере дома исчезала: «Сулимова, 3/2» ≡ «Сулимова, 32», «Краснолесья, 10/7» ≡
|
||||||
|
-- «107», «Машиностроителей, 4/1» ≡ «41». В ГАР это разные дома с разными house_guid
|
||||||
|
-- («ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120), а матчер ГАР↔houses
|
||||||
|
-- (gar_flats_loader._MATCH_SQL) при фильтре по городу берёт из такого ключа строку с
|
||||||
|
-- max(flat_count): так «Сулимова 32» получил 414 квартир дома 3/2. Ревизия DaData 02.07
|
||||||
|
-- подтвердила класс (23 из 25 расхождений — в пользу DaData, метка dadata_fias_revision).
|
||||||
|
--
|
||||||
|
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 311 без изменений,
|
||||||
|
-- кроме последнего шага: S6 оставляет [а-я0-9/], новый S7 убирает «/», если хотя бы с одной
|
||||||
|
-- стороны от неё не цифра. «3/2» → «3/2», «3 / 2» → «3/2» (пробелы S6 уже снял), а угловые
|
||||||
|
-- адреса «Гагарина, 27 / Первомайская, 81» и «204Г/1» дают прежний ключ.
|
||||||
|
--
|
||||||
|
-- Новый ключ — ТОЛЬКО уточнение старого: убери из нового все «/» — получишь старый. Значит,
|
||||||
|
-- два адреса с одинаковым новым ключом имели одинаковый и старый; склеить дома, которые
|
||||||
|
-- раньше не склеивались, правка не может, только развести.
|
||||||
|
--
|
||||||
|
-- Замер на проде 2026-09-17 (read-only, выражение нового тела inline в SELECT):
|
||||||
|
-- houses: ключ меняется у 2549 из 49 204; групп «один новый ключ — несколько старых» 0.
|
||||||
|
-- Распалось 47 прежних канон-кластеров — 113 пар домов, и ни одна не ближе 250 м
|
||||||
|
-- (минимум 256 м), общего ФИАС нет ни в одной, разный ФИАС у 5. Страж 250 м
|
||||||
|
-- house_dedup_merge эти пары и так не сливал, число слияний не меняется.
|
||||||
|
-- gar_house_flats: «/» между цифрами в 7237 строках с квартирами (66: 938, 77: 2711,
|
||||||
|
-- 50: 3588); групп «один новый ключ — несколько старых» 0.
|
||||||
|
-- Симуляция _MATCH_SQL по затронутым ключам (регион: сопоставлений было → стало,
|
||||||
|
-- неоднозначных ключей было → стало): 66 с фильтром ЕКБ 399 → 364, 17 → 9;
|
||||||
|
-- 77: 259 → 242, 38 → 12; 50: 400 → 448, 348 → 249. Каждая потерянная пара —
|
||||||
|
-- дробь против числа («Высоцкого, 4/2» ↔ ГАР «42», 725 квартир).
|
||||||
|
--
|
||||||
|
-- DEPENDENCIES (existing prod objects):
|
||||||
|
-- - tradein_canon_addr(text) — мигр. 144/147/311 (заменяем тело, IMMUTABLE, сигнатура та же).
|
||||||
|
-- ДЕПЛОИТЬ ПОСЛЕ 311 (PR #3558): тело включает её шаг S0b, иначе порядок файлов неважен.
|
||||||
|
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
|
||||||
|
-- (мигр. 144). Значения у 7237 строк меняются → REINDEX обязателен: Postgres не
|
||||||
|
-- перестраивает функциональный индекс при смене тела функции (урок мигр. 147).
|
||||||
|
--
|
||||||
|
-- ЧИСТКА СОПОСТАВЛЕНИЙ, СДЕЛАННЫХ СКЛЕЕННЫМ КЛЮЧОМ. Матчер (_MATCH_SQL) только проставляет
|
||||||
|
-- gar_house_guid и никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир),
|
||||||
|
-- при перематче новым ключом не найдёт пары и так и останется с чужими квартирами. Поэтому
|
||||||
|
-- здесь сбрасываются ровно те canon_addr-сопоставления, у которых ключи дома и его ГАР-строки
|
||||||
|
-- совпадают без «/», но различаются с ней, — то есть держались только на потерянной дроби.
|
||||||
|
-- Сопоставления, разошедшиеся с ключом по другим причинам (прод: 4 дома, адрес сменился после
|
||||||
|
-- матча), и методы dadata_fias/dadata_fias_revision не трогаются.
|
||||||
|
-- Замер 17.09: 47 домов (регион ГАР 66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных
|
||||||
|
-- объявлений; все 47 — дробь против числа («Удельная, 1/1» → «11», «Ландау, 5/1» → «51»).
|
||||||
|
-- Сомнительны 2 из 47: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50) — возможно,
|
||||||
|
-- тот же дом, у которого источник потерял дробь; без ГАР-числа они честнее, чем с угаданным.
|
||||||
|
-- Порог остановки 100: сбросили больше — критерий ловит не то, миграция падает целиком.
|
||||||
|
-- Перематч (новые и исправленные пары) — отдельный шаг, см. PR.
|
||||||
|
--
|
||||||
|
-- POST-DEPLOY: новые ключи houses сразу действуют в house_dedup_merge. Перематч ГАР — по желанию
|
||||||
|
-- владельца: python -m app.tasks.gar_flats_load --match-only --region 66 (и 77, 50).
|
||||||
|
--
|
||||||
|
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op; сброшенные дома
|
||||||
|
-- второй раз не находятся (gar_match_method уже NULL). Раннер деплоя гонит файл через psql
|
||||||
|
-- ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): REINDEX и UPDATE houses берут блокировки — не вставать в очередь.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
|
||||||
|
SELECT regexp_replace( -- S7: «/» остаётся только между цифрами (#2189)
|
||||||
|
regexp_replace( -- S6: оставить только [а-я0-9/]
|
||||||
|
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
|
||||||
|
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
|
||||||
|
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
|
||||||
|
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
|
||||||
|
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
|
||||||
|
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
|
||||||
|
regexp_replace( -- S1b: «б-р» → «бульвар»
|
||||||
|
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
|
||||||
|
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
|
||||||
|
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
|
||||||
|
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
|
||||||
|
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
|
||||||
|
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
|
||||||
|
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
|
||||||
|
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
|
||||||
|
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
|
||||||
|
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
|
||||||
|
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
|
||||||
|
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
|
||||||
|
'[^а-я0-9/]', '', 'g'),
|
||||||
|
'(?<![0-9])/|/(?![0-9])', '', 'g');
|
||||||
|
$func$ LANGUAGE sql IMMUTABLE;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION tradein_canon_addr(text) IS
|
||||||
|
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311 + 322): '
|
||||||
|
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
|
||||||
|
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
|
||||||
|
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
|
||||||
|
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9] '
|
||||||
|
'и «/» между цифрами («3/2» ≠ «32»).';
|
||||||
|
|
||||||
|
REINDEX INDEX gar_house_flats_canon_idx;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
reset_rows bigint;
|
||||||
|
BEGIN
|
||||||
|
UPDATE houses h
|
||||||
|
SET gar_house_guid = NULL, gar_flat_count = NULL, gar_matched_at = NULL, gar_match_method = NULL
|
||||||
|
FROM gar_house_flats g
|
||||||
|
WHERE g.house_guid = h.gar_house_guid
|
||||||
|
AND h.gar_match_method = 'canon_addr'
|
||||||
|
AND tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address))
|
||||||
|
<> tradein_canon_addr(g.norm_address)
|
||||||
|
AND replace(tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)), '/', '')
|
||||||
|
= replace(tradein_canon_addr(g.norm_address), '/', '');
|
||||||
|
GET DIAGNOSTICS reset_rows = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = %', reset_rows;
|
||||||
|
|
||||||
|
-- Исключение откатывает всю транзакцию, включая замену функции.
|
||||||
|
IF reset_rows > 100 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'houses: к сбросу % canon_addr-сопоставлений при замере 17.09.2026 = 47 — '
|
||||||
|
'критерий ловит не то, миграция остановлена', reset_rows;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
-- 326_domclick_msk_reenable_after_incremental_save.sql
|
||||||
|
-- Вернуть домклик-свипы Москвы (77) и области (50) — причина выключения устранена.
|
||||||
|
--
|
||||||
|
-- Apply after: 325_listing_source_snapshots_change_only_comment.sql
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Миграция 308 выключила эти строки: свип копил лоты в памяти и сохранял их ОДНИМ
|
||||||
|
-- save_listings после всех шести корзин, поэтому снятие по watchdog теряло всё
|
||||||
|
-- собранное, а чекпоинт при этом помечал корзины пройденными. На выдаче размером с
|
||||||
|
-- Москву (≈23 690 лотов вторички против ≈6 300 у ЕКБ) снятие было гарантировано, и
|
||||||
|
-- итоговый сбор равнялся нулю навсегда.
|
||||||
|
--
|
||||||
|
-- PR #3592 это снял: save_listings зовётся из колбэка on_bucket сразу после КАЖДОЙ
|
||||||
|
-- успешной корзины, туда же переехал чекпоинт — done_buckets теперь означает
|
||||||
|
-- «собрано И сохранено». Снятие по watchdog больше не теряет собранное, а корзина
|
||||||
|
-- без сохранённых строк в чекпоинт не попадает. Тем же колбэком добавлена
|
||||||
|
-- кооперативная отмена по корзинам (раньше is_cancelled проверялся только перед
|
||||||
|
-- SERP-фазой, и повисший свип нельзя было снять три часа).
|
||||||
|
--
|
||||||
|
-- interval_days = 1, а не 3 как было: полный проход Москвы в одно окно watchdog'а
|
||||||
|
-- по-прежнему НЕ помещается (замер run 7344: 2 корзины из 6 за два часа). Механизм
|
||||||
|
-- добора — ротация стартовой корзины (start_bucket_index = run_id % 6) плюс
|
||||||
|
-- skip_buckets из чекпоинта: каждый прогон берёт корзины, которых ещё нет в
|
||||||
|
-- done_buckets. При суточном такте шесть корзин закрываются примерно за трое суток,
|
||||||
|
-- при трёхсуточном — за девять, а корпус живёт 14 суток (LISTINGS_FRESH_DAYS).
|
||||||
|
-- Девять суток на полный оборот не оставляли бы запаса на пропуски из-за
|
||||||
|
-- QRATOR-банов.
|
||||||
|
--
|
||||||
|
-- watchdog_sec НЕ задаётся намеренно. Override в коде есть (PR #3592, читается из
|
||||||
|
-- default_params обоими хендлерами), но поднимать таймаут до замера нечем
|
||||||
|
-- обосновать: с инкрементальным сохранением ранний снос перестал быть потерей, а
|
||||||
|
-- более длинный прогон дольше держит один из ДВУХ узлов provider_affinity='any'
|
||||||
|
-- (id 13 и 14), за которые конкурирует cian. Сначала смотрим реальный выход за
|
||||||
|
-- прогон, потом решаем про таймаут.
|
||||||
|
--
|
||||||
|
-- Окна не меняются: Москва 0-3, область 9-12 (разведены миграцией 307), ЕКБ 3-6.
|
||||||
|
-- Ни одно окно не содержит двух домклик-строк — это условие, за нарушение которого
|
||||||
|
-- 17.09 ЕКБ-свип run 7339 отбился 'banned' за 0 секунд.
|
||||||
|
--
|
||||||
|
-- Чекпоинт прогонов 7333/7344 сбрасывать не нужно: у обоих buckets_completed пуст,
|
||||||
|
-- они были задрейнены деплоем ещё до первой завершённой корзины.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ: UPDATE ... WHERE source IN (...) — повторный прогон пишет те же
|
||||||
|
-- значения. next_run_at не трогаем: планировщик посчитает его сам по окну, а явная
|
||||||
|
-- простановка здесь разъехалась бы с реальным временем применения миграции.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
UPDATE scrape_schedules
|
||||||
|
SET enabled = true,
|
||||||
|
default_params = jsonb_set(default_params, '{interval_days}', '1'::jsonb, true)
|
||||||
|
WHERE source IN ('domclick_city_sweep_moskva', 'domclick_city_sweep_moskovskaya_oblast');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -1674,12 +1674,16 @@ class _HouseIdResolution:
|
||||||
total: int = 0 # deals processed in the full-engine path under the flag
|
total: int = 0 # deals processed in the full-engine path under the flag
|
||||||
resolved: int = 0 # deals where match_house_readonly returned an id
|
resolved: int = 0 # deals where match_house_readonly returned an id
|
||||||
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
|
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]:
|
def as_json(self) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"resolved": self.resolved,
|
"resolved": self.resolved,
|
||||||
"total": self.total,
|
"total": self.total,
|
||||||
"imv_reachable": self.imv_reachable,
|
"imv_reachable": self.imv_reachable,
|
||||||
|
"house_type_from_houses": self.house_type_from_houses,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1975,12 +1979,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
||||||
``params`` block. Touches NO DB / network and does NOT consult
|
``params`` block. Touches NO DB / network and does NOT consult
|
||||||
``settings_at_capture`` — it prices against the live committed
|
``settings_at_capture`` — it prices against the live committed
|
||||||
``estimator.settings`` defaults (so a settings change is caught as a metric
|
``estimator.settings`` defaults and module constants (so a change is caught as a
|
||||||
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение —
|
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
|
||||||
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
|
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
|
||||||
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
|
(закоммиченная — такая); более старая упадёт на «control flow diverged from
|
||||||
потока (см. комментарий у пина ниже). Deterministic: same fixture → identical
|
capture» — её надо перезахватить. Deterministic: same fixture → identical dict.
|
||||||
dict.
|
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
m = est.m
|
m = est.m
|
||||||
|
|
@ -1991,83 +1994,60 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
pred_ppm2_all: list[float] = []
|
pred_ppm2_all: list[float] = []
|
||||||
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
||||||
|
|
||||||
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
|
for rec in deals:
|
||||||
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
|
kw = dict(rec["kwargs"])
|
||||||
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
|
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||||
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
|
kw["geo"] = est.GeocodeResult(**kw["geo"])
|
||||||
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
|
kw["ratio_resolver"] = _make_call_stub(
|
||||||
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
|
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
|
||||||
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
|
)
|
||||||
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
|
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
|
||||||
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
|
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
|
||||||
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
|
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
|
||||||
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
|
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
|
||||||
# вызывается, а реплей с dedup=False их оценивает и упирается в
|
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
|
||||||
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
|
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
|
||||||
# False — для старых фикстур без settings_at_capture поведение прежнее).
|
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
|
||||||
_dedup_capture = bool(
|
kw["quarter_index_lookup"] = _make_call_stub(
|
||||||
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
|
rec.get("qi_calls") or [],
|
||||||
)
|
label="quarter_index_lookup",
|
||||||
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
|
coerce=_coerce_qi_return,
|
||||||
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
|
on_exhausted=None,
|
||||||
try:
|
unrecorded_counter=unrecorded,
|
||||||
for rec in deals:
|
)
|
||||||
kw = dict(rec["kwargs"])
|
kw["quarter_indexes_lookup"] = _make_call_stub(
|
||||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
rec.get("qis_calls") or [],
|
||||||
kw["geo"] = est.GeocodeResult(**kw["geo"])
|
label="quarter_indexes_lookup",
|
||||||
kw["ratio_resolver"] = _make_call_stub(
|
coerce=_coerce_qis_return,
|
||||||
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
|
on_exhausted={},
|
||||||
)
|
unrecorded_counter=unrecorded,
|
||||||
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
|
)
|
||||||
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
|
|
||||||
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
|
|
||||||
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
|
|
||||||
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
|
|
||||||
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
|
|
||||||
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
|
|
||||||
kw["quarter_index_lookup"] = _make_call_stub(
|
|
||||||
rec.get("qi_calls") or [],
|
|
||||||
label="quarter_index_lookup",
|
|
||||||
coerce=_coerce_qi_return,
|
|
||||||
on_exhausted=None,
|
|
||||||
unrecorded_counter=unrecorded,
|
|
||||||
)
|
|
||||||
kw["quarter_indexes_lookup"] = _make_call_stub(
|
|
||||||
rec.get("qis_calls") or [],
|
|
||||||
label="quarter_indexes_lookup",
|
|
||||||
coerce=_coerce_qis_return,
|
|
||||||
on_exhausted={},
|
|
||||||
unrecorded_counter=unrecorded,
|
|
||||||
)
|
|
||||||
|
|
||||||
pr = m._price_from_inputs(**kw)
|
pr = m._price_from_inputs(**kw)
|
||||||
|
|
||||||
_es_ppm2_raw = pr.expected_sold_per_m2
|
_es_ppm2_raw = pr.expected_sold_per_m2
|
||||||
_es_price_raw = pr.expected_sold_price
|
_es_price_raw = pr.expected_sold_price
|
||||||
_r_low_raw = pr.expected_sold_range_low
|
_r_low_raw = pr.expected_sold_range_low
|
||||||
_r_high_raw = pr.expected_sold_range_high
|
_r_high_raw = pr.expected_sold_range_high
|
||||||
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
|
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
|
||||||
es_price = float(_es_price_raw) if _es_price_raw is not None else None
|
es_price = float(_es_price_raw) if _es_price_raw is not None else None
|
||||||
r_low = float(_r_low_raw) if _r_low_raw is not None else None
|
r_low = float(_r_low_raw) if _r_low_raw is not None else None
|
||||||
r_high = float(_r_high_raw) if _r_high_raw is not None else None
|
r_high = float(_r_high_raw) if _r_high_raw is not None else None
|
||||||
prediction = Prediction(
|
prediction = Prediction(
|
||||||
deal_id=int(rec["deal_id"]),
|
deal_id=int(rec["deal_id"]),
|
||||||
rooms=rec["rooms"],
|
rooms=rec["rooms"],
|
||||||
area_m2=float(rec["area_m2"]),
|
area_m2=float(rec["area_m2"]),
|
||||||
sold_ppm2=float(rec["sold_ppm2"]),
|
sold_ppm2=float(rec["sold_ppm2"]),
|
||||||
median_ppm2=float(pr.median_ppm2),
|
median_ppm2=float(pr.median_ppm2),
|
||||||
confidence=pr.confidence,
|
confidence=pr.confidence,
|
||||||
anchor_tier=pr.anchor_tier,
|
anchor_tier=pr.anchor_tier,
|
||||||
expected_sold_ppm2=es_ppm2,
|
expected_sold_ppm2=es_ppm2,
|
||||||
expected_sold_price=es_price,
|
expected_sold_price=es_price,
|
||||||
range_low=r_low,
|
range_low=r_low,
|
||||||
range_high=r_high,
|
range_high=r_high,
|
||||||
)
|
)
|
||||||
predictions.append(prediction)
|
predictions.append(prediction)
|
||||||
pred_ppm2_all.append(prediction.median_ppm2)
|
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.
|
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
||||||
|
|
@ -2123,7 +2103,6 @@ def _predict_full_spine(
|
||||||
whether the house IMV anchor actually resolved a row for this deal.
|
whether the house IMV anchor actually resolved a row for this deal.
|
||||||
"""
|
"""
|
||||||
m = est.m
|
m = est.m
|
||||||
settings = est.settings
|
|
||||||
|
|
||||||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
||||||
db, deal, est, target_house_id=target_house_id
|
db, deal, est, target_house_id=target_house_id
|
||||||
|
|
@ -2212,7 +2191,7 @@ def _predict_full_spine(
|
||||||
res = m._lookup_quarter_index(
|
res = m._lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
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:
|
if capture is not None:
|
||||||
ret = _sanitize_json(list(res) if res is not None else None)
|
ret = _sanitize_json(list(res) if res is not None else None)
|
||||||
|
|
@ -2223,7 +2202,7 @@ def _predict_full_spine(
|
||||||
res = m._lookup_quarter_indexes(
|
res = m._lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
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:
|
if capture is not None:
|
||||||
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
||||||
|
|
@ -2575,6 +2554,18 @@ def run_backtest_full(
|
||||||
target_house_id = _resolve_house_id_for_deal(db, deal)
|
target_house_id = _resolve_house_id_for_deal(db, deal)
|
||||||
if target_house_id is not None:
|
if target_house_id is not None:
|
||||||
resolution.resolved += 1
|
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:
|
try:
|
||||||
pr = _predict_full_spine(
|
pr = _predict_full_spine(
|
||||||
db,
|
db,
|
||||||
|
|
@ -2641,10 +2632,11 @@ def run_backtest_full(
|
||||||
if resolution is not None:
|
if resolution is not None:
|
||||||
metrics["house_id_resolution"] = resolution.as_json()
|
metrics["house_id_resolution"] = resolution.as_json()
|
||||||
logger.info(
|
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.resolved,
|
||||||
resolution.total,
|
resolution.total,
|
||||||
resolution.imv_reachable,
|
resolution.imv_reachable,
|
||||||
|
resolution.house_type_from_houses,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dump_fixture is not None and capture is not None:
|
if dump_fixture is not None and capture is not None:
|
||||||
|
|
@ -2658,7 +2650,9 @@ def _write_fixture(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Freeze captured per-deal replay records into a committed JSON fixture.
|
"""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
|
informational snapshot (NOT consulted by ``replay_fixture``). A final
|
||||||
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
||||||
JSON-plain before ``json.dump(allow_nan=False)``.
|
JSON-plain before ``json.dump(allow_nan=False)``.
|
||||||
|
|
@ -2668,6 +2662,11 @@ def _write_fixture(
|
||||||
for name in sorted(type(est.settings).model_fields)
|
for name in sorted(type(est.settings).model_fields)
|
||||||
if name.startswith("estimate_")
|
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 = {
|
fixture = {
|
||||||
"schema_version": FIXTURE_SCHEMA_VERSION,
|
"schema_version": FIXTURE_SCHEMA_VERSION,
|
||||||
"engine": "full",
|
"engine": "full",
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,19 @@
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"high": {
|
"high": {
|
||||||
"coverage_pct": 75.0,
|
"coverage_pct": 75.0,
|
||||||
"mape_pct": 19.6,
|
"mape_pct": 19.97,
|
||||||
"n": 8,
|
"n": 8,
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 83.67,
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 15.64,
|
||||||
"n": 1562,
|
"n": 1562,
|
||||||
"n_covered": 1029
|
"n_covered": 1030
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 70.0,
|
||||||
"mape_pct": 27.59,
|
"mape_pct": 26.53,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_covered": 21
|
"n_covered": 21
|
||||||
}
|
}
|
||||||
|
|
@ -26,17 +26,17 @@
|
||||||
],
|
],
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 15.88,
|
"mape_pct": 15.92,
|
||||||
"median_bias_pct": 2.87,
|
"median_bias_pct": 2.87,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.88,
|
"p25_pct": -10.94,
|
||||||
"p75_pct": 22.98
|
"p75_pct": 22.81
|
||||||
},
|
},
|
||||||
"per_area_bucket": {
|
"per_area_bucket": {
|
||||||
"0 <30": {
|
"0 <30": {
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 19.76,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -1.02,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -16.45,
|
||||||
"p75_pct": 34.7
|
"p75_pct": 34.7
|
||||||
|
|
@ -45,21 +45,21 @@
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 15.64,
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": 4.37,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -10.56,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 22.86
|
||||||
},
|
},
|
||||||
"2 44-62": {
|
"2 44-62": {
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 13.93,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": 4.13,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -8.12,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 23.5
|
||||||
},
|
},
|
||||||
"3 62-85": {
|
"3 62-85": {
|
||||||
"mape_pct": 15.97,
|
"mape_pct": 15.97,
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -2.33,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -11.99,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 18.18
|
||||||
},
|
},
|
||||||
"4 >=85": {
|
"4 >=85": {
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 19.76,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -1.02,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -16.45,
|
||||||
|
|
@ -86,17 +86,17 @@
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": 4.37,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -10.56,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 22.86
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 13.93,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": 4.13,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -8.12,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 23.5
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -2.33,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -11.99,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 18.18
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
|
|
@ -126,11 +126,11 @@
|
||||||
"p75_pct": -12.79
|
"p75_pct": -12.79
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 10.68,
|
"mape_pct": 10.69,
|
||||||
"median_bias_pct": -5.78,
|
"median_bias_pct": -5.78,
|
||||||
"n": 425,
|
"n": 425,
|
||||||
"p25_pct": -15.88,
|
"p25_pct": -15.98,
|
||||||
"p75_pct": 4.49
|
"p75_pct": 4.54
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": null,
|
"mape_pct": null,
|
||||||
|
|
@ -140,11 +140,11 @@
|
||||||
"p75_pct": null
|
"p75_pct": null
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 20.17,
|
"mape_pct": 20.22,
|
||||||
"median_bias_pct": 18.98,
|
"median_bias_pct": 18.98,
|
||||||
"n": 697,
|
"n": 697,
|
||||||
"p25_pct": 1.25,
|
"p25_pct": 0.96,
|
||||||
"p75_pct": 41.97
|
"p75_pct": 42.31
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 36.11,
|
"mape_pct": 36.11,
|
||||||
|
|
@ -162,9 +162,9 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 83.22,
|
"coverage_pct": 83.29,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_covered": 1056
|
"n_covered": 1057
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
|
|
@ -173,9 +173,9 @@
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 83.67,
|
||||||
"n": 1231,
|
"n": 1231,
|
||||||
"n_covered": 1029
|
"n_covered": 1030
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 70.0,
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
355
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json
vendored
Normal file
355
tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json
vendored
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
{
|
||||||
|
"_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=NO page=1. Урезано: 3 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.",
|
||||||
|
"response": {
|
||||||
|
"search": {
|
||||||
|
"offers": {
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"offerId": "5228381943217465492",
|
||||||
|
"url": "https://novostroi-ekb.ru/catalog/sale/vtorichka/object/1064",
|
||||||
|
"price": {
|
||||||
|
"currency": "RUR",
|
||||||
|
"value": 4400000,
|
||||||
|
"period": "WHOLE_LIFE",
|
||||||
|
"unit": "WHOLE_OFFER",
|
||||||
|
"trend": "DECREASED",
|
||||||
|
"previous": 4450000,
|
||||||
|
"hasPriceHistory": true,
|
||||||
|
"valuePerPart": 129412,
|
||||||
|
"unitPerPart": "SQUARE_METER",
|
||||||
|
"valueForWhole": 4400000,
|
||||||
|
"unitForWhole": "WHOLE_OFFER",
|
||||||
|
"price": {
|
||||||
|
"value": 4400000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"pricePerPart": {
|
||||||
|
"value": 129412,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_METER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"priceForWhole": {
|
||||||
|
"value": 4400000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"area": {
|
||||||
|
"value": 34,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"kitchenSpace": {
|
||||||
|
"value": 7,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"roomsTotal": 1,
|
||||||
|
"floorsOffered": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"floorsTotal": 9,
|
||||||
|
"ceilingHeight": 2.5,
|
||||||
|
"building": {
|
||||||
|
"builtYear": 1978,
|
||||||
|
"buildingType": "BRICK"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"point": {
|
||||||
|
"latitude": 56.903275,
|
||||||
|
"longitude": 60.6229,
|
||||||
|
"precision": "EXACT"
|
||||||
|
},
|
||||||
|
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Шефская улица, 85",
|
||||||
|
"streetAddress": "Шефская улица, 85",
|
||||||
|
"metro": {
|
||||||
|
"metroGeoId": 101947,
|
||||||
|
"metroCityRgid": 559132,
|
||||||
|
"name": "Проспект Космонавтов",
|
||||||
|
"metroTransport": "ON_FOOT",
|
||||||
|
"timeToMetro": 7,
|
||||||
|
"latitude": 56.901844,
|
||||||
|
"longitude": 60.61394,
|
||||||
|
"minTimeToMetro": 5,
|
||||||
|
"lineColors": [
|
||||||
|
"f03d2f"
|
||||||
|
],
|
||||||
|
"rgbColor": "f03d2f"
|
||||||
|
},
|
||||||
|
"metroList": [
|
||||||
|
{
|
||||||
|
"name": "Проспект Космонавтов",
|
||||||
|
"timeToMetro": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Машиностроителей",
|
||||||
|
"timeToMetro": 14
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"creationDate": "2026-08-19T06:23:51Z",
|
||||||
|
"description": "1 комнатная квартира 34 кв .м с развитой инфраструктурой , станция метро «КОСМАНАВТОВ» в шаговой доступности . Фукциональная планировка правильной формы, дополнительное место хранения гардеробная , в квартире сделана качественная предчистовая отделка",
|
||||||
|
"author": {
|
||||||
|
"category": "AGENCY",
|
||||||
|
"organization": "САМОЛЕТ ПЛЮС"
|
||||||
|
},
|
||||||
|
"predictions": {
|
||||||
|
"predictedPrice": {
|
||||||
|
"min": "3795000",
|
||||||
|
"max": "4638000",
|
||||||
|
"value": "4217000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainImages": [
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14333116/89f721ff-ad4b-49f1-8cec-432ad11baeab/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/13853239/caf4740f-dca8-47a3-8005-56968d20a3b1/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14244677/f1943334-731e-4539-98f6-9d38d4a725ce/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/12726755/346626db-ba69-4a49-8940-76f08d6aa924/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/20074534/021cc849-6554-4059-9423-19193e3006b9/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/13912669/b240dbf0-1adc-4dbd-9ce8-c0ca6c478fc8/main"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offerId": "6282460993454547968",
|
||||||
|
"url": "//realty.yandex.ru/offer/6282460993454547968",
|
||||||
|
"price": {
|
||||||
|
"currency": "RUR",
|
||||||
|
"value": 10290000,
|
||||||
|
"period": "WHOLE_LIFE",
|
||||||
|
"unit": "WHOLE_OFFER",
|
||||||
|
"trend": "UNCHANGED",
|
||||||
|
"hasPriceHistory": false,
|
||||||
|
"valuePerPart": 294000,
|
||||||
|
"unitPerPart": "SQUARE_METER",
|
||||||
|
"valueForWhole": 10290000,
|
||||||
|
"unitForWhole": "WHOLE_OFFER",
|
||||||
|
"price": {
|
||||||
|
"value": 10290000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"pricePerPart": {
|
||||||
|
"value": 294000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_METER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"priceForWhole": {
|
||||||
|
"value": 10290000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"area": {
|
||||||
|
"value": 35,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"livingSpace": {
|
||||||
|
"value": 11,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"kitchenSpace": {
|
||||||
|
"value": 14,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"roomsTotal": 1,
|
||||||
|
"floorsOffered": [
|
||||||
|
27
|
||||||
|
],
|
||||||
|
"floorsTotal": 27,
|
||||||
|
"ceilingHeight": 2.75,
|
||||||
|
"building": {
|
||||||
|
"builtYear": 2023,
|
||||||
|
"buildingType": "MONOLIT",
|
||||||
|
"siteId": 1668651,
|
||||||
|
"siteName": "Парковый квартал"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"point": {
|
||||||
|
"latitude": 56.815243,
|
||||||
|
"longitude": 60.62593,
|
||||||
|
"precision": "EXACT"
|
||||||
|
},
|
||||||
|
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, Машинная улица, 4",
|
||||||
|
"streetAddress": "Машинная улица, 4",
|
||||||
|
"metro": {
|
||||||
|
"metroGeoId": 116399,
|
||||||
|
"metroCityRgid": 559132,
|
||||||
|
"name": "Чкаловская",
|
||||||
|
"metroTransport": "ON_FOOT",
|
||||||
|
"timeToMetro": 17,
|
||||||
|
"latitude": 56.807995,
|
||||||
|
"longitude": 60.610184,
|
||||||
|
"minTimeToMetro": 12,
|
||||||
|
"lineColors": [
|
||||||
|
"f03d2f"
|
||||||
|
],
|
||||||
|
"rgbColor": "f03d2f"
|
||||||
|
},
|
||||||
|
"metroList": [
|
||||||
|
{
|
||||||
|
"name": "Чкаловская",
|
||||||
|
"timeToMetro": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ботаническая",
|
||||||
|
"timeToMetro": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Площадь 1905 Года",
|
||||||
|
"timeToMetro": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Геологическая",
|
||||||
|
"timeToMetro": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"creationDate": "2026-08-02T14:04:19Z",
|
||||||
|
"description": "Продам квартиру в 10 минутах от центра города. Рядом Парк Маяковского. Хорошая транспортная доступность: до метро можно дойти пешком. Великолепный вид из окна.Хороший обзор. Высокие потолки, 2,75.Нет соседей сверху. Качественный",
|
||||||
|
"author": {
|
||||||
|
"category": "OWNER"
|
||||||
|
},
|
||||||
|
"predictions": {
|
||||||
|
"predictedPrice": {
|
||||||
|
"min": "8741000",
|
||||||
|
"max": "10683000",
|
||||||
|
"value": "9712000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainImages": [
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/10211679/add.69def35c26379a08babb742069d310dc.realty-api-vos/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14373170/add.e2e2b76847d9b69e3abe0c64efee75c4.realty-api-vos/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14717586/add.c65fd15bb15736448bac2a597ffd0bae.realty-api-vos/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/11512705/add.b894c4e448f696251deaed148ee9c30e.realty-api-vos/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/13847384/add.f13072669ebf54647b1682fc70fa9e99.realty-api-vos/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14527622/add.1708ed4ada10c2d407f64b0e744d8047.realty-api-vos/main"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offerId": "8530722463088927619",
|
||||||
|
"url": "//realty.yandex.ru/offer/8530722463088927619",
|
||||||
|
"price": {
|
||||||
|
"currency": "RUR",
|
||||||
|
"value": 5600000,
|
||||||
|
"period": "WHOLE_LIFE",
|
||||||
|
"unit": "WHOLE_OFFER",
|
||||||
|
"trend": "UNCHANGED",
|
||||||
|
"hasPriceHistory": false,
|
||||||
|
"valuePerPart": 269231,
|
||||||
|
"unitPerPart": "SQUARE_METER",
|
||||||
|
"valueForWhole": 5600000,
|
||||||
|
"unitForWhole": "WHOLE_OFFER",
|
||||||
|
"price": {
|
||||||
|
"value": 5600000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"pricePerPart": {
|
||||||
|
"value": 269231,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_METER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
},
|
||||||
|
"priceForWhole": {
|
||||||
|
"value": 5600000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"priceType": "PER_OFFER",
|
||||||
|
"pricingPeriod": "WHOLE_LIFE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"area": {
|
||||||
|
"value": 20.8,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"livingSpace": {
|
||||||
|
"value": 12,
|
||||||
|
"unit": "SQUARE_METER"
|
||||||
|
},
|
||||||
|
"floorsOffered": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"floorsTotal": 11,
|
||||||
|
"ceilingHeight": 2.7,
|
||||||
|
"building": {
|
||||||
|
"builtYear": 2021,
|
||||||
|
"buildingType": "MONOLIT_BRICK",
|
||||||
|
"siteId": 1948687,
|
||||||
|
"siteName": "Северный квартал"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"point": {
|
||||||
|
"latitude": 56.89804,
|
||||||
|
"longitude": 60.6199,
|
||||||
|
"precision": "EXACT"
|
||||||
|
},
|
||||||
|
"geocoderAddress": "Россия, Свердловская область, Екатеринбург, переулок Ритслянда, 15",
|
||||||
|
"streetAddress": "переулок Ритслянда, 15",
|
||||||
|
"metro": {
|
||||||
|
"metroGeoId": 101947,
|
||||||
|
"metroCityRgid": 559132,
|
||||||
|
"name": "Проспект Космонавтов",
|
||||||
|
"metroTransport": "ON_FOOT",
|
||||||
|
"timeToMetro": 5,
|
||||||
|
"latitude": 56.901844,
|
||||||
|
"longitude": 60.61394,
|
||||||
|
"minTimeToMetro": 5,
|
||||||
|
"lineColors": [
|
||||||
|
"f03d2f"
|
||||||
|
],
|
||||||
|
"rgbColor": "f03d2f"
|
||||||
|
},
|
||||||
|
"metroList": [
|
||||||
|
{
|
||||||
|
"name": "Проспект Космонавтов",
|
||||||
|
"timeToMetro": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Уралмаш",
|
||||||
|
"timeToMetro": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Машиностроителей",
|
||||||
|
"timeToMetro": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"creationDate": "2026-09-15T06:17:51Z",
|
||||||
|
"description": "Продается прекрасная студия в ЖК Северные кварталы, застройщик Брусника. Дом комфорт класса Площадь студии 20,8 плюс теплая лоджия 4кв.м (убрана перегородка) -благодаря этому площадь студии увеличена до 24,8кв.м - компактно и функционально для одного",
|
||||||
|
"author": {
|
||||||
|
"category": "AGENCY",
|
||||||
|
"organization": "DOMOS CLUB"
|
||||||
|
},
|
||||||
|
"predictions": {
|
||||||
|
"predictedPrice": {
|
||||||
|
"min": "4562000",
|
||||||
|
"max": "5576000",
|
||||||
|
"value": "5069000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainImages": [
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14727395/1635cddb-a57c-4cce-a6c1-c70b9aef13cd/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14621926/3ae41eaf-5b30-46ec-b844-b40af2bb697a/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/12719313/6921877e-51f6-4d02-93ef-0395b99776bb/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/13297592/8a8d583b-d205-460e-aec8-c9cb827eae73/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/10246351/8eee4902-aa24-42dc-aaa2-9259c82146d8/main",
|
||||||
|
"//avatars.mds.yandex.net/get-realty-offers/14621926/116dc1df-3288-4236-acfe-2f660fb2ac8b/main"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pager": {
|
||||||
|
"page": 0,
|
||||||
|
"pageSize": 20,
|
||||||
|
"sitesPageSize": 20,
|
||||||
|
"totalItems": 4077,
|
||||||
|
"totalPages": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""scraper_kit не импортирует app.* (#2422 п.2).
|
||||||
|
|
||||||
|
Инвариант держал test_kit_avito_has_no_app_imports, удалённый вместе с golden-parity
|
||||||
|
(#2419). Проверка обходит весь пакет, а не только providers: импорт app внутри kit —
|
||||||
|
цикл зависимостей, где бы он ни стоял. Смотрит AST, поэтому находит и ленивый импорт
|
||||||
|
внутри функции, и не срабатывает на слово «app» в комментарии.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import scraper_kit
|
||||||
|
|
||||||
|
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
KIT_ROOT = Path(scraper_kit.__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _app_imports(path: Path) -> list[str]:
|
||||||
|
found: list[str] = []
|
||||||
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
names = [alias.name for alias in node.names]
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0:
|
||||||
|
names = [node.module or ""]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
found += [f"{n} (строка {node.lineno})" for n in names if n.split(".")[0] == "app"]
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def test_kit_has_no_app_imports() -> None:
|
||||||
|
files = sorted(KIT_ROOT.rglob("*.py"))
|
||||||
|
# Обход не пустой и дошёл до провайдеров — иначе тест зелёный по построению.
|
||||||
|
assert KIT_ROOT / "providers" / "avito" / "houses.py" in files
|
||||||
|
assert len(files) > 40
|
||||||
|
|
||||||
|
offenders = {
|
||||||
|
str(p.relative_to(KIT_ROOT)): imports for p in files if (imports := _app_imports(p))
|
||||||
|
}
|
||||||
|
assert offenders == {}
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
"""Разбор kit-парсеров на сохранённых страницах площадок: фикстура → лот (#2422, #2408).
|
||||||
|
|
||||||
|
Golden-parity тесты сверяли kit с legacy-копией и ушли вместе с ней (#2419). После
|
||||||
|
этого разбор avito.houses, cian.serp и yandex.serp не проверял никто: правка
|
||||||
|
извлечения поля в kit проходила бы зелёной. Здесь ожидания записаны прямо значениями
|
||||||
|
из сохранённых страниц, без второй реализации в роли эталона.
|
||||||
|
|
||||||
|
Фикстуры:
|
||||||
|
- avito_house_572538.html, avito_houses_sample.html — карточки домов Авито
|
||||||
|
(вторичка), рейтинг только в reviews.filterEntries (#1789);
|
||||||
|
- avito_novostroyka_12879380.html — карточка ЖК (developmentPage);
|
||||||
|
- avito_serp_sample.html — выдача Авито, собранная из живой страницы (#726);
|
||||||
|
- cian_serp_ekb_2room_trimmed.html, yandex_gate_ekb_*_trimmed.json — живые выдачи
|
||||||
|
2026-09-17, урезанные до читаемых парсером полей (что и как урезано — в шапке
|
||||||
|
каждого файла; разбор урезанной и полной выдачи совпал поле в поле, кроме числа
|
||||||
|
фото).
|
||||||
|
|
||||||
|
Не покрыто здесь, потому что уже покрыто на фикстурах в своих файлах: avito.detail
|
||||||
|
(test_avito_detail_fields_3047.py), cian.detail (test_cian_views_parse.py,
|
||||||
|
test_cian_bti_house_persist.py), domclick (test_domclick_sweep.py,
|
||||||
|
scrapers/test_domclick_detail.py), base (scrapers/test_base_clamp_date.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito.houses import (
|
||||||
|
HouseCatalogEnrichment,
|
||||||
|
_extract_preloaded_state,
|
||||||
|
parse_houses_state,
|
||||||
|
)
|
||||||
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
from scraper_kit.providers.cian.serp import CianScraper
|
||||||
|
from scraper_kit.providers.yandex.serp import _extract_gate_data, _parse_gate_json
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent.parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name: str) -> str:
|
||||||
|
return (FIXTURES / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ── avito.houses: карточка дома (вторичка) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _house(name: str, url: str) -> HouseCatalogEnrichment:
|
||||||
|
state = _extract_preloaded_state(_read(name))
|
||||||
|
assert state is not None, name
|
||||||
|
return parse_houses_state(state, url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def house_572538() -> HouseCatalogEnrichment:
|
||||||
|
return _house("avito_house_572538.html", "https://www.avito.ru/catalog/houses/572538")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def house_115188() -> HouseCatalogEnrichment:
|
||||||
|
return _house("avito_houses_sample.html", "https://www.avito.ru/catalog/houses/115188")
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_characteristics(house_572538: HouseCatalogEnrichment) -> None:
|
||||||
|
h = house_572538.house
|
||||||
|
assert h.ext_id == 572538
|
||||||
|
assert h.ext_id_hash == "MTE3LjQ1Nzk0Mg"
|
||||||
|
assert h.full_address == "Свердловская область, Екатеринбург, ул. Сони Морозовой, 190"
|
||||||
|
assert (h.lat, h.lon) == (56.832359, 60.625583)
|
||||||
|
assert h.year_built == 1994
|
||||||
|
assert h.total_floors == 10
|
||||||
|
assert h.house_type == "brick"
|
||||||
|
assert h.material_floors == "Железобетонные"
|
||||||
|
assert h.hot_water == "Центральное"
|
||||||
|
assert h.passenger_elevators == 1
|
||||||
|
assert h.cargo_elevators is None # параметра нет на странице — не 0
|
||||||
|
assert h.has_concierge is True
|
||||||
|
assert h.has_playground is True
|
||||||
|
assert h.closed_yard is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_rating_from_reviews_filter_entries_1789(
|
||||||
|
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
|
||||||
|
) -> None:
|
||||||
|
"""В обеих карточках housePage отдаёт бейдж без оценки (scoreString «—»), оценка
|
||||||
|
есть только в reviews.filterEntries[type=score]. Без запасного пути #1789
|
||||||
|
рейтинг и число отзывов были бы None."""
|
||||||
|
h = house_572538.house
|
||||||
|
assert h.rating_score == pytest.approx(14 / 3)
|
||||||
|
assert h.rating_string == "4,7"
|
||||||
|
assert h.reviews_count == 3
|
||||||
|
assert [(r["score"], r["count"]) for r in h.rating_distribution] == [
|
||||||
|
(5, 2),
|
||||||
|
(4, 1),
|
||||||
|
(3, 0),
|
||||||
|
(2, 0),
|
||||||
|
(1, 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
h2 = house_115188.house
|
||||||
|
assert (h2.rating_score, h2.rating_string, h2.reviews_count) == (4.0, "4,0", 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_reviews(house_572538: HouseCatalogEnrichment) -> None:
|
||||||
|
reviews = {r.ext_review_id: r for r in house_572538.reviews}
|
||||||
|
assert list(reviews) == [2360701, 2018777, 1757422]
|
||||||
|
|
||||||
|
full = reviews[2018777]
|
||||||
|
assert full.author_name == "Иван"
|
||||||
|
assert full.score == 5
|
||||||
|
assert full.rated_date == date(2025, 9, 14)
|
||||||
|
assert full.text_main is not None and full.text_main.startswith("Желательно добавить")
|
||||||
|
assert full.text_pros is not None and full.text_pros.startswith("Удобное расположение")
|
||||||
|
assert full.text_cons is not None and full.text_cons.startswith("Возможно, проблемы")
|
||||||
|
|
||||||
|
# Секции без заголовка нет — основной текст пуст, плюсы/минусы на месте.
|
||||||
|
no_main = reviews[1757422]
|
||||||
|
assert no_main.rated_date == date(2025, 7, 9)
|
||||||
|
assert no_main.text_main is None
|
||||||
|
assert no_main.text_pros is not None and no_main.text_pros.startswith("Двор закрытый")
|
||||||
|
|
||||||
|
# Отзыв 2360701 датирован «16 апреля» без года — такую дату разбор сейчас не
|
||||||
|
# читает (rated_date=None). Это дефект, а не контракт, поэтому здесь не закреплён.
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_reviews_next_page(
|
||||||
|
house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment
|
||||||
|
) -> None:
|
||||||
|
assert house_572538.reviews_next_page_url is None # запись pages без nextPageUrl
|
||||||
|
assert house_115188.reviews_next_page_url == (
|
||||||
|
"/web/3/rating/model?categoryId=24&catalogId=283&paths[]=115188&limit=10&cursor=3"
|
||||||
|
"&sortBy[]=modelTypeId&sortBy[]=publishedAt&modelTypeId=14&developmentId=115188"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_placement_history(house_572538: HouseCatalogEnrichment) -> None:
|
||||||
|
history = house_572538.placement_history
|
||||||
|
assert len(history) == 8
|
||||||
|
first = history[0]
|
||||||
|
assert first.ext_item_id == "2304054124"
|
||||||
|
assert first.title == "3-к. квартира, 108\xa0м², 5/10\xa0эт."
|
||||||
|
assert (first.start_price, first.last_price) == (11_000_000, 14_000_000)
|
||||||
|
assert first.start_price_date == date(2022, 1, 8)
|
||||||
|
assert first.last_price_date == date(2026, 1, 17)
|
||||||
|
assert first.exposure_days == 677
|
||||||
|
assert first.removed_date is None # виджет даты снятия не отдаёт
|
||||||
|
assert first.raw_payload is not None and "itemImage" not in first.raw_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_mini_serp_and_seller(house_572538: HouseCatalogEnrichment) -> None:
|
||||||
|
# Цена в виджете — строка «21 700 000 ₽» с неразрывными пробелами.
|
||||||
|
assert [(m.ext_item_id, m.price_rub) for m in house_572538.mini_serp] == [
|
||||||
|
(8007602481, 21_700_000),
|
||||||
|
(8029678809, 21_600_000),
|
||||||
|
]
|
||||||
|
first = house_572538.mini_serp[0]
|
||||||
|
assert first.metro_text == "Геологическая, 1,5 км"
|
||||||
|
assert first.metro_color == "#0A6F20"
|
||||||
|
assert first.seller is not None
|
||||||
|
assert first.seller.ext_seller_id == "i251003466"
|
||||||
|
assert first.seller.name == "Самолет Плюс Центральный"
|
||||||
|
assert first.seller.seller_type == "Компания"
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_house_recommendations(house_115188: HouseCatalogEnrichment) -> None:
|
||||||
|
recs = house_115188.recommendations
|
||||||
|
assert len(recs) == 14
|
||||||
|
assert recs[0].title == "1-к. квартира, 30,3\xa0м², 3/5\xa0эт."
|
||||||
|
assert recs[0].price_text == "4\xa0000\xa0000 ₽"
|
||||||
|
assert recs[0].address == "Свердловская область, Екатеринбург, улица 40-летия Октября, 32А"
|
||||||
|
assert recs[0].image_url is not None and recs[0].image_url.startswith(
|
||||||
|
"https://50.img.avito.st/"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── avito.houses: карточка ЖК (developmentPage) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_novostroyka_development_page() -> None:
|
||||||
|
e = _house(
|
||||||
|
"avito_novostroyka_12879380.html",
|
||||||
|
"https://www.avito.ru/catalog/novostroyki/ekaterinburg/semeynyy_kvartal_beregovoy/12879380",
|
||||||
|
)
|
||||||
|
h = e.house
|
||||||
|
assert h.ext_id == 12879380
|
||||||
|
assert h.ext_id_hash == "MGDKHN70PQ"
|
||||||
|
assert h.title == "Семейный квартал «Береговой»"
|
||||||
|
assert h.short_address == "ул. Щербакова, жилые дома"
|
||||||
|
assert h.full_address is None # на странице пустая строка
|
||||||
|
assert (h.lat, h.lon) == (56.77005, 60.67244)
|
||||||
|
assert h.house_type == "monolith"
|
||||||
|
assert h.house_class == "comfort"
|
||||||
|
assert (h.passenger_elevators, h.cargo_elevators) == (42, 60)
|
||||||
|
assert (h.developer_name, h.developer_key) == ("Талан", "6749804656-3468412387")
|
||||||
|
assert h.year_built is None # дом строится: «Срок сдачи», а не год постройки
|
||||||
|
assert h.rating_score is None
|
||||||
|
assert e.reviews == [] and e.placement_history == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── avito.serp ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_avito_serp_cards_to_lots() -> None:
|
||||||
|
scraper = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True)) # type: ignore[arg-type]
|
||||||
|
lots = scraper._parse_html(
|
||||||
|
_read("avito_serp_sample.html"),
|
||||||
|
source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/prodam?p=1",
|
||||||
|
)
|
||||||
|
got = [
|
||||||
|
(lot.source_id, lot.price_rub, lot.rooms, lot.area_m2, lot.floor, lot.total_floors)
|
||||||
|
for lot in lots
|
||||||
|
]
|
||||||
|
assert got == [
|
||||||
|
("8043936560", 5_500_000, 2, 54.0, 5, 9),
|
||||||
|
("8163084615", 4_200_000, 1, 38.0, 3, 16),
|
||||||
|
]
|
||||||
|
assert lots[0].source_url == (
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_54m_59et._8043936560"
|
||||||
|
)
|
||||||
|
# sortTimeStamp в миллисекундах, дата по Москве (#726).
|
||||||
|
assert [lot.listing_date for lot in lots] == [date(2023, 11, 15), date(2023, 11, 16)]
|
||||||
|
assert {lot.listing_segment for lot in lots} == {"vtorichka"}
|
||||||
|
assert scraper.parse_failures == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── cian.serp ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def cian_lots() -> dict[str, Any]:
|
||||||
|
scraper = CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
|
||||||
|
html = _read("cian_serp_ekb_2room_trimmed.html")
|
||||||
|
lots = scraper._parse_serp_html(html)
|
||||||
|
assert scraper._extract_total_offers(html) == 13_895
|
||||||
|
assert (scraper.state_extraction_attempts, scraper.state_extraction_failures) == (2, 0)
|
||||||
|
return {lot.source_id: lot for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_all_offers_parsed(cian_lots: dict[str, Any]) -> None:
|
||||||
|
assert list(cian_lots) == ["331661795", "333599038", "333279363", "321628098"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_secondary_offer(cian_lots: dict[str, Any]) -> None:
|
||||||
|
lot = cian_lots["331661795"]
|
||||||
|
assert lot.source == "cian"
|
||||||
|
assert lot.source_url.startswith("https://ekb.cian.ru/sale/flat/331661795/")
|
||||||
|
assert lot.listing_segment == "vtorichka"
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == ("cian", None)
|
||||||
|
assert lot.price_rub == 7_990_000
|
||||||
|
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (
|
||||||
|
2,
|
||||||
|
47.2,
|
||||||
|
28.0,
|
||||||
|
7.5,
|
||||||
|
)
|
||||||
|
assert (lot.floor, lot.total_floors, lot.year_built) == (2, 17, 1980)
|
||||||
|
assert lot.house_type == "panel"
|
||||||
|
assert lot.address == "р-н Октябрьский, мкр. Парковый, улица Большакова, 13"
|
||||||
|
assert (lot.lat, lot.lon) == (56.820035, 60.634287)
|
||||||
|
assert lot.listing_date == date(2026, 9, 14)
|
||||||
|
assert (lot.mortgage_available, lot.sale_type) == (True, "alternative")
|
||||||
|
assert (lot.is_homeowner, lot.is_pro_seller) == (False, True)
|
||||||
|
assert lot.phones == [] # #2217: телефоны не сохраняем
|
||||||
|
assert lot.metro_stations[0] == {
|
||||||
|
"name": "Геологическая",
|
||||||
|
"time": 5,
|
||||||
|
"mode": "transport",
|
||||||
|
"line_color": "00701A",
|
||||||
|
"line_id": 26,
|
||||||
|
"is_default": True,
|
||||||
|
}
|
||||||
|
# descriptionMinhash приходит списком — склеен через запятую в исходном порядке.
|
||||||
|
assert lot.description_minhash.startswith("16147461,5781654,4548911,")
|
||||||
|
assert len(lot.photo_urls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_newbuilding_with_house(cian_lots: dict[str, Any]) -> None:
|
||||||
|
lot = cian_lots["333599038"]
|
||||||
|
assert lot.listing_segment == "novostroyki"
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5779142")
|
||||||
|
assert lot.address == "р-н Железнодорожный, мкр. Старая Сортировка, переулок Проходной, 1Б"
|
||||||
|
assert lot.year_built == 2028 # buildYear нет, берётся год сдачи из deadline
|
||||||
|
assert lot.house_type == "monolith"
|
||||||
|
assert (lot.price_rub, lot.area_m2, lot.floor, lot.total_floors) == (4_834_000, 36.04, 22, 32)
|
||||||
|
assert lot.sale_type == "fz214"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_newbuilding_decoration_and_balcony(cian_lots: dict[str, Any]) -> None:
|
||||||
|
lot = cian_lots["333279363"]
|
||||||
|
assert lot.repair_state == "good" # decoration=fine
|
||||||
|
assert lot.has_balcony is True # balconiesCount=1
|
||||||
|
assert lot.house_type is None # materialType на странице null
|
||||||
|
assert lot.year_built == 2026
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_serp_newbuilding_without_street_keeps_address_houseless(
|
||||||
|
cian_lots: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""В geo.address нет ни улицы, ни дома; в названии ЖК номера нет, кадастра нет.
|
||||||
|
Номер дома не выдумывается (#1773)."""
|
||||||
|
lot = cian_lots["321628098"]
|
||||||
|
assert lot.address == "р-н Чкаловский, мкр. Вторчермет"
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5705732")
|
||||||
|
|
||||||
|
|
||||||
|
# ── yandex.serp ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _gate(name: str) -> dict[str, Any]:
|
||||||
|
return json.loads(_read(name))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def yandex_secondary() -> dict[str, Any]:
|
||||||
|
lots = _parse_gate_json(_gate("yandex_gate_ekb_vtorichka_trimmed.json"), 1, "NO")
|
||||||
|
return {lot.source_id: lot for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_gate_pager() -> None:
|
||||||
|
extracted = _extract_gate_data(_gate("yandex_gate_ekb_vtorichka_trimmed.json"))
|
||||||
|
assert extracted is not None
|
||||||
|
entities, pager = extracted
|
||||||
|
assert len(entities) == 3
|
||||||
|
assert (pager["totalItems"], pager["totalPages"]) == (4077, 25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_agency_offer_with_partner_url(yandex_secondary: dict[str, Any]) -> None:
|
||||||
|
lot = yandex_secondary["5228381943217465492"]
|
||||||
|
# url в выдаче — сайт агентства; source_url строится из offerId (#2235).
|
||||||
|
assert lot.source_url == "https://realty.yandex.ru/offer/5228381943217465492/"
|
||||||
|
assert lot.yandex_offer_id == "5228381943217465492"
|
||||||
|
assert lot.listing_segment == "vtorichka"
|
||||||
|
assert (lot.price_rub, lot.price_per_m2) == (4_400_000, 129_412)
|
||||||
|
assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (1, 34.0, None, 7.0)
|
||||||
|
assert (lot.floor, lot.total_floors, lot.year_built) == (8, 9, 1978)
|
||||||
|
assert lot.house_type == "brick" # BRICK
|
||||||
|
assert lot.ceiling_height_m == 2.5
|
||||||
|
assert lot.address == "Россия, Свердловская область, Екатеринбург, Шефская улица, 85"
|
||||||
|
assert (lot.lat, lot.lon) == (56.903275, 60.6229)
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == (None, None)
|
||||||
|
assert lot.listing_date == lot.publish_date == date(2026, 8, 19)
|
||||||
|
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, True, "САМОЛЕТ ПЛЮС")
|
||||||
|
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
|
||||||
|
4_217_000,
|
||||||
|
3_795_000,
|
||||||
|
4_638_000,
|
||||||
|
)
|
||||||
|
assert (lot.price_trend, lot.price_previous_rub) == ("DECREASED", 4_450_000)
|
||||||
|
assert lot.metro_stations[0] == {"name": "Проспект Космонавтов", "min": 7}
|
||||||
|
# В фикстуре 6 фото с «//»: берутся первые 5 со схемой.
|
||||||
|
assert len(lot.photo_urls) == 5
|
||||||
|
assert all(u.startswith("https://avatars.mds.yandex.net/") for u in lot.photo_urls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_owner_offer_in_complex(yandex_secondary: dict[str, Any]) -> None:
|
||||||
|
lot = yandex_secondary["6282460993454547968"]
|
||||||
|
assert lot.source_url == "https://realty.yandex.ru/offer/6282460993454547968"
|
||||||
|
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (True, None, None)
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "1668651")
|
||||||
|
assert lot.house_type == "monolith" # MONOLIT
|
||||||
|
assert (lot.floor, lot.total_floors) == (27, 27)
|
||||||
|
assert (lot.living_area_m2, lot.kitchen_area_m2) == (11.0, 14.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_studio_without_rooms_total(yandex_secondary: dict[str, Any]) -> None:
|
||||||
|
lot = yandex_secondary["8530722463088927619"]
|
||||||
|
assert lot.rooms == 0 # roomsTotal отсутствует — студия
|
||||||
|
assert lot.area_m2 == 20.8
|
||||||
|
assert lot.kitchen_area_m2 is None
|
||||||
|
assert lot.house_type == "monolith_brick" # MONOLIT_BRICK
|
||||||
|
|
||||||
|
|
||||||
|
def test_yandex_newbuilding_without_valuation() -> None:
|
||||||
|
lots = _parse_gate_json(_gate("yandex_gate_ekb_novostroyki_trimmed.json"), 1, "YES")
|
||||||
|
assert len(lots) == 1
|
||||||
|
lot = lots[0]
|
||||||
|
assert lot.source_id == "6216493807134609867"
|
||||||
|
assert lot.listing_segment == "novostroyki"
|
||||||
|
assert lot.price_rub == 27_620_850
|
||||||
|
assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == (
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert lot.ceiling_height_m is None
|
||||||
|
assert lot.house_type is None
|
||||||
|
assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "2006795")
|
||||||
|
assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, None, None)
|
||||||
|
assert lot.address == (
|
||||||
|
"Россия, Свердловская область, Екатеринбург, жилой район Центральный, "
|
||||||
|
"ЖК Александровский Сад"
|
||||||
|
)
|
||||||
|
|
@ -36,6 +36,7 @@ import pytest
|
||||||
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=None))
|
asyncio.run(_call_kit_own_session(config=None))
|
||||||
|
|
||||||
assert captured.get("proxies") is None
|
assert captured.get("proxies") is None
|
||||||
|
|
@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
||||||
|
|
||||||
assert captured.get("proxies") == {
|
assert captured.get("proxies") == {
|
||||||
"http": "http://test-proxy.local:8080",
|
"http": "http://test-proxy.local:8080",
|
||||||
"https": "http://test-proxy.local:8080",
|
"https": "http://test-proxy.local:8080",
|
||||||
}
|
}
|
||||||
|
# #2386: сессия строится общей build_document_session — параметры те же, что были у
|
||||||
|
# собственной (impersonate/timeout=25/document-заголовки).
|
||||||
|
assert captured.get("impersonate") == DEFAULT_IMPERSONATE
|
||||||
|
assert captured.get("timeout") == 25
|
||||||
|
assert captured.get("headers") == DOCUMENT_HEADERS
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ _SSR_LITERAL = """{
|
||||||
},
|
},
|
||||||
"legalOptions": {"saleType": "Свободная продажа"},
|
"legalOptions": {"saleType": "Свободная продажа"},
|
||||||
"egrnData": {
|
"egrnData": {
|
||||||
"area": 38.2,
|
"area": {"status": "success", "value": 38.2},
|
||||||
"floor": 5,
|
"floor": {"status": "success", "value": 5},
|
||||||
"owners_count": 1,
|
"owners_count": {"status": "success", "value": 1},
|
||||||
"collateral": true,
|
"collateral": true,
|
||||||
"collateral_sber": false
|
"collateral_sber": false
|
||||||
},
|
},
|
||||||
|
|
@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None:
|
||||||
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
|
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
|
||||||
assert "house_type" not in raw
|
assert "house_type" not in raw
|
||||||
assert raw["domclick_building_guid"] == "abc-guid-123"
|
assert raw["domclick_building_guid"] == "abc-guid-123"
|
||||||
assert raw["egrn_area"] == 38.2
|
assert raw["egrn_area"] == {"status": "success", "value": 38.2}
|
||||||
assert raw["demand"]["calls"] == 5
|
assert raw["demand"]["calls"] == 5
|
||||||
assert raw["demand"]["favorites"] == 12
|
assert raw["demand"]["favorites"] == 12
|
||||||
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm
|
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
# DATABASE_URL required by config before any app import.
|
# DATABASE_URL required by config before any app import.
|
||||||
|
|
@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||||
resolve_cian_zhk_url_via_search,
|
resolve_cian_zhk_url_via_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# config обязателен (#2385); на пути с переданной session он не читается.
|
||||||
|
_CONFIG = SimpleNamespace(cian_proxy_url=None)
|
||||||
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None:
|
||||||
async def test_resolve_via_search_happy_path() -> None:
|
async def test_resolve_via_search_happy_path() -> None:
|
||||||
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
||||||
session = _mock_session(status_code=200, text=_serp_fixture())
|
session = _mock_session(status_code=200, text=_serp_fixture())
|
||||||
url = await resolve_cian_zhk_url_via_search(48853, session=session)
|
url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session)
|
||||||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||||
# caller owns a passed-in session — resolver must NOT close it.
|
# caller owns a passed-in session — resolver must NOT close it.
|
||||||
session.close.assert_not_awaited()
|
session.close.assert_not_awaited()
|
||||||
|
|
@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None:
|
||||||
async def test_resolve_via_search_no_match_returns_none() -> None:
|
async def test_resolve_via_search_no_match_returns_none() -> None:
|
||||||
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
||||||
session = _mock_session(status_code=200, text="<html>no results</html>")
|
session = _mock_session(status_code=200, text="<html>no results</html>")
|
||||||
assert await resolve_cian_zhk_url_via_search(999, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_via_search_non_200_returns_none() -> None:
|
async def test_resolve_via_search_non_200_returns_none() -> None:
|
||||||
"""A non-200 (block / 404) → None without attempting extraction."""
|
"""A non-200 (block / 404) → None without attempting extraction."""
|
||||||
session = _mock_session(status_code=429, text="too many requests")
|
session = _mock_session(status_code=429, text="too many requests")
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
||||||
session.close = AsyncMock()
|
session.close = AsyncMock()
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,9 @@ class FakeSession:
|
||||||
exp = row.get("expires_at")
|
exp = row.get("expires_at")
|
||||||
return exp is None or exp > datetime.now(UTC)
|
return exp is None or exp > datetime.now(UTC)
|
||||||
|
|
||||||
if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
|
# primary: своя affinity ИЛИ 'any'. Полный фрагмент, а не "provider_affinity IN":
|
||||||
|
# с #3299 такой же IN есть и в backup-подзапросе fallback'а.
|
||||||
|
if "provider_affinity IN (:provider, 'any')" in sql:
|
||||||
cands = [
|
cands = [
|
||||||
r
|
r
|
||||||
for r in self.rows
|
for r in self.rows
|
||||||
|
|
@ -189,17 +191,27 @@ class FakeSession:
|
||||||
# тоже гейтим по подстроке (b2-подзапрос), иначе мок «чинил» бы
|
# тоже гейтим по подстроке (b2-подзапрос), иначе мок «чинил» бы
|
||||||
# незащищённый SQL сам.
|
# незащищённый SQL сам.
|
||||||
backup_must_be_usable = "b2.banned_until > now()" in sql
|
backup_must_be_usable = "b2.banned_until > now()" in sql
|
||||||
|
# #3299: backup — 'any' или та же affinity, здоровый, с живой арендой; гейт
|
||||||
|
# по подстроке, как выше — иначе мок сам «чинил» бы старый предикат.
|
||||||
|
counts_any = "IN (sp.provider_affinity, 'any')" in sql
|
||||||
|
|
||||||
def _has_backup(row: dict[str, Any]) -> bool:
|
def _has_backup(row: dict[str, Any]) -> bool:
|
||||||
if row["provider_affinity"] == "any":
|
if row["provider_affinity"] == "any":
|
||||||
return True
|
return True
|
||||||
|
affinities = (
|
||||||
|
(row["provider_affinity"], "any")
|
||||||
|
if counts_any
|
||||||
|
else (row["provider_affinity"],)
|
||||||
|
)
|
||||||
return any(
|
return any(
|
||||||
other["provider_affinity"] == row["provider_affinity"]
|
other["provider_affinity"] in affinities
|
||||||
and other["enabled"]
|
and other["enabled"]
|
||||||
|
and (not counts_any or other["consecutive_fails"] < max_fails)
|
||||||
|
and (not counts_any or _not_expired(other))
|
||||||
and other["id"] != row["id"]
|
and other["id"] != row["id"]
|
||||||
and not (
|
and not (
|
||||||
backup_must_be_usable
|
backup_must_be_usable
|
||||||
and self._has_active_ban(other["id"], other["provider_affinity"])
|
and self._has_active_ban(other["id"], row["provider_affinity"])
|
||||||
)
|
)
|
||||||
for other in self.rows
|
for other in self.rows
|
||||||
)
|
)
|
||||||
|
|
@ -392,13 +404,24 @@ class FakeSession:
|
||||||
# fallback-safe: другой ПРИГОДНЫЙ узел ТОЙ ЖЕ affinity (банимый узел
|
# fallback-safe: другой ПРИГОДНЫЙ узел ТОЙ ЖЕ affinity (банимый узел
|
||||||
# остаётся enabled и тоже считается — бан теперь per-source; а вот
|
# остаётся enabled и тоже считается — бан теперь per-source; а вот
|
||||||
# забаненный своим же источником backup'ом не считается).
|
# забаненный своим же источником backup'ом не считается).
|
||||||
|
# #3299: тот же гейт, что в acquire-ветке мока.
|
||||||
|
counts_any = "IN (sp.provider_affinity, 'any')" in sql
|
||||||
|
affinities = (
|
||||||
|
(sp["provider_affinity"], "any") if counts_any else (sp["provider_affinity"],)
|
||||||
|
)
|
||||||
return any(
|
return any(
|
||||||
other["provider_affinity"] == sp["provider_affinity"]
|
other["provider_affinity"] in affinities
|
||||||
and other["enabled"]
|
and other["enabled"]
|
||||||
|
and (not counts_any or other["consecutive_fails"] < max_fails)
|
||||||
|
and (
|
||||||
|
not counts_any
|
||||||
|
or other.get("expires_at") is None
|
||||||
|
or other["expires_at"] > datetime.now(UTC)
|
||||||
|
)
|
||||||
and other["id"] != sp["id"]
|
and other["id"] != sp["id"]
|
||||||
and not (
|
and not (
|
||||||
backup_must_be_usable
|
backup_must_be_usable
|
||||||
and self._has_active_ban(other["id"], other["provider_affinity"])
|
and self._has_active_ban(other["id"], sp["provider_affinity"])
|
||||||
)
|
)
|
||||||
for other in self.rows
|
for other in self.rows
|
||||||
)
|
)
|
||||||
|
|
@ -712,11 +735,16 @@ def test_acquire_fallback_skips_expired_lease() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_acquire_fallback_prefers_non_expired_over_expired() -> None:
|
def test_acquire_fallback_prefers_non_expired_over_expired() -> None:
|
||||||
"""Просроченный узел пропускается, живой той же чужой affinity — выдан fallback'ом."""
|
"""Просроченный узел пропускается, живой той же чужой affinity — выдан fallback'ом.
|
||||||
|
|
||||||
|
Узел 3 (занят чужим прогоном) — резерв cian: с #3299 просроченный узел 1 резервом
|
||||||
|
не считается, и без узла 3 живой узел 2 был бы последним для cian и защищён.
|
||||||
|
"""
|
||||||
db = FakeSession(
|
db = FakeSession(
|
||||||
[
|
[
|
||||||
_proxy(1, affinity="cian", expires_at=datetime.now(UTC) - timedelta(minutes=1)),
|
_proxy(1, affinity="cian", expires_at=datetime.now(UTC) - timedelta(minutes=1)),
|
||||||
_proxy(2, affinity="cian", expires_at=datetime.now(UTC) + timedelta(hours=1)),
|
_proxy(2, affinity="cian", expires_at=datetime.now(UTC) + timedelta(hours=1)),
|
||||||
|
_proxy(3, affinity="cian", leased_by=99),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
||||||
|
|
@ -735,9 +763,9 @@ def test_acquire_warns_on_expired_proxy(caplog: pytest.LogCaptureFixture) -> Non
|
||||||
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
||||||
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
|
||||||
assert lease is not None and lease.id == 2 # живой узел всё равно выдан
|
assert lease is not None and lease.id == 2 # живой узел всё равно выдан
|
||||||
assert any(
|
assert any("expired" in r.message and "id=1" in r.message for r in caplog.records), (
|
||||||
"expired" in r.message and "id=1" in r.message for r in caplog.records
|
"ожидался WARNING про просроченный proxy id=1"
|
||||||
), "ожидался WARNING про просроченный proxy id=1"
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── release ──────────────────────────────────────────────────────────────────
|
# ── release ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1549,9 +1577,7 @@ async def test_probe_proxy_proxy_error_logs_single_line_without_traceback(
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
||||||
|
|
||||||
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
||||||
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
|
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy("http://u:p@h1:8080")
|
||||||
"http://u:p@h1:8080"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert exit_ip is None
|
assert exit_ip is None
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,19 @@ tests/test_pdf_real_render.py
|
||||||
tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent
|
tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent
|
||||||
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
|
tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked
|
||||||
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
|
tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows
|
||||||
|
tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits
|
||||||
|
tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number
|
||||||
|
tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number
|
||||||
|
tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash
|
||||||
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
|
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
|
||||||
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
|
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
|
||||||
|
tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region
|
||||||
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
||||||
|
tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
|
||||||
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
|
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
|
||||||
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard
|
tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal
|
||||||
|
tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing
|
||||||
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent
|
||||||
tests/test_user_events.py::test_real_record_event_inserts_row
|
tests/test_user_events.py::test_real_record_event_inserts_row
|
||||||
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest
|
||||||
|
|
@ -146,3 +153,49 @@ tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hun
|
||||||
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
|
tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
|
||||||
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
|
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
|
||||||
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
|
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
|
||||||
|
|
||||||
|
# Жилая площадь и балконы переживают переобход выдачи (#3252) — тот же `_live_session()`.
|
||||||
|
# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: SERP без полей не стирает то, что
|
||||||
|
# добыла карточка (COALESCE), и настоящее новое значение всё ещё перезаписывает.
|
||||||
|
# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip.
|
||||||
|
tests/test_3252_domclick_card_fields.py::test_serp_rescrape_keeps_detail_living_area_and_balconies
|
||||||
|
tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites
|
||||||
|
|
||||||
|
# Пул прокси (#3299, #3310, #3404) — предикаты выдачи/защиты и атрибуция прогона живут
|
||||||
|
# в SQL, мок их не проверяет. Live-тесты на схеме из миграций: #3299 и #3310 — в одной
|
||||||
|
# внешней транзакции с откатом (commit пула = RELEASE savepoint), #3404 — настоящими
|
||||||
|
# сессиями, строки t3404-* удаляются в finally. В ci-tradein.yml идут по-настоящему
|
||||||
|
# (postgres-сервис, #2745); на машине без БД и в deploy-tradein.yml — skip.
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_dedicated_node_goes_to_other_source_when_any_node_backs_it
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_only_dedicated_node_without_any_nodes_stays_protected
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_any_node_banned_by_dedicated_source_is_not_a_backup
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_unhealthy_any_node_is_not_a_backup
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_expired_any_node_is_not_a_backup
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_counts_dedicated_node_reachable_via_any_backup
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_still_protects_when_dedicated_node_has_no_usable_backup
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_unhealthy_backup_does_not_count
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_backup_does_not_count
|
||||||
|
tests/test_3299_fallback_counts_any_nodes.py::test_mark_banned_expired_node_does_not_save_the_source
|
||||||
|
tests/test_3310_curl_ban_keeps_last_node.py::test_three_bans_on_last_node_keep_it_in_the_pool
|
||||||
|
tests/test_3310_curl_ban_keeps_last_node.py::test_transport_failure_still_counts_against_node_health
|
||||||
|
tests/test_3404_egress_run_attribution.py::test_resolve_within_run_writes_node_to_scrape_runs
|
||||||
|
tests/test_3404_egress_run_attribution.py::test_resolve_outside_run_writes_nothing
|
||||||
|
tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_callers_transaction
|
||||||
|
|
||||||
|
# Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только
|
||||||
|
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
|
||||||
|
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
|
||||||
|
tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent
|
||||||
|
tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many
|
||||||
|
|
||||||
|
# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с
|
||||||
|
# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на
|
||||||
|
# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745).
|
||||||
|
tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head
|
||||||
|
|
||||||
|
# Снапшоты «строка на изменение» (#2993, миграция 325): сравнение с последним
|
||||||
|
# снимком источника и суточная идемпотентность исполняет только Postgres — на
|
||||||
|
# мок-лэйне деплоя БД нет. В ci-tradein.yml бегут по-настоящему (postgres-сервис).
|
||||||
|
tests/test_2993_snapshot_change_only.py::test_events_survive_change_only_writes
|
||||||
|
tests/test_2993_snapshot_change_only.py::test_only_changed_or_new_sources_get_a_row_today
|
||||||
|
tests/test_2993_snapshot_change_only.py::test_rerun_same_day_compares_with_todays_row
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
|
||||||
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
|
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
|
||||||
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
|
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
|
||||||
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
|
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
|
||||||
at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl),
|
at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl),
|
||||||
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
|
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
|
||||||
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
|
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
|
||||||
guard above, lines 141-148)."""
|
guard above, lines 141-148)."""
|
||||||
|
|
|
||||||
202
tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
Normal file
202
tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""Упавший хендлер не оставляет строку scrape_runs в 'running' до zombie-reaper'а (#1940).
|
||||||
|
|
||||||
|
Прод 17.09 06:58: avito_city_sweep run 7349 — через 10 мс после claim BrowserFetcher.__aenter__
|
||||||
|
бросил NoProxyAvailableError, планировщик записал «crashed run_id=7349» и всё. Строка
|
||||||
|
осталась 'running' с heartbeat_at == started_at; 21 сутки до этого так ушли в 'zombie'
|
||||||
|
18 avito-свипов, каждый через 6 ч. Проверяется значение статуса в строке прогона.
|
||||||
|
|
||||||
|
Финализатор — `runs.mark_crashed`. Его зовут оба запускающих: планировщик и ручные
|
||||||
|
запуски из админки (они идут мимо `_dispatch`). Здесь боевой `mark_crashed` над
|
||||||
|
двойником одной строки scrape_runs: `_Row` отвечает на те же SQL, что пишет runs.py,
|
||||||
|
с гейтом `WHERE status = 'running'`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import types
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.orchestration import runs as kit_runs
|
||||||
|
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||||
|
from scraper_kit.orchestration.scheduler import Handler, SchedulerContext, _dispatch
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
RUN_ID = 7349
|
||||||
|
|
||||||
|
|
||||||
|
class _Row:
|
||||||
|
"""Сессия над одной строкой scrape_runs (status/ban_kind/error/counters).
|
||||||
|
|
||||||
|
История источника для стрик-алерта — `CONSECUTIVE_FAILURE_ALERT_THRESHOLD` неудач:
|
||||||
|
ровно веха лестницы `_streak_alert_due`, на которой дубль и уходил бы в Sentry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.status = "running"
|
||||||
|
self.ban_kind: str | None = None
|
||||||
|
self.error: str | None = None
|
||||||
|
self.updates = 0
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
sql = str(stmt)
|
||||||
|
p = params or {}
|
||||||
|
res = MagicMock()
|
||||||
|
res.fetchone.return_value = None # гейт _claim_run: «не running»
|
||||||
|
res.scalar.return_value = True # advisory-lock взят
|
||||||
|
if "INSERT INTO scrape_runs" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(id=RUN_ID)
|
||||||
|
elif "SELECT status FROM scrape_runs WHERE id" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(status=self.status)
|
||||||
|
elif "UPDATE scrape_runs" in sql and "SET status" in sql:
|
||||||
|
hit = self.status == "running"
|
||||||
|
if hit:
|
||||||
|
m = re.search(r"SET status = '(\w+)'", sql)
|
||||||
|
assert m is not None
|
||||||
|
self.status = m.group(1)
|
||||||
|
self.ban_kind = p.get("ban_kind")
|
||||||
|
self.error = p.get("error")
|
||||||
|
self.updates += 1
|
||||||
|
res.first.return_value = types.SimpleNamespace(id=RUN_ID) if hit else None
|
||||||
|
elif "SELECT source FROM scrape_runs" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(source="avito_city_sweep")
|
||||||
|
elif "SELECT status, counters FROM scrape_runs" in sql:
|
||||||
|
res.fetchall.return_value = [
|
||||||
|
types.SimpleNamespace(status="failed", counters={})
|
||||||
|
] * CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||||
|
return res
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_and_wait(job: Any, row: _Row) -> None:
|
||||||
|
ctx = SchedulerContext(
|
||||||
|
config=MagicMock(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
enrichment=MagicMock(),
|
||||||
|
session_factory=lambda: row,
|
||||||
|
)
|
||||||
|
sched = {
|
||||||
|
"id": 1,
|
||||||
|
"source": "avito_city_sweep",
|
||||||
|
"window_start_hour": 6,
|
||||||
|
"window_end_hour": 7,
|
||||||
|
"default_params": {},
|
||||||
|
}
|
||||||
|
run_id = await _dispatch(Handler(job, "avito_city_sweep"), row, sched, ctx) # type: ignore[arg-type]
|
||||||
|
assert run_id == RUN_ID
|
||||||
|
await asyncio.gather(*ctx._inflight_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── планировщик ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_proxy_crash_before_try_marks_run_banned_infra() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
raise NoProxyAvailableError("avito")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
|
assert row.error is not None and "no proxy available" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wrapped_no_proxy_is_still_infra() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
try:
|
||||||
|
raise NoProxyAvailableError("cian")
|
||||||
|
except NoProxyAvailableError as exc:
|
||||||
|
raise RuntimeError("sidecar fetch failed") from exc
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_other_crash_marks_run_failed() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
raise KeyError("anchors")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert row.error is not None and "KeyError" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_handler_own_final_status_survives() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
kit_runs.mark_done(db, run_id, {"done_buckets": ["center"]})
|
||||||
|
raise RuntimeError("after mark_done")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.updates) == ("done", 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pipeline_failure_is_alerted_once_not_twice() -> None:
|
||||||
|
"""Обычный путь пайплайна: mark_failed и raise. Повторная финализация не шлёт дубль.
|
||||||
|
|
||||||
|
Стрик после первой финализации стоит на вехе лестницы; no-op mark_failed из
|
||||||
|
планировщика позвал бы `_alert_on_run_id` ещё раз с тем же стриком.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
kit_runs.mark_failed(db, run_id, "cian-sweep fatal", {})
|
||||||
|
raise RuntimeError("cian-sweep fatal")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
sentry = MagicMock()
|
||||||
|
with patch.object(kit_runs, "sentry_sdk", sentry):
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.updates) == ("failed", 1)
|
||||||
|
assert sentry.capture_message.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── ручные запуски из админки (мимо _dispatch) ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "runner"),
|
||||||
|
[
|
||||||
|
("avito-city-sweep", "run_avito_city_sweep"),
|
||||||
|
("cian-city-sweep", "run_cian_city_sweep"),
|
||||||
|
("cian-full-load", "run_cian_full_load"),
|
||||||
|
("yandex-full-load", "run_yandex_full_load"),
|
||||||
|
("yandex-city-sweep", "run_yandex_city_sweep"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_admin_manual_launch_crash_is_finalized(path: str, runner: str) -> None:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import admin as admin_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||||
|
row = _Row()
|
||||||
|
app.dependency_overrides[get_db] = lambda: row
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(kit_runs, "sentry_sdk", None),
|
||||||
|
patch.object(admin_module, "has_running_run", return_value=False),
|
||||||
|
patch.object(admin_module, "SessionLocal", lambda: row),
|
||||||
|
patch.object(admin_module, runner, AsyncMock(side_effect=NoProxyAvailableError("avito"))),
|
||||||
|
):
|
||||||
|
r = TestClient(app).post(f"/api/v1/admin/scrape/{path}", json={})
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""#1970: калибровочные константы движка, вынесенные из Settings, — значения.
|
||||||
|
|
||||||
|
Поля estimate_* жили в Settings и формально переопределялись через ENV. При
|
||||||
|
переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже
|
||||||
|
сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и
|
||||||
|
tradein-tgbot — совпали, ENV-оверрайдов нет), а не скопированы из кода.
|
||||||
|
|
||||||
|
Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не
|
||||||
|
проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок),
|
||||||
|
а поведенческие тесты ловят не все пороги. Поменять число — осознанно: здесь,
|
||||||
|
в estimator.py и с прогоном регрессионного гейта.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.services import estimator
|
||||||
|
|
||||||
|
# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026)
|
||||||
|
_PROD_VALUES: dict[str, tuple[str, float]] = {
|
||||||
|
# #2379, #764 квартальный индекс
|
||||||
|
"QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10),
|
||||||
|
"QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6),
|
||||||
|
"QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0),
|
||||||
|
"QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
|
||||||
|
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
|
||||||
|
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
|
||||||
|
# #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config)
|
||||||
|
"CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10),
|
||||||
|
"CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40),
|
||||||
|
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
|
||||||
|
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
|
||||||
|
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
|
||||||
|
# #2381, #651/#652 same-building anchor + IMV-blend
|
||||||
|
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
|
||||||
|
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
|
||||||
|
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
|
||||||
|
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
|
||||||
|
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
|
||||||
|
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
|
||||||
|
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
|
||||||
|
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
|
||||||
|
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
|
||||||
|
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
|
||||||
|
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
|
||||||
|
"FSD_K": ("estimate_fsd_k", 1.65),
|
||||||
|
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
|
||||||
|
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
|
||||||
|
_REMOVED_SWITCHES = (
|
||||||
|
"estimate_dedup_analogs_enabled", # #2378
|
||||||
|
"estimate_expected_sold_le_asking", # #2380
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||||
|
def test_constant_keeps_prod_value(const: str) -> None:
|
||||||
|
assert getattr(estimator, const) == _PROD_VALUES[const][1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||||
|
def test_settings_field_is_gone(const: str) -> None:
|
||||||
|
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
|
||||||
|
assert _PROD_VALUES[const][0] not in Settings.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", _REMOVED_SWITCHES)
|
||||||
|
def test_switch_is_gone(field: str) -> None:
|
||||||
|
assert field not in Settings.model_fields
|
||||||
215
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal file
215
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""#2189 — канон-ключ адреса не теряет дробь в номере дома («Сулимова, 3/2» ≠ «Сулимова, 32»).
|
||||||
|
|
||||||
|
Проверяется функция, которую поставили миграции (322), а не её Python-копия, и оба её
|
||||||
|
потребителя: матч ГАР↔houses (gar_flats_loader) и схлопывание дублей (house_dedup_merge).
|
||||||
|
Без БД тесты пропускаются (записи в tests/skip_allowlist.txt), в CI идут на схеме из data/sql.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.services import gar_flats_loader as gfl
|
||||||
|
from app.services import house_dedup_merge as hdm
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session() -> Any | None:
|
||||||
|
try:
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
engine = create_engine(dsn, future=True)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
needs_db = pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
|
||||||
|
|
||||||
|
|
||||||
|
@needs_db
|
||||||
|
def test_real_canon_keeps_slash_between_digits() -> None:
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
|
||||||
|
def canon(s: str) -> str:
|
||||||
|
return db.execute(text("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# живые пары с прода (ревизия DaData 02.07): это разные дома
|
||||||
|
assert canon("Екатеринбург, ул. Сулимова, 3/2") == "сулимова3/2"
|
||||||
|
assert canon("Екатеринбург, ул. Сулимова, 32") == "сулимова32"
|
||||||
|
assert canon("улица Краснолесья, 10/7") == "краснолесья10/7"
|
||||||
|
assert canon("ул. Краснолесья,107") == "краснолесья107"
|
||||||
|
assert canon("улица Машиностроителей, 4/1") != canon("ул. Машиностроителей, 41")
|
||||||
|
# пробелы вокруг дроби и формат ГАР дают тот же ключ, что и слитная запись
|
||||||
|
assert canon("ул. Сулимова, 3 / 2") == canon(
|
||||||
|
"р-н Кировский, мкр. Пионерский, ул. Сулимова,3/2"
|
||||||
|
)
|
||||||
|
# «/» не между цифрами — как раньше: угловой адрес и литера перед дробью
|
||||||
|
assert canon(
|
||||||
|
"Екатеринбург, Втузгородок м-н, улица Гагарина, 27 / Первомайская улица, 81"
|
||||||
|
) == ("гагарина27первомайская81")
|
||||||
|
assert canon("ул. 8 Марта,204Г/1") == "8марта204г1"
|
||||||
|
# остальная нормализация не задета
|
||||||
|
assert canon("ул. Шаумяна,20") == canon("ул. Шаумяна, 20") == "шаумяна20"
|
||||||
|
assert (
|
||||||
|
canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == "евгениясавкова17а"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@needs_db
|
||||||
|
def test_real_gar_match_picks_the_house_with_the_same_number() -> None:
|
||||||
|
"""Прод: «Сулимова 32» получал ГАР-дом «3/2» (414 квартир) — max(flat_count) в общем ключе."""
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO gar_house_flats "
|
||||||
|
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
|
||||||
|
"('g2189-3-2', '66', 414, 'г. Екатеринбург, ул. Сулимова2189, 3/2', "
|
||||||
|
" 'ул. Сулимова2189, 3/2'),"
|
||||||
|
"('g2189-32', '66', 120, 'г. Екатеринбург, ул. Сулимова2189, 32', "
|
||||||
|
" 'ул. Сулимова2189, 32')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# region_code обязателен: с #2583 H5 матчер даёт guid только ГАР-строке региона дома,
|
||||||
|
# дом без региона не матчится вовсе.
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO houses (id, source, ext_house_id, url, address, region_code) VALUES "
|
||||||
|
"(900189, 'avito', 'EXT-2189-32', 'u', 'Екатеринбург, ул. Сулимова2189, 32', 66),"
|
||||||
|
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3 / 2', 66)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
gfl.match_houses_to_gar(db, region_code="66")
|
||||||
|
|
||||||
|
got = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, gar_house_guid, gar_flat_count, gar_match_method FROM houses "
|
||||||
|
"WHERE id IN (900189, 900190) ORDER BY id"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert [tuple(r) for r in got] == [
|
||||||
|
(900189, "g2189-32", 120, "canon_addr"),
|
||||||
|
(900190, "g2189-3-2", 414, "canon_addr"),
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@needs_db
|
||||||
|
def test_real_dedup_does_not_merge_slash_and_plain_number() -> None:
|
||||||
|
"""Дома «3/2» и «32» без ФИАС в одной точке — разные здания, в один кластер не попадают."""
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
|
||||||
|
"(900189, 'avito', 'EXT-2189-32', 'u', 'ул. Сулимова2189, 32', 56.84, 60.6),"
|
||||||
|
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3/2', 56.84009, 60.6)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
hdm.merge_duplicate_houses(db, dry_run=False)
|
||||||
|
|
||||||
|
ids = {r.id for r in db.execute(text("SELECT id FROM houses WHERE id IN (900189, 900190)"))}
|
||||||
|
assert ids == {900189, 900190}
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
for sql in (
|
||||||
|
"DELETE FROM house_sources WHERE house_id IN (900189, 900190)",
|
||||||
|
"DELETE FROM house_address_aliases WHERE house_id IN (900189, 900190)",
|
||||||
|
"DELETE FROM house_merge_log WHERE loser_id IN (900189, 900190)",
|
||||||
|
"DELETE FROM houses WHERE id IN (900189, 900190)",
|
||||||
|
):
|
||||||
|
db.execute(text(sql))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
_MIGRATION = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "data"
|
||||||
|
/ "sql"
|
||||||
|
/ "322_canon_keep_slash_in_house_number.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_322_resets_only_matches_held_by_the_lost_slash() -> None:
|
||||||
|
"""Матчер пары не снимает: «4/1», пришитый к ГАР «41», без миграции так и остался бы с ним.
|
||||||
|
|
||||||
|
Прогон настоящего файла миграции; houses и gar_house_flats затенены временными таблицами
|
||||||
|
(pg_temp первым в search_path), реальные таблицы не трогаются.
|
||||||
|
"""
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
pytest.skip("нет доступной Postgres test-БД")
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(
|
||||||
|
dsn.replace("postgresql+psycopg://", "postgresql://"), autocommit=True
|
||||||
|
) as c:
|
||||||
|
c.execute(
|
||||||
|
"CREATE TEMP TABLE gar_house_flats (house_guid text PRIMARY KEY, region_code text,"
|
||||||
|
" flat_count int, norm_address text)"
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"CREATE TEMP TABLE houses (id int PRIMARY KEY, short_address text, full_address text,"
|
||||||
|
" address text, gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||||||
|
" gar_match_method text)"
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO gar_house_flats VALUES "
|
||||||
|
"('g-3-2', '66', 414, 'ул. Сулимова, 3/2'),"
|
||||||
|
"('g-41', '66', 92, 'ул. Машиностроителей, 41'),"
|
||||||
|
"('g-58', '66', 249, 'ул. Абрикосовая, 58')"
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO houses (id, short_address, gar_house_guid, gar_flat_count, gar_matched_at,"
|
||||||
|
" gar_match_method) VALUES "
|
||||||
|
# держится только на потерянной дроби → сброс
|
||||||
|
"(1, 'ул. Сулимова, 32', 'g-3-2', 414, now(), 'canon_addr'),"
|
||||||
|
"(2, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'canon_addr'),"
|
||||||
|
# верная пара с дробью
|
||||||
|
"(3, 'Сулимова ул.,3/2', 'g-3-2', 414, now(), 'canon_addr'),"
|
||||||
|
# не канон-метод
|
||||||
|
"(4, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'dadata_fias_revision'),"
|
||||||
|
# ключ разошёлся не из-за дроби (адрес сменился после матча)
|
||||||
|
"(5, 'жилой комплекс Совушки, Абрикосовая ул.,58', 'g-58', 249, now(), 'canon_addr')"
|
||||||
|
)
|
||||||
|
|
||||||
|
c.execute(_MIGRATION.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT id, gar_house_guid, gar_flat_count, gar_matched_at IS NOT NULL,"
|
||||||
|
" gar_match_method FROM houses ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == [
|
||||||
|
(1, None, None, False, None),
|
||||||
|
(2, None, None, False, None),
|
||||||
|
(3, "g-3-2", 414, True, "canon_addr"),
|
||||||
|
(4, "g-41", 92, True, "dadata_fias_revision"),
|
||||||
|
(5, "g-58", 249, True, "canon_addr"),
|
||||||
|
]
|
||||||
259
tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
Normal file
259
tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""Крайние случаи city-свипов kit'а, не покрытые после удаления legacy-пайплайна (#2406).
|
||||||
|
|
||||||
|
Уже покрыто: avito — таймаут якоря и дрейн после первого якоря (test_3319); дрейн ДО
|
||||||
|
первого якоря у yandex/cian/newbuilding (test_3333); отмена у domclick (test_3369).
|
||||||
|
Здесь остаток:
|
||||||
|
(а) таймаут якоря у cian и yandex; у domclick снятая/упавшая фаза не отмечает
|
||||||
|
корзины, строки которых не сохранены;
|
||||||
|
(б) SIGTERM-дрейн ПОСЛЕ первого якоря у cian и yandex;
|
||||||
|
(в) пользовательская отмена (runs.is_cancelled=True) после первого якоря у avito,
|
||||||
|
cian и yandex.
|
||||||
|
|
||||||
|
Проверяется строка прогона, а не вызовы: _FakeDb мержит counters всех записей по
|
||||||
|
порядку (как `counters || :counters` в runs.py) и запоминает финальный статус.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import json
|
||||||
|
import types
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.orchestration import pipeline as pl
|
||||||
|
|
||||||
|
ANCHOR_A = (56.83, 60.60, "ekb-center")
|
||||||
|
ANCHOR_B = (56.79, 60.63, "ekb-south")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.counters: dict[str, Any] = {}
|
||||||
|
self.status = "running"
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
sql = str(stmt)
|
||||||
|
if params and "counters" in params:
|
||||||
|
self.counters.update(json.loads(params["counters"]))
|
||||||
|
for st in ("done", "failed", "banned"):
|
||||||
|
if f"SET status = '{st}'" in sql and self.status == "running":
|
||||||
|
self.status = st
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Scraper:
|
||||||
|
"""Двойник Cian/Yandex/AvitoScraper: помнит якоря, роняет заданный TimeoutError'ом."""
|
||||||
|
|
||||||
|
visited: list[float] = [] # noqa: RUF012
|
||||||
|
timeout_on: float | None = None
|
||||||
|
|
||||||
|
gate_fetch_attempts = state_extraction_attempts = 1
|
||||||
|
gate_fetch_failures = state_extraction_failures = 0
|
||||||
|
request_delay_sec = 0.0
|
||||||
|
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None:
|
||||||
|
self._browser = None
|
||||||
|
self._cffi = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Scraper:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_e: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _visit(self, lat: float) -> None:
|
||||||
|
_Scraper.visited.append(lat)
|
||||||
|
if lat == _Scraper.timeout_on:
|
||||||
|
raise TimeoutError
|
||||||
|
|
||||||
|
async def fetch_around_multi_room(self, lat: float, *_a: Any, **kw: Any) -> list[Any]:
|
||||||
|
self._visit(lat)
|
||||||
|
if kw.get("on_combo") is not None: # yandex: единица чекпоинта — combo
|
||||||
|
kw["on_combo"](f"combo@{lat}", [])
|
||||||
|
return []
|
||||||
|
return [types.SimpleNamespace(listing_segment="vtorichka", house_ext_id=None)]
|
||||||
|
|
||||||
|
async def fetch_around(self, lat: float, *_a: Any, **_kw: Any) -> list[Any]:
|
||||||
|
self._visit(lat)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> types.SimpleNamespace:
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
scraper_fetch_mode="cffi",
|
||||||
|
scraper_proxy_url=None,
|
||||||
|
use_proxy_pool_browser=False,
|
||||||
|
browser_http_endpoint=None,
|
||||||
|
environment="test",
|
||||||
|
avito_serp_ok_not_banned=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sweep(
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
timeout_on: float | None = None,
|
||||||
|
drain_after_first: bool = False,
|
||||||
|
cancel_after_first: bool = False,
|
||||||
|
) -> _FakeDb:
|
||||||
|
_Scraper.visited = []
|
||||||
|
_Scraper.timeout_on = timeout_on
|
||||||
|
db = _FakeDb()
|
||||||
|
common: dict[str, Any] = {
|
||||||
|
"run_id": 2406,
|
||||||
|
"config": _config(),
|
||||||
|
"matcher": MagicMock(),
|
||||||
|
"anchors": [ANCHOR_A, ANCHOR_B],
|
||||||
|
"request_delay_sec": 0.0,
|
||||||
|
"shutdown_requested": lambda: drain_after_first and bool(_Scraper.visited),
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch.object(pl, "CianScraper", _Scraper),
|
||||||
|
patch.object(pl, "YandexRealtyScraper", _Scraper),
|
||||||
|
patch.object(pl, "AvitoScraper", _Scraper),
|
||||||
|
patch.object(pl, "AsyncSession", _Scraper),
|
||||||
|
patch.object(pl, "save_listings", lambda *_a, **_kw: (1, 0)),
|
||||||
|
patch.object(
|
||||||
|
pl.runs, "is_cancelled", lambda *_a: cancel_after_first and bool(_Scraper.visited)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if kind == "cian":
|
||||||
|
await pl.run_cian_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
enrich_houses=False,
|
||||||
|
detail_top_n=0,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
elif kind == "yandex":
|
||||||
|
enrichment = MagicMock()
|
||||||
|
enrichment.record_yandex_price_history.return_value = 0
|
||||||
|
await pl.run_yandex_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
enrichment=enrichment,
|
||||||
|
enrich_address=False,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await pl.run_avito_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
enrichment=MagicMock(),
|
||||||
|
enrich_houses=False,
|
||||||
|
enrich_imv=False,
|
||||||
|
detail_top_n=0,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _done_key(kind: str, anchor: tuple[float, float, str]) -> str:
|
||||||
|
return f"combo@{anchor[0]}" if kind == "yandex" else anchor[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ── (а) таймаут якоря: якорь не засчитан, следующий обработан ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["cian", "yandex"])
|
||||||
|
async def test_anchor_timeout_skips_anchor_and_continues(kind: str) -> None:
|
||||||
|
db = await _sweep(kind, timeout_on=ANCHOR_A[0])
|
||||||
|
|
||||||
|
assert _Scraper.visited == [ANCHOR_A[0], ANCHOR_B[0]]
|
||||||
|
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_B)]
|
||||||
|
assert db.counters["errors_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("broken", ["fetch_timeout", "save_failed"])
|
||||||
|
async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> None:
|
||||||
|
"""Корзина без сохранённых строк не попадает в чекпоинт.
|
||||||
|
|
||||||
|
С инкрементальным сохранением (fix/domclick-incremental-save) save_listings зовётся из
|
||||||
|
колбэка on_bucket ПОСЛЕ каждой корзины, а чекпоинт пишется там же и только
|
||||||
|
ПОСЛЕ успешного save. Поэтому корзина, чей save упал, в done_buckets не попадает,
|
||||||
|
хотя скрейпер её фетч прошёл (_s.completed_buckets её содержит). Снятая
|
||||||
|
watchdog'ом фаза — тот же инвариант: до on_bucket она не дошла.
|
||||||
|
|
||||||
|
Отметить такую корзину пройденной значило бы, что следующий прогон пропустит её
|
||||||
|
через skip_buckets навсегда (миграция 308).
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _Dc(_Scraper):
|
||||||
|
blocked = False
|
||||||
|
geo_filtered = fetch_errors = bucket_start_index = 0
|
||||||
|
buckets_completed, buckets_total = 1, 6
|
||||||
|
completed_buckets = ["st"] # noqa: RUF012
|
||||||
|
|
||||||
|
async def fetch_city(self, **kw: Any) -> list[Any]:
|
||||||
|
if broken == "fetch_timeout":
|
||||||
|
raise TimeoutError
|
||||||
|
# Заглушка обязана ВЫЗВАТЬ колбэк: путь сохранения переехал внутрь цикла
|
||||||
|
# по корзинам (serp.py fetch_city), и стаб, который просто возвращает лоты,
|
||||||
|
# проверял бы мёртвую ветку — save_listings не был бы вызван вовсе.
|
||||||
|
on_bucket = kw.get("on_bucket")
|
||||||
|
lots = [object()]
|
||||||
|
if on_bucket is not None:
|
||||||
|
on_bucket("st", lots)
|
||||||
|
return lots
|
||||||
|
|
||||||
|
saved: list[int] = []
|
||||||
|
|
||||||
|
def _save(_db: Any, lots: list[Any], **_kw: Any) -> tuple[int, int]:
|
||||||
|
if broken == "save_failed":
|
||||||
|
raise RuntimeError("save_listings упал")
|
||||||
|
saved.append(len(lots))
|
||||||
|
return len(lots), 0
|
||||||
|
|
||||||
|
db = _FakeDb()
|
||||||
|
with (
|
||||||
|
patch.object(pl, "DomClickScraper", _Dc),
|
||||||
|
patch.object(pl, "save_listings", _save),
|
||||||
|
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
|
||||||
|
):
|
||||||
|
counters = await pl.run_domclick_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
run_id=2406,
|
||||||
|
config=types.SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
pages=1,
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved == [], "тест не о том: строки сохранились"
|
||||||
|
assert counters.errors_count >= 1
|
||||||
|
assert db.counters["done_buckets"] == [], (
|
||||||
|
f"корзины без единой сохранённой строки в чекпоинте: {db.counters['done_buckets']}"
|
||||||
|
)
|
||||||
|
assert db.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (б) дрейн после первого якоря: interrupted=1, в чекпоинте ровно первый ──────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["cian", "yandex"])
|
||||||
|
async def test_drain_after_first_anchor_is_partial_and_interrupted(kind: str) -> None:
|
||||||
|
db = await _sweep(kind, drain_after_first=True)
|
||||||
|
|
||||||
|
assert _Scraper.visited == [ANCHOR_A[0]]
|
||||||
|
assert db.counters["interrupted"] == 1
|
||||||
|
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
|
||||||
|
assert db.status == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (в) пользовательская отмена после первого якоря ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["avito", "cian", "yandex"])
|
||||||
|
async def test_user_cancel_after_first_anchor_stops_without_finalizing(kind: str) -> None:
|
||||||
|
db = await _sweep(kind, cancel_after_first=True)
|
||||||
|
|
||||||
|
assert _Scraper.visited == [ANCHOR_A[0]]
|
||||||
|
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
|
||||||
|
assert "interrupted" not in db.counters
|
||||||
|
# Строку финализирует сам mark_cancelled (UI); свип только останавливается.
|
||||||
|
assert db.status == "running"
|
||||||
|
|
@ -186,6 +186,12 @@ class _FakeFetcher:
|
||||||
def report_ban(self, reason: str) -> None:
|
def report_ban(self, reason: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def request_context_reset(self) -> None:
|
||||||
|
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
|
||||||
|
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
|
||||||
|
# не поведение свипа, а собственную неполноту.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ async def test_403_bans_the_node_for_cian_only() -> None:
|
||||||
with pytest.raises(CianBlockedError):
|
with pytest.raises(CianBlockedError):
|
||||||
await _fetch(403, spy)
|
await _fetch(403, spy)
|
||||||
assert spy.mark_banned_calls == [(1, "cian")]
|
assert spy.mark_banned_calls == [(1, "cian")]
|
||||||
assert spy.mark_health_calls == [(1, False)]
|
assert spy.mark_health_calls == [] # #3310: отказ площадки не копит consecutive_fails
|
||||||
assert spy.release_calls == [1] # lease не течёт даже на бане
|
assert spy.release_calls == [1] # lease не течёт даже на бане
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ async def test_price_history_403_bans_the_node_for_cian() -> None:
|
||||||
pool = _SpyPool()
|
pool = _SpyPool()
|
||||||
result = await _run_price_history(pool, status_code=403)
|
result = await _run_price_history(pool, status_code=403)
|
||||||
assert pool.mark_banned_calls == [(9, "cian")]
|
assert pool.mark_banned_calls == [(9, "cian")]
|
||||||
assert pool.mark_health_calls == [(9, False)]
|
assert pool.mark_health_calls == [] # #3310: бан вместо mark_health(False)
|
||||||
assert result.errors == 1 # прогон честен: отказ посчитан
|
assert result.errors == 1 # прогон честен: отказ посчитан
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -335,7 +335,7 @@ async def test_zhk_resolve_403_reaches_the_pool() -> None:
|
||||||
with pytest.raises(CianBlockedError):
|
with pytest.raises(CianBlockedError):
|
||||||
await _resolve(403, spy)
|
await _resolve(403, spy)
|
||||||
assert spy.mark_banned_calls == [(9, "cian")]
|
assert spy.mark_banned_calls == [(9, "cian")]
|
||||||
assert spy.mark_health_calls == [(9, False)]
|
assert spy.mark_health_calls == [] # #3310: бан вместо mark_health(False)
|
||||||
assert spy.release_calls == [9]
|
assert spy.release_calls == [9]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
151
tradein-mvp/backend/tests/test_2898_exposure_window.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист.
|
||||||
|
|
||||||
|
Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не
|
||||||
|
сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых
|
||||||
|
объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET.
|
||||||
|
|
||||||
|
Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция
|
||||||
|
(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from app.services.estimator import (
|
||||||
|
EXPOSURE_WINDOW_MIN_N,
|
||||||
|
_exposure_window,
|
||||||
|
_fetch_exposure_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское
|
||||||
|
# округление дало бы 16); p75 на 21.75 → 23.75 → 24.
|
||||||
|
_DAYS_30 = list(range(2, 32))
|
||||||
|
|
||||||
|
|
||||||
|
def test_quartiles_match_percentile_cont() -> None:
|
||||||
|
w = _exposure_window(_DAYS_30)
|
||||||
|
assert w is not None
|
||||||
|
assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_of_input_does_not_matter() -> None:
|
||||||
|
w = _exposure_window(list(reversed(_DAYS_30)))
|
||||||
|
assert w is not None
|
||||||
|
assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skewed_sample_window() -> None:
|
||||||
|
"""Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост."""
|
||||||
|
days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400]
|
||||||
|
w = _exposure_window(days)
|
||||||
|
assert w is not None
|
||||||
|
# позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62
|
||||||
|
assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62)
|
||||||
|
|
||||||
|
|
||||||
|
def test_below_threshold_no_window() -> None:
|
||||||
|
assert EXPOSURE_WINDOW_MIN_N == 30
|
||||||
|
assert _exposure_window(_DAYS_30[:-1]) is None
|
||||||
|
assert _exposure_window([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_failure_is_graceful() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = RuntimeError("boom")
|
||||||
|
assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_without_coords_does_not_query() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == []
|
||||||
|
assert not db.execute.called
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST: окно уходит и в ответ, и в INSERT ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]:
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1)
|
||||||
|
geo = GeocodeResult(
|
||||||
|
lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim"
|
||||||
|
)
|
||||||
|
fetch = MagicMock(return_value=exposure_days)
|
||||||
|
patches = (
|
||||||
|
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||||
|
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||||
|
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
|
||||||
|
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||||
|
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None)
|
||||||
|
),
|
||||||
|
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||||
|
patch("app.services.estimator._fetch_exposure_days", new=fetch),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run() -> Any:
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
for cm in patches:
|
||||||
|
stack.enter_context(cm)
|
||||||
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
result = anyio.run(_run)
|
||||||
|
# Выборка берётся по координатам и параметрам оцениваемой квартиры.
|
||||||
|
kwargs = fetch.call_args.kwargs
|
||||||
|
assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == (
|
||||||
|
56.838,
|
||||||
|
60.595,
|
||||||
|
1,
|
||||||
|
40.0,
|
||||||
|
)
|
||||||
|
for call in db.execute.call_args_list:
|
||||||
|
sql = str(getattr(call.args[0], "text", call.args[0]))
|
||||||
|
if "INSERT INTO trade_in_estimates" in sql:
|
||||||
|
return result, call.args[1]
|
||||||
|
raise AssertionError("INSERT INTO trade_in_estimates не вызван")
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_persists_window_and_returns_it() -> None:
|
||||||
|
result, params = _run_estimate(_DAYS_30)
|
||||||
|
assert (
|
||||||
|
params["est_days_p25"],
|
||||||
|
params["est_days_p50"],
|
||||||
|
params["est_days_p75"],
|
||||||
|
params["est_days_n"],
|
||||||
|
) == (9, 17, 24, 30)
|
||||||
|
assert result.exposure_window is not None
|
||||||
|
assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24)
|
||||||
|
# Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений.
|
||||||
|
assert result.est_days_on_market == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_small_sample_persists_nulls() -> None:
|
||||||
|
result, params = _run_estimate(_DAYS_30[:-1])
|
||||||
|
assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
assert result.exposure_window is None
|
||||||
|
assert result.est_days_on_market is None
|
||||||
246
tradein-mvp/backend/tests/test_2993_snapshot_change_only.py
Normal file
246
tradein-mvp/backend/tests/test_2993_snapshot_change_only.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""#2993: listing_source_snapshots получает строку только при изменении источника.
|
||||||
|
|
||||||
|
Проверки идут по ЗНАЧЕНИЯМ в живом Postgres: какие источники получили строку за
|
||||||
|
сегодня, с какими значениями, и какие события записал event-diff поверх них.
|
||||||
|
Без реальной БД файл self-skip'ается, как соседние live-тесты (в CI есть
|
||||||
|
postgres, см. ci-tradein.yml). Вся синтетика живёт в одной транзакции, очистка —
|
||||||
|
rollback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.tasks import listing_source_snapshot as snap_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session() -> Any | None:
|
||||||
|
"""Тот же контракт, что у test_revisit_floor_lateral_lookup.py."""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
engine = create_engine(dsn, future=True)
|
||||||
|
conn = engine.connect()
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
conn.close()
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
|
||||||
|
_PRICE = 5_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _source(db: Any, tag: str, *, price: int, payload: str, seen_days_ago: int) -> int:
|
||||||
|
ext_id = f"zzz2993-{tag}-{uuid.uuid4().hex[:8]}"
|
||||||
|
listing_id = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO listings
|
||||||
|
(source, source_url, source_id, dedup_hash, price_rub,
|
||||||
|
is_active, scraped_at, last_seen_at)
|
||||||
|
VALUES ('cian', :url, :ext_id, :ext_id, :price, true, now(), now())
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"url": f"https://example.test/2993/{ext_id}", "ext_id": ext_id, "price": price},
|
||||||
|
).scalar_one()
|
||||||
|
return int(
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO listing_sources
|
||||||
|
(listing_id, ext_source, ext_id, confidence, matched_method,
|
||||||
|
price_rub, raw_payload, last_seen_at)
|
||||||
|
VALUES (:listing_id, 'cian', :ext_id, 1.0, 'test',
|
||||||
|
:price, CAST(:payload AS jsonb),
|
||||||
|
now() - make_interval(days => :seen))
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"listing_id": listing_id,
|
||||||
|
"ext_id": ext_id,
|
||||||
|
"price": price,
|
||||||
|
"payload": payload,
|
||||||
|
"seen": seen_days_ago,
|
||||||
|
},
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(
|
||||||
|
db: Any,
|
||||||
|
lsid: int,
|
||||||
|
*,
|
||||||
|
days_ago: int,
|
||||||
|
price: int,
|
||||||
|
payload: str,
|
||||||
|
seen_days_ago: int,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
# now() постоянен внутри транзакции — last_seen_at снимка совпадает с
|
||||||
|
# listing_sources.last_seen_at до микросекунды, если seen_days_ago тот же.
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO listing_source_snapshots
|
||||||
|
(listing_source_id, snapshot_date, price_rub, is_active,
|
||||||
|
last_seen_at, payload_hash)
|
||||||
|
VALUES (:lsid, CURRENT_DATE - :days_ago, :price, :is_active,
|
||||||
|
now() - make_interval(days => :seen),
|
||||||
|
md5(CAST(:payload AS jsonb)::text))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"lsid": lsid,
|
||||||
|
"days_ago": days_ago,
|
||||||
|
"price": price,
|
||||||
|
"is_active": is_active,
|
||||||
|
"seen": seen_days_ago,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_writer(db: Any) -> None:
|
||||||
|
db.execute(
|
||||||
|
snap_mod._SNAPSHOT_SQL,
|
||||||
|
{"freshness_days": snap_mod.FRESHNESS_WINDOW_DAYS, "run_id": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _today(db: Any, ids: list[int]) -> dict[int, tuple[int, bool]]:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT listing_source_id, price_rub, is_active
|
||||||
|
FROM listing_source_snapshots
|
||||||
|
WHERE snapshot_date = CURRENT_DATE AND listing_source_id = ANY(:ids)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"ids": ids},
|
||||||
|
).fetchall()
|
||||||
|
return {int(r[0]): (int(r[1]), bool(r[2])) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Any) -> dict[str, int]:
|
||||||
|
"""Семь источников: по одному на каждую ветку условия записи."""
|
||||||
|
p = '{"v": 1}'
|
||||||
|
ids = {
|
||||||
|
"same": _source(db, "same", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
"same_gap": _source(db, "same_gap", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
"price": _source(db, "price", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
"payload": _source(db, "payload", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
"seen": _source(db, "seen", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
"active": _source(db, "active", price=_PRICE, payload=p, seen_days_ago=10),
|
||||||
|
"new": _source(db, "new", price=_PRICE, payload=p, seen_days_ago=1),
|
||||||
|
}
|
||||||
|
_snapshot(db, ids["same"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=1)
|
||||||
|
# Последний снимок пятидневной давности совпадает, а более старый — нет:
|
||||||
|
# сравнивать обязаны с ПОСЛЕДНИМ, а не с любым из прошлых.
|
||||||
|
_snapshot(db, ids["same_gap"], days_ago=9, price=_PRICE - 1, payload=p, seen_days_ago=9)
|
||||||
|
_snapshot(db, ids["same_gap"], days_ago=5, price=_PRICE, payload=p, seen_days_ago=1)
|
||||||
|
_snapshot(db, ids["price"], days_ago=1, price=4_900_000, payload=p, seen_days_ago=1)
|
||||||
|
_snapshot(db, ids["payload"], days_ago=1, price=_PRICE, payload='{"v": 0}', seen_days_ago=1)
|
||||||
|
_snapshot(db, ids["seen"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=3)
|
||||||
|
# last_seen_at тот же, но 10 суток > окна свежести: вчера снимок был активным.
|
||||||
|
_snapshot(db, ids["active"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=10)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_changed_or_new_sources_get_a_row_today() -> None:
|
||||||
|
db = _live_session()
|
||||||
|
try:
|
||||||
|
ids = _seed(db)
|
||||||
|
_run_writer(db)
|
||||||
|
today = _today(db, list(ids.values()))
|
||||||
|
by_tag = {tag: today.get(lsid) for tag, lsid in ids.items()}
|
||||||
|
|
||||||
|
assert by_tag == {
|
||||||
|
"same": None,
|
||||||
|
"same_gap": None,
|
||||||
|
"price": (_PRICE, True),
|
||||||
|
"payload": (_PRICE, True),
|
||||||
|
"seen": (_PRICE, True),
|
||||||
|
"active": (_PRICE, False),
|
||||||
|
"new": (_PRICE, True),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_survive_change_only_writes() -> None:
|
||||||
|
"""event-diff видит только записанные сегодня строки — и не теряет ни одного события."""
|
||||||
|
db = _live_session()
|
||||||
|
try:
|
||||||
|
ids = _seed(db)
|
||||||
|
_run_writer(db)
|
||||||
|
db.execute(snap_mod._EVENT_DIFF_SQL).fetchall()
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT listing_source_id, event_type, diff_percent
|
||||||
|
FROM listing_source_events
|
||||||
|
WHERE listing_source_id = ANY(:ids)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"ids": list(ids.values())},
|
||||||
|
).fetchall()
|
||||||
|
tag_of = {lsid: tag for tag, lsid in ids.items()}
|
||||||
|
events = {(tag_of[int(r[0])], r[1], None if r[2] is None else float(r[2])) for r in rows}
|
||||||
|
|
||||||
|
assert events == {
|
||||||
|
("price", "price_change", 2.0408), # round((5.0-4.9)/4.9*100, 4)
|
||||||
|
("payload", "edited", None),
|
||||||
|
("new", "first_seen", None),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_same_day_compares_with_todays_row() -> None:
|
||||||
|
"""Второй прогон в те же сутки сравнивает с уже записанной сегодня строкой.
|
||||||
|
|
||||||
|
price вернулся к вчерашней цене — сегодняшняя строка обязана перезаписаться
|
||||||
|
(иначе на сегодня осталась бы цена, которой у источника больше нет); same
|
||||||
|
изменился после первого прогона — строка появляется; seen не менялся после
|
||||||
|
первого прогона — строка остаётся как была.
|
||||||
|
"""
|
||||||
|
db = _live_session()
|
||||||
|
try:
|
||||||
|
ids = _seed(db)
|
||||||
|
_run_writer(db)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE listing_sources SET price_rub = 4900000 WHERE id = :id"),
|
||||||
|
{"id": ids["price"]},
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE listing_sources SET price_rub = 5100000 WHERE id = :id"),
|
||||||
|
{"id": ids["same"]},
|
||||||
|
)
|
||||||
|
_run_writer(db)
|
||||||
|
today = _today(db, list(ids.values()))
|
||||||
|
|
||||||
|
assert today.get(ids["price"]) == (4_900_000, True)
|
||||||
|
assert today.get(ids["same"]) == (5_100_000, True)
|
||||||
|
assert today.get(ids["seen"]) == (_PRICE, True)
|
||||||
|
assert ids["same_gap"] not in today
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue