Compare commits

..

No commits in common. "main" and "fix/3493-celery-exporter-memory-alert" have entirely different histories.

200 changed files with 2241 additions and 12616 deletions

View file

@ -233,10 +233,6 @@ 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` — то есть
@ -260,12 +256,10 @@ jobs:
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания. # не бежали ни разу с момента написания.
# #
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
# ST_DWithin по geography, без расширения его не исполнить. На plain # нужен, в отличие от tradein-лэйна.
# 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 по
@ -296,14 +290,15 @@ jobs:
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
# #
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна. # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
# здесь не нужен вовсе, в отличие от 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 \
postgis/postgis:16-3.4 postgres:16
ready="" ready=""
for _ in $(seq 1 45); do for _ in $(seq 1 45); do

View file

@ -91,16 +91,12 @@ 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,METRICS_WATCHDOG_PING_URL 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
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 }}
@ -197,24 +193,6 @@ 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 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь # запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не # хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
@ -243,8 +221,7 @@ 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:-}" \
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}' \
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
@ -411,15 +388,6 @@ 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 положила бы их все, включая
@ -438,6 +406,15 @@ 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 выше:
@ -447,41 +424,27 @@ jobs:
# незамеченными (#3467): lastConfigTime совпадал со startTime # незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**. # контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
# #
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый # У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО # /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# файла: после `git reset --hard` за путём в контейнере остаётся # после `git reset --hard` подхватывается без пересоздания
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и # контейнера. --web.enable-lifecycle уже включён в compose ради
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09). # этого шага (см. docker-compose.metrics.yml) — просто раньше
# Поэтому при расхождении инода контейнер пересоздаётся (#3467). # никто не звал сам reload.
# --web.enable-lifecycle включён в compose ради этого шага.
# #
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО # promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым # положить работающий Prometheus молчаливым откатом на дефолты.
# контейнером того же образа. `docker exec` в работающий проверял #
# бы тот самый старый инод, а не то, что сейчас применится. # Плюс юнит-тесты правил (#3493): синтаксически верное правило может
# Синтаксически верное правило может врать по смыслу — # врать по смыслу — `count(x == 1) == 0` от пустого вектора не
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, — # срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
# отсюда test rules. # экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
if docker run --rm --entrypoint sh \ # одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \ if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \
-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 && docker run --rm --entrypoint promtool \
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \ -v /opt/gendesign/ops/metrics/prometheus:/work:ro \
gendesign-metrics docker-compose.metrics.yml \ "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml test rules /work/tests/infra_test.yml; then
# ── Приёмка ────────────────────────────────────────────────
# После возможного пересоздания выше — ждём оба процесса,
# которые дальше перечитывают конфиг по 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
@ -507,30 +470,6 @@ 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
@ -622,21 +561,6 @@ 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
@ -706,10 +630,3 @@ 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

View file

@ -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 — точечное # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это # перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про # снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.

View file

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

View file

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

View file

@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
""" """
objects_with_velocity_data: int # число комплексов с velocity-данными objects_with_velocity_data: int # число комплексов с velocity-данными
objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage) objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
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 —

View file

@ -587,6 +587,19 @@ 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 помещения/парковка фазы.

View file

@ -19,6 +19,7 @@ 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
@ -259,8 +260,10 @@ 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: кварталы с broken/NULL geom дают quarter_bbox_3857() == None → # Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot. # кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
# пропускаются, а на следующем 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])
@ -275,6 +278,25 @@ 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 друг друга, поэтому в
@ -657,7 +679,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: # квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик # begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными. # инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
try: try:
@ -1454,6 +1476,114 @@ def upsert_quarter_stats(
) )
# ── cad_territorial_zones upsert ────────────────────────────────────────────
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
Args:
db: SQLAlchemy session.
quarter_cad: кадастровый номер квартала (3 сегмента).
features: list of raw feature dicts от get_features_in_bbox_grid.
Returns:
Количество успешно upserted строк.
"""
inserted = 0
for f in features:
props: dict = f.get("properties") or {}
geom = f.get("geometry")
geom_geojson: str | None = json.dumps(geom) if geom else None
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
if _raw_id:
zone_id = str(_raw_id)
else:
_props_hash = hashlib.md5(
json.dumps(props, sort_keys=True).encode("utf-8")
).hexdigest()[:12]
zone_id = f"{quarter_cad}_{_props_hash}"
zone_code = (
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
)
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
# db.execute() в этом loop — outer tx гарантирована.
with db.begin_nested():
db.execute(
text("""
INSERT INTO cad_territorial_zones
(quarter_cad, zone_id, zone_code, zone_name,
permitted_use, raw_props, geom)
VALUES (
CAST(:quarter_cad AS text),
CAST(:zone_id AS text),
CAST(:zone_code AS text),
CAST(:zone_name AS text),
CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography
ELSE NULL
END
)
ON CONFLICT (zone_id) DO UPDATE SET
zone_code = EXCLUDED.zone_code,
zone_name = EXCLUDED.zone_name,
permitted_use = EXCLUDED.permitted_use,
raw_props = EXCLUDED.raw_props,
geom = EXCLUDED.geom,
fetched_at = NOW()
"""),
{
"quarter_cad": quarter_cad,
"zone_id": zone_id,
"zone_code": zone_code,
"zone_name": zone_name,
"permitted_use": permitted_use,
"raw_props": json.dumps(props, ensure_ascii=False),
"geom": geom_geojson,
},
)
inserted += 1
except Exception as e:
logger.warning(
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
zone_id,
quarter_cad,
e,
)
db.commit()
return inserted
# ── Утилиты ────────────────────────────────────────────────────────────────── # ── Утилиты ──────────────────────────────────────────────────────────────────

View file

@ -30,15 +30,11 @@ _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")
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон # Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга _RE_ACT_NUMBER = re.compile(
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12». re.UNICODE | re.IGNORECASE,
_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})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не # Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
@ -237,9 +233,7 @@ 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( act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
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
) )
@ -295,26 +289,9 @@ def extract_izyatie_records(
# ── Вспомогательные функции ──────────────────────────────────────────────────── # ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None: def _extract_act_number(text: str) -> 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))
@ -326,17 +303,6 @@ 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.
@ -349,10 +315,18 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок. актуальные изъятия, ни сверить срок.
""" """
m = _act_date_match(text, require_act_context=require_act_context) for m in _RE_ACT_DATE.finditer(text):
if m is None: if require_act_context and not _act_context_matches(text, m.start()):
return None continue
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}" day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
return None
def _extract_purpose(text: str) -> str | None: def _extract_purpose(text: str) -> str | None:

View file

@ -31,13 +31,8 @@ _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]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])", r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE, re.UNICODE,
) )

View file

@ -1109,6 +1109,9 @@ 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)
@ -1129,16 +1132,13 @@ 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=len(complex_groups), objects_total_in_radius=groups_total_pre_filter,
raw_objects_total=raw_objects_total_in_radius, raw_objects_total=raw_objects_total_in_radius,
) )

View file

@ -344,26 +344,21 @@ _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,
cs.source_id AS project_name, c.id AS complex_id
-- #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,
@ -374,20 +369,6 @@ _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)
@ -395,13 +376,7 @@ _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.
@ -409,18 +384,15 @@ _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'а
-- (корпуса у Объектива corpus_name внутри проекта, не отдельные имена). -- (у комплекса может быть несколько корпус-project_name velocity
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
SELECT nc.obj_id, SELECT nc.obj_id,
nc.project_name AS objective_complex_name ol.project_name AS objective_complex_name
FROM nearest_cx nc FROM nearest_cx nc
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не JOIN objective_lots ol
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» ON ol.complex_id = nc.complex_id
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз 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
@ -633,11 +605,9 @@ _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_sources # GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON # objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и # velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
# 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("""
@ -654,17 +624,16 @@ _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,
cs.source_id AS project_name, c.id AS complex_id
-- #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,
@ -675,17 +644,6 @@ _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
@ -696,13 +654,7 @@ _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
@ -710,10 +662,8 @@ _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.project_name = nc.project_name ON ol.complex_id = nc.complex_id
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,

View file

@ -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 в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat # territorial_zones dumps (Phase 2.5 bulk_harvest, 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,13 +346,11 @@ 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. Исходная причина «hard-ban, cooldown 24-48h» оказалась # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05. # cooldown 24-48h (проверить через targeted test).
# Включать только после решения по прокси и принятого по числу строк
# 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 МСК
@ -364,11 +362,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) та же блокировка StormWall на IP # DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. # каталог-квартиры используют тот же 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 строк — включать смысла нет.
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру. # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с 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"] = {

View file

@ -110,25 +110,6 @@ _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
@ -136,7 +117,6 @@ 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:

View file

@ -24,9 +24,8 @@ 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): наш.дом.рф за ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) WAF-cooldown на
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает; VPS IP; включается вручную после проверки targeted-тестом.
включать после прокси и принятого kn-прогона (#3307).
""" """
from __future__ import annotations from __future__ import annotations
@ -117,7 +116,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 отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне Beat отключён (WAF cooldown); при ручном/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.

View file

@ -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-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443). Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
""" """
from __future__ import annotations from __future__ import annotations

View file

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

View file

@ -1,11 +1,10 @@
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. """Тесты для WAF cooldown guard (#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, что закрыт StormWall (#2443). Beat schedule /сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
для обоих тасков отключён по этой причине эти эндпоинты должны отказывать в (#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа то, по чему оператор должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не smoke-тест/re-trigger не углубил бан.
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
""" """
from __future__ import annotations from __future__ import annotations
@ -20,15 +19,6 @@ 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
@ -43,7 +33,9 @@ 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
_assert_refusal_names_real_condition(resp.json()["detail"]) detail = 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()
@ -76,7 +68,9 @@ 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
_assert_refusal_names_real_condition(resp.json()["detail"]) detail = 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()

View file

@ -264,10 +264,8 @@ 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 — знаменатель coverage, после исключения 0 (#2464); # objects_total_in_radius = 1 (до исключения)
# число obj_id в радиусе до исключения — raw_objects_total assert body["data_quality"]["objects_total_in_radius"] == 1
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()

View file

@ -1,4 +1,4 @@
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164). """Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты Прод-факт, 27.08. Канал доставки включили, `notify()` заработал и алерты
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
@ -16,7 +16,9 @@
реально ушло бы в сеть. реально ушло бы в сеть.
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
бы с оригиналом на первой же правке. бы с оригиналом на первой же правке. Сорсить файл целиком нельзя у
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
настоящие сетевые проверки.
""" """
from __future__ import annotations from __future__ import annotations
@ -32,6 +34,7 @@ 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'
@ -102,7 +105,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}"')
@ -150,73 +153,3 @@ 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})"
)

View file

@ -14,9 +14,7 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
Как это ломало алерты: Как это ломало алерты:
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был * `ops/uptime-healthcheck.sh` ОДИН `curl`, дальше `|| log WARN`. Каждый
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) ОДИН
`curl`, дальше `|| log WARN`. Каждый
отказ терял уведомление целиком. Watchdog, который не может дозваться, отказ терял уведомление целиком. Watchdog, который не может дозваться,
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
@ -53,6 +51,7 @@ 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 в
@ -66,7 +65,8 @@ 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'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh. Весь скрипт source'ить нельзя: uptime-healthcheck.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", [LIB_BACKUP]) @pytest.mark.parametrize("script_rel", [UPTIME, 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", [LIB_BACKUP]) @pytest.mark.parametrize("script_rel", [UPTIME, 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,6 +164,12 @@ 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:
"""Транзиентный таймаут не должен трогать запасной канал. """Транзиентный таймаут не должен трогать запасной канал.

View file

@ -21,11 +21,7 @@
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
@ -183,48 +179,3 @@ 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

View file

@ -21,9 +21,8 @@
переменной и инфраструктурная тема «метрики» оставалась пустой, а весь трафик, переменной и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
развести: инфраструктура в «метрики», клиентские инциденты в «алерты». развести: инфраструктура в «метрики», клиентские инциденты в «алерты».
Тест ниже закрепляет именно это для единственного оставшегося прямого Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают
получателя `telegram` (Watchdog с 17.09 больше не шлёт в Telegram вовсе, ИНФРАСТРУКТУРНУЮ тему, а не общую.
у него теперь внешний webhook-приёмник `watchdog-ping`, см. шаблон).
Тесты рендерят шаблон обоими способами и разбирают результат как YAML Тесты рендерят шаблон обоими способами и разбирают результат как YAML
проверяется фактический конфиг, а не наличие нужных слов в тексте. проверяется фактический конфиг, а не наличие нужных слов в тексте.
@ -44,56 +43,9 @@ 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:
def _envsubst_allowlist() -> set[str]: """Повторяет подстановку деплоя и разбирает результат как YAML.
"""Реальный список переменных, которые деплой передаёт в 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). Тема
клиентских инцидентов сюда не подставляется вовсе: маршрут клиентских инцидентов сюда не подставляется вовсе: маршрут
@ -103,18 +55,11 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
""" """
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 = (
values = dict(_DUMMY_VALUES) text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line .replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
if watchdog_ping_block is not None: .replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
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, (
"в отрендеренном конфиге остался литерал плейсхолдера — " "в отрендеренном конфиге остался литерал плейсхолдера — "
"значит в шаблоне появилась подстановка, о которой тест не знает" "значит в шаблоне появилась подстановка, о которой тест не знает"
@ -122,37 +67,6 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
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", []):
@ -162,16 +76,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) оставалась брали топик из одной переменной с клиентскими инцидентами, и тема «метрики»
пустой. С 17.09 (устранение шума Watchdog) прямой Telegram-получатель (245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив»
остался один `telegram`; Watchdog теперь пингует внешний будет капать мимо, и это заметят не сразу сюда же попадёт и весь
deadman-приёмник вебхуком (`watchdog-ping`, без topic вовсе не Telegram). инфраструктурный шум.
""" """
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE)) cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
assert len(cfgs) >= 1, f"ожидался хотя бы один получатель telegram, найдено {len(cfgs)}" assert len(cfgs) >= 2, 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}"

View file

@ -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: watchdog-ping")] block = block[: block.index("- name: telegram-heartbeat")]
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис" assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки" assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
# У прочих приёмников прямой путь сохранён. # У прочих приёмников прямой путь сохранён.

View file

@ -10,41 +10,28 @@ 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. `POST /-/reload` лечит правила они подключены в этом же workflow только для Prometheus починка не пересоздание
КАТАЛОГОМ. prometheus.yml же бинд-маунт ОДНОГО файла: reload перечитывает контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён, пути заново, так что новый инод после `git reset --hard` подхватывается
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 ЕСТЬ и идёт по файлам с диска, Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
(2) reload и пересоздание ТОЛЬКО после успешной валидации, (3) шаг обязан ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы подтверждён сменой lastConfigTime.
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()
@ -78,7 +65,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 = _guard_start(text) guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
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, (
@ -90,7 +77,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 = _guard_start(text) guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
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]
@ -123,54 +110,3 @@ 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, "отказ перезагрузки не роняет деплой"

View file

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

View file

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

View file

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

View file

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

View file

@ -433,43 +433,7 @@ 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 == []
# после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id assert resp.data_quality.objects_total_in_radius == 1
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 ──────────────────────

View file

@ -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 фаза корректно пропускается (тест про # grid-walk + territorial_zones фазы корректно пропускаются (тест про
# 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 фаза пропускается (тесты про snapshot / # grid-walk + territorial_zones фазы пропускаются (тесты про 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 фаза пропускается (тесты про snapshot / # grid-walk + territorial_zones фазы пропускаются (тесты про 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 фаза пропускается (тесты про snapshot / # grid-walk + territorial_zones фазы пропускаются (тесты про 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 фаза пропускается (тесты про snapshot / # grid-walk + territorial_zones фазы пропускаются (тесты про 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 фаза пропускается (тесты про snapshot / # grid-walk + territorial_zones фазы пропускаются (тесты про 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,6 +1518,7 @@ 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]] = []
@ -1556,42 +1557,6 @@ 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 ─────────────────────

View file

@ -144,29 +144,6 @@ 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 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции

View file

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

View file

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

View file

@ -1919,17 +1919,17 @@ export interface paths {
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, * @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-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). * Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint для ad-hoc
* Этот endpoint для ad-hoc запуска (smoke-тест или повторный pass для * запуска (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, грузит всё подряд.
* *
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc * beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
* запуск без явного подтверждения оператора запрещён (условие #3307). * re-trigger без явного подтверждения оператора запрещён.
*/ */
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.
* *
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же * same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
* блокировкой StormWall (условие #3307). * того же WAF-бана.
*/ */
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;

View file

@ -10,8 +10,9 @@
# 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), so an # Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
# hourly cron doesn't spam Telegram once a backup is already known to be stale. # discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
# Telegram once a backup is already known to be stale.
# #
# Usage (cron — one line per sentinel, run more often than the backup itself # 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
@ -26,10 +27,9 @@
# /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: notify() in ops/lib-backup.sh — creds in # Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/ # notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first # TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
# 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).

View file

@ -22,11 +22,7 @@
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ч.
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env 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
# (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, поэтому запись
@ -37,11 +33,11 @@
# file-level копия работающего узла может оказаться рассогласованной. Время — # file-level копия работающего узла может оказаться рассогласованной. Время —
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), # 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
# чтобы три задачи не дрались за диск и сеть одновременно. # чтобы три задачи не дрались за диск и сеть одновременно.
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 45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: # Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
# сутки плюс запас на сдвиг прогона. # сутки плюс запас на сдвиг прогона.
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 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
# ── Уборка докера ─────────────────────────────────────────────────────────── # ── Уборка докера ───────────────────────────────────────────────────────────
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их # Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их

View file

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

View file

@ -51,10 +51,5 @@ 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

View file

@ -27,17 +27,6 @@
#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

View file

@ -24,8 +24,13 @@
# #
# 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/TELEGRAM_TOPIC_ID from THIS file. # TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
# Without the first two set, ops/check-backup-staleness.sh still logs, just # /etc/default/gendesign-uptime — deliberately a separate config so backup
# alerting doesn't depend on the uptime watchdog's env file existing).
# SAME variable names as ops/gendesign-uptime.default.example — point both
# files at the same bot/chat if you want one Telegram destination for
# everything, or use different bots/chats if you'd rather split the noise.
# Without these two set, ops/check-backup-staleness.sh still logs, just
# 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) —
@ -36,14 +41,9 @@
#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) --- # --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
#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

View file

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

View file

@ -5,7 +5,9 @@
# 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). # notify() when Telegram vars are actually set — curl is already a hard
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
# new dependency).
# #
# 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):
@ -16,8 +18,14 @@
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# --- notify ------------------------------------------------------------ # --- notify ------------------------------------------------------------
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env # Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164. # this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
# backup alerting doesn't depend on the uptime watchdog's config being
# present, and vice versa. Point both files at the same bot/chat if you want
# one Telegram destination for everything — that's an ops choice, not this
# script's concern.
# #
# 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
@ -145,9 +153,12 @@ sentinel_age_hours() {
} }
# --- transition-tracked alert state -------------------------------------- # --- transition-tracked alert state --------------------------------------
# A flat "<label> <status>" file, one line per label, so repeated runs alert # Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# 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; }

View file

@ -57,7 +57,6 @@ 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
@ -77,16 +76,6 @@ 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()
@ -330,9 +319,7 @@ 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_request), и ошибки разбора запроса log.info("%s %s", self.address_string(), fmt % args)
# (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)

View file

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

View file

@ -133,29 +133,6 @@
} }
}, },
{
"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 } },
{ {

View file

@ -63,85 +63,40 @@ groups:
- name: host - name: host
interval: 60s interval: 60s
rules: rules:
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ # Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
# (host=infra). Проценты на дисках разного размера значат разное — # порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
# 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: |
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"} (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ 1073741824 < 30 / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
for: 15m for: 15m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Свободно на диске меньше 30 ГБ" summary: "Диск занят больше 85 %"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ." description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
- alert: DiskSpaceCritical - alert: DiskSpaceCritical
expr: | expr: |
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"} (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ 1073741824 < 15 / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
for: 5m for: 5m
labels: labels:
severity: critical severity: critical
annotations: annotations:
summary: "Свободно на диске меньше 15 ГБ" summary: "Диск почти кончился"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается." description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. 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: |
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600)) predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
/ 1073741824 > 0
for: 30m for: 30m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "По текущему темпу диск кончится за сутки" summary: "По текущему темпу диск кончится за сутки"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ." description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
- alert: MemoryPressure - alert: MemoryPressure
expr: | expr: |
@ -260,40 +215,6 @@ 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.0817.09 (61 857 точек): 140
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
#
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
# заводится в UI GlitchTip, а не кодом.
- alert: MeraBackendDown
expr: |
up{job="app", app="mera"} == 0
or absent(up{job="app", app="mera"})
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Гейт по RPS внутри знаменателя — тот же приём, что у # Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть, # PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит # иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
@ -487,44 +408,3 @@ 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 часов — похоже, её не заметили или на неё забили."

View file

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

View file

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

View file

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

171
ops/uptime-healthcheck.sh Executable file
View file

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

View file

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

View file

@ -43,7 +43,6 @@ 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
@ -75,14 +74,6 @@ _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):
@ -243,21 +234,19 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client() client = get_telegram_client()
try: try:
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S): await client.send_message(
await client.send_message( chat_id=settings.telegram_alerts_chat_id,
chat_id=settings.telegram_alerts_chat_id, text=text,
text=text, message_thread_id=settings.telegram_alerts_topic_id or None,
message_thread_id=settings.telegram_alerts_topic_id or None, # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не # может легально висеть воркерные минуты ретраев.
# может легально висеть воркерные минуты ретраев. timeout=_INTERACTIVE_SEND_TIMEOUT_S,
timeout=_INTERACTIVE_SEND_TIMEOUT_S, max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES, )
) except TelegramError:
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок # 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого # сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон # текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry). # (#3471, см. app.tasks.glitchtip_alert_retry).

View file

@ -2,16 +2,9 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки: POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
уходит сообщением в 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 = ...`), без проверки владельца любой
@ -32,17 +25,15 @@ 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, BackgroundTasks, Depends, Header, HTTPException, Request from fastapi import APIRouter, 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 SessionLocal, get_db from app.core.db import 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__)
@ -108,49 +99,11 @@ 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."""
@ -232,15 +185,6 @@ 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(),

View file

@ -31,7 +31,6 @@ from app.schemas.trade_in import (
CoverageProbeInput, CoverageProbeInput,
CoverageProbeResponse, CoverageProbeResponse,
DkpCorridor, DkpCorridor,
ExposureWindow,
HouseAnalyticsKpi, HouseAnalyticsKpi,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
@ -391,7 +390,6 @@ 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(
""" """
@ -420,10 +418,6 @@ 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)
""" """
@ -458,10 +452,6 @@ 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(
@ -688,8 +678,7 @@ 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}
@ -729,7 +718,6 @@ 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,
) )
@ -854,19 +842,6 @@ 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,
@ -921,10 +896,6 @@ 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),
) )

View file

@ -44,12 +44,6 @@ _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")
@ -384,6 +378,17 @@ 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 ₽/м², не имея рядом ни одного
@ -403,6 +408,25 @@ 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²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > 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%-й
@ -497,13 +521,128 @@ 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 /
@ -572,16 +711,6 @@ 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
@ -596,6 +725,20 @@ 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, не кэшируется).
@ -620,7 +763,26 @@ 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: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ── # ── #764: per-cadastral-quarter price index correction ───────────────────
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → skip (location in median).
estimate_quarter_match_skip_ratio: float = 0.6
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
estimate_quarter_index_max_for_small_n: float = 2.0
estimate_quarter_index_small_n_threshold: int = 50
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в 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).
@ -809,7 +971,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, клампа expected_sold ≤ asking не задевают. # 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его # NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод # bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025. # обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
@ -1032,6 +1194,14 @@ 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.
@ -1051,19 +1221,25 @@ 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 вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL. # detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046). # SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# True = curl_cffi через settings.scraper_proxy_url. # (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# #
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который # ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4 # отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование # 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован # браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только # браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
# environment'ом сервиса scraper, а tradein-backend (ручка админки # Прод переведён на браузер через docker-compose.prod.yml (environment
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом. # перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
# других окружений вслепую.
avito_detail_backfill_use_curl: bool = Field( avito_detail_backfill_use_curl: bool = Field(
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
) )
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший

View file

@ -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'а
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи. ценовой шкале. None если для дома нет свежей IMV-записи.
""" """
@ -163,13 +163,9 @@ class DkpCorridor(BaseModel):
[low,high]×slack добавляется текстовая пометка. [low,high]×slack добавляется текстовая пометка.
None / count=0 если по улице нет сопоставимых сделок. None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N #3452: «advisory» здесь НЕ безусловно. При count >= estimate_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 # число ДКП-сделок в выборке
@ -196,13 +192,10 @@ class DkpCorridor(BaseModel):
def advisory_only(self) -> bool: def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены. """#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=1..9 на экране была неотличима от работающей. цену зона n=3..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
@ -214,9 +207,9 @@ class DkpCorridor(BaseModel):
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом. вторым числом.
""" """
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light from app.core.config import settings # локально: schemas остаётся import-light
return self.count < CORRIDOR_CLAMP_MIN_N return self.count < settings.estimate_corridor_clamp_min_n
class PriceTrendPoint(BaseModel): class PriceTrendPoint(BaseModel):
@ -230,21 +223,6 @@ 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
@ -307,11 +285,7 @@ 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
# #2898: окно срока экспозиции (p25p75) — показывать его, а не одно число. est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
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) →
@ -370,9 +344,6 @@ 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:

View file

@ -67,12 +67,8 @@ 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)
@ -110,16 +106,10 @@ 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)
@ -127,7 +117,6 @@ 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)
@ -142,11 +131,8 @@ 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:
@ -156,11 +142,9 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | None: def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None. """Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет, Возвращает "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).
@ -192,11 +176,6 @@ 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} в окне "

View file

@ -34,7 +34,6 @@ 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,
@ -56,7 +55,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 CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings from app.core.config import LISTINGS_FRESH_DAYS, Settings, 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,
@ -64,7 +63,6 @@ from app.schemas.trade_in import (
AvitoImvSummary, AvitoImvSummary,
CianValuationSummary, CianValuationSummary,
DkpCorridor, DkpCorridor,
ExposureWindow,
PriceTrendPoint, PriceTrendPoint,
TradeInEstimateInput, TradeInEstimateInput,
) )
@ -285,32 +283,6 @@ 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:
@ -344,7 +316,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>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor # n>=estimate_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)
@ -444,107 +416,6 @@ 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²)×(1tol)
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
SB_MAD_K_SMALL_N = 2.5
SB_SMALL_N_THRESHOLD = 10
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
# FSD<0.15) проходят без изменений.
SB_GATE_MIN_N = 3
SB_GATE_MAX_FSD = 0.20
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -2117,29 +1988,15 @@ 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))
# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут # Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с # региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей # _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в # чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities # (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS # сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а
# 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(_TARGET_CITIES, key=len, reverse=True)) + "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True))
+ r")\b" + r")\b"
) )
@ -2147,8 +2004,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).
Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
развёрток) по границе слова/фразы работает и для forward-формата («Екатеринбург, ул. границе слова/фразы работает и для forward-формата («Екатеринбург, ул.
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
важна. ёе нормализация ДО поиска (симметрично _normalize_building_key). важна. ёе нормализация ДО поиска (симметрично _normalize_building_key).
@ -2156,7 +2013,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>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor + n>=estimate_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:
@ -2366,7 +2223,7 @@ def _fetch_dkp_corridor(
latest_deal = dd latest_deal = dd
ppm2_values = sorted(adjusted) ppm2_values = sorted(adjusted)
# #3452: чем получен коридор — улицей или расширением до города (widen ниже). # #3452: чем получен коридор — улицей или расширением до города (widen ниже).
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и # Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит # «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
# (pydantic игнорирует лишние kwargs), это служебная метка для логов. # (pydantic игнорирует лишние kwargs), это служебная метка для логов.
scope = "street" scope = "street"
@ -2735,7 +2592,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 = SB_MIN_COMPS min_comps = settings.estimate_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)
@ -3118,8 +2975,12 @@ 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 tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD: if (
effective_mad_k = SB_MAD_K_SMALL_N tier != "A"
and settings.estimate_sb_mad_k_small_n < mad_k
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
):
effective_mad_k = settings.estimate_sb_mad_k_small_n
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(
@ -3533,7 +3394,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 и раньше
@ -3730,28 +3591,32 @@ 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=SB_AREA_SIGMA, sigma=settings.estimate_sb_area_sigma,
rooms_boost=SB_ROOMS_MATCH_BOOST, rooms_boost=settings.estimate_sb_rooms_match_boost,
floor_target=floor, floor_target=floor,
total_floors_target=total_floors, total_floors_target=total_floors,
floor_sigma=SB_FLOOR_SIGMA, floor_sigma=settings.estimate_sb_floor_sigma,
min_comps=SB_MIN_COMPS, min_comps=settings.estimate_sb_min_comps,
mad_k=SB_MAD_K, mad_k=settings.estimate_sb_mad_k,
) )
# #1795 шаг 3: гейт Tier C. # #1795 шаг 3: гейт Tier C.
if anchor is not None and anchor_tier == "C": if (
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 * ANCHOR_TIER_C_CORRIDOR_MULT gate_threshold = corridor_high_for_gate * settings.estimate_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"]),
ANCHOR_TIER_C_CORRIDOR_MULT, settings.estimate_anchor_tier_c_corridor_mult,
int(gate_threshold), int(gate_threshold),
) )
anchor = None anchor = None
@ -3759,7 +3624,10 @@ 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 = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD gate_thin = (
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"
@ -3799,13 +3667,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 - SB_GUARDRAIL_TOL) floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_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 = FSD_K * anchor["fsd"] half = settings.estimate_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))
# Спред комплов. # Спред комплов.
@ -3831,10 +3699,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"]
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу. # #695: explanation описывает ИМЕННО якорные комплы.
explanation = ( explanation = (
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}" f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
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-популяции.
@ -3858,7 +3726,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 > WIDE_CORRIDOR_THRESHOLD: if corridor_pct > settings.estimate_wide_corridor_threshold:
confidence = _downgrade_confidence(confidence) confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + ( explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом " " Очень широкий ценовой диапазон по дому (вероятно, дом "
@ -3962,8 +3830,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=IMV_BLEND_WEIGHT, weight=settings.estimate_imv_blend_weight,
threshold=IMV_BLEND_THRESHOLD, threshold=settings.estimate_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
), ),
@ -3975,7 +3843,7 @@ def _price_from_inputs(
median_price, median_price,
new_median, new_median,
anchor_used, anchor_used,
IMV_BLEND_WEIGHT, settings.estimate_imv_blend_weight,
range_high, range_high,
new_range_high, new_range_high,
) )
@ -4043,15 +3911,15 @@ def _price_from_inputs(
# Bimodal/nominal guard (Guard-4). # Bimodal/nominal guard (Guard-4).
if ( if (
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N target_qi > settings.estimate_quarter_index_max_for_small_n
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD and target_n_deals < settings.estimate_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,
QUARTER_INDEX_SMALL_N_THRESHOLD, settings.estimate_quarter_index_small_n_threshold,
target_quarter, target_quarter,
) )
else: else:
@ -4076,11 +3944,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 > QUARTER_MATCH_SKIP_RATIO: if same_quarter_ratio > settings.estimate_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,
QUARTER_MATCH_SKIP_RATIO, settings.estimate_quarter_match_skip_ratio,
target_quarter, target_quarter,
) )
else: else:
@ -4133,8 +4001,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=QUARTER_INDEX_FACTOR_MIN, min_factor=settings.estimate_quarter_index_factor_min,
max_factor=QUARTER_INDEX_FACTOR_MAX, max_factor=settings.estimate_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
@ -4156,7 +4024,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 = CORRIDOR_CLAMP_SLACK slack = settings.estimate_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(
@ -4168,7 +4036,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=CORRIDOR_CLAMP_MIN_N, min_n=settings.estimate_corridor_clamp_min_n,
) )
if clamped: if clamped:
logger.info( logger.info(
@ -4191,9 +4059,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) >= CORRIDOR_CLAMP_MIN_N and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
): ):
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_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(
@ -4201,7 +4069,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"],
RADIUS_FLOOR_FACTOR, settings.estimate_radius_floor_factor,
int(radius_floor_ppm2), int(radius_floor_ppm2),
floor_factor, floor_factor,
) )
@ -4221,7 +4089,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 effective_ratio > 1.0: if settings.estimate_expected_sold_le_asking and 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,
@ -4264,7 +4132,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 expected_sold_price: if settings.estimate_expected_sold_le_asking and 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))
@ -4346,7 +4214,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 SB_MIN_COMPS), it returns None # own MAD-clip drops comps below estimate_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"
@ -5298,14 +5166,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=QUARTER_INDEX_MIN_N_DEALS, min_n_deals=settings.estimate_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=QUARTER_INDEX_MIN_N_DEALS, min_n_deals=settings.estimate_quarter_index_min_n_deals,
) )
# ── Deterministic pricing orchestration ────────────────────────────────── # ── Deterministic pricing orchestration ──────────────────────────────────
@ -5443,11 +5311,10 @@ async def estimate_quality(
estimate_id = uuid4() estimate_id = uuid4()
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и # меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
# Зона n=1..9 на экране неотличима от работающего коридора, поэтому # Зона n=3..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:
@ -5455,7 +5322,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,
CORRIDOR_CLAMP_MIN_N, settings.estimate_corridor_clamp_min_n,
(dkp_raw or {}).get("scope", "unknown"), (dkp_raw or {}).get("scope", "unknown"),
target_city or "unknown", target_city or "unknown",
) )
@ -5518,19 +5385,6 @@ 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 = (
@ -5560,7 +5414,6 @@ 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
@ -5584,7 +5437,6 @@ 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,
@ -5645,11 +5497,6 @@ 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),
@ -5726,8 +5573,10 @@ 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 in ANCHOR_API_TIERS: if anchor_tier == "A":
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier] api_analog_tier: str | None = "same_building"
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:
@ -5792,6 +5641,7 @@ 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),
@ -5820,8 +5670,7 @@ 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,
exposure_window=exposure_window, est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
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,
@ -5932,8 +5781,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) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price порога) и `estimate_manual_review_high_value_rub` (expected_sold_price порога) и
`MANUAL_REVIEW_ELITE_PPM2` (asking /м² порога) независимо от `estimate_manual_review_elite_ppm2` (asking /м² порога) независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции. почти всегда подхватывается value-based триггером той же функции.
""" """
@ -5970,6 +5819,7 @@ 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: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
@ -5999,13 +5849,15 @@ def _manual_review(
reasons: list[str] = [] reasons: list[str] = []
if premium_building: if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB: high_value = settings.estimate_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("низкая уверенность оценки")
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO: wide_ratio = settings.estimate_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 >= MANUAL_REVIEW_ELITE_PPM2: if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
reasons.append( reasons.append(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
@ -6013,88 +5865,23 @@ def _manual_review(
return (bool(reasons), reasons) return (bool(reasons), reasons)
# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был def _estimate_days_on_market(
# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок listings: list[dict[str, Any]], deals: list[dict[str, Any]]
# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция ) -> int | None:
# снятых объявлений — house_placement_history.exposure_days. """Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
#
# Порог 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, чтобы одно и то же окно не расходилось на единицу.
""" """
if len(days) < EXPOSURE_WINDOW_MIN_N: values = [
int(lot["days_on_market"])
for lot in (*listings, *deals)
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
]
if len(values) < 3:
return None return None
p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive") values.sort()
return ExposureWindow( return values[len(values) // 2]
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:
@ -6764,9 +6551,8 @@ 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 **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
±estimate_tier_h_floors_tol (0.30). Если estimate_tier_h_min_comps (5) Если 5 результатов возвращаем; tier='H'.
результатов возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны. Пропускается если year_built или total_floors неизвестны.
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра. **Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
@ -6961,12 +6747,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 ±tol, #3234: tol/порог в settings) # ── Tier H: same class (year ±15, total_floors ±30%) ────────────────────
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 * (1 - settings.estimate_tier_h_floors_tol)) tf_min = math.floor(total_floors * 0.7)
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol)) tf_max = math.ceil(total_floors * 1.3)
tier_h_rows = ( tier_h_rows = (
db.execute( db.execute(
@ -7074,7 +6860,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) >= settings.estimate_tier_h_min_comps: if len(tier_h) >= 5:
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,
@ -7542,18 +7328,15 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
) )
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис: # («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я # буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# Новосибирская», «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+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: # #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
@ -7565,19 +7348,10 @@ _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+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" r"((?:\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 »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица). # чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
@ -7763,7 +7537,6 @@ 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)
@ -7882,7 +7655,8 @@ def _union_find_phys_dedup(
не дедупятся остаются уникальными. не дедупятся остаются уникальными.
include_price=True статистический ключ (влияет на n_analogs/median/cv, include_price=True статистический ключ (влияет на n_analogs/median/cv,
вызывающий _dedup_cross_source). include_price=False display-only ключ без гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
_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) без цены единственный оставшийся
@ -8003,14 +7777,13 @@ 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 кросс-поста, не ставший представителем, выпадает
из выборки по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель из выборки по нему нет независимой ценовой точки). Флаг
снят (#2378) — бэктест #1966 OFF vs ON был estimate_dedup_analogs_enabled OFF no-op (байт-идентичный проход, нужно
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%), дедуп меняет для frozen backtest regression gate). Ключ включает price_bucket
только честность счёта 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 len(lots) < 2: if not settings.estimate_dedup_analogs_enabled or 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)
@ -8056,8 +7829,9 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
случай: перевыставили на другой площадке дешевле/дороже) без такого случай: перевыставили на другой площадке дешевле/дороже) без такого
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог». дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
Независим от _dedup_cross_source: тот управляет статистикой Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
(median/n_analogs), этот только честностью UI-карточек. НЕ трогает статистикой (median/n_analogs), этот только честностью UI-карточек и
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены
из отдельного (не обязательно совпадающего по итоговой длине) пула. из отдельного (не обязательно совпадающего по итоговой длине) пула.
""" """
@ -8069,10 +7843,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 < OUTLIER_SMALL_N_THRESHOLD) #1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL несколько элитных ужесточаем k с 1.5 до estimate_outlier_tukey_k_small несколько элитных
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
тянут радиусную медиану вверх. тянут радиусную медиану вверх. threshold=0 выключено (старое поведение).
""" """
if len(lots) < 5: if len(lots) < 5:
return lots # на маленькой выборке нечего фильтровать return lots # на маленькой выборке нечего фильтровать
@ -8082,8 +7856,11 @@ 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 len(prices) < OUTLIER_SMALL_N_THRESHOLD: if (
tukey_k = OUTLIER_TUKEY_K_SMALL settings.estimate_outlier_tukey_k_small < 1.5
and len(prices) < settings.estimate_outlier_small_n_threshold
):
tukey_k = settings.estimate_outlier_tukey_k_small
q1 = _percentile(prices, 0.25) q1 = _percentile(prices, 0.25)
q3 = _percentile(prices, 0.75) q3 = _percentile(prices, 0.75)

View file

@ -603,17 +603,11 @@ 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, region_code tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
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) <> ''
@ -628,15 +622,15 @@ _MATCH_SQL = text(
), ),
gar_ranked AS ( gar_ranked AS (
SELECT SELECT
canon, house_guid, flat_count, region_code, canon, house_guid, flat_count,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid PARTITION BY canon ORDER BY flat_count DESC, house_guid
) AS rn, ) AS rn,
COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits COUNT(*) OVER (PARTITION BY canon) AS canon_hits
FROM gar_scope FROM gar_scope
), ),
gar_pick AS ( gar_pick AS (
SELECT canon, house_guid, flat_count, region_code SELECT canon, house_guid, flat_count
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)
@ -650,33 +644,10 @@ _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,
@ -695,14 +666,11 @@ 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, снято guid чужого региона=%d" "gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
" (region=%s, city=%s)",
matched, matched,
cleared,
region_code, region_code,
city_filter, city_filter,
) )

View file

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

View file

@ -25,31 +25,30 @@ 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, BOTH PASSES #2690): because the canon strips город/район, GEO GUARD (anti over-merge, CANON PASS ONLY #2187): 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 cluster a house is a LOSER only 5») would share a canon. To avoid merging them, within a canon-cluster a house is a LOSER only
if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-key houses if it is within 250 m of the keeper (ST_DistanceSphere on the WGS84 geom). Same-canon houses
>250 m away or with NULL geom on either side are left as separate rows (conservative). >250 m away or with NULL geom on either side are left as separate rows (conservative). The
The FIAS pass used to skip this guard (#2187, «a shared ФИАС UUID IS the building identity»). FIAS pass deliberately SKIPS this guard: a shared ФИАС/ГАР UUID IS the building identity and
That premise is false: house_fias_id is DaData's answer to OUR address string (KEY section strictly outranks geo-proximity, so same-fias rows merge even with NULL geom on a side or
below), so a street+number without a town gets the same UUID as the same street+number in >250 m apart (the geom-first keeper rule simultaneously repairs the broken coordinate).
another town. Prod run 2026-09-05 (house_merge_log, merge_pass='fias'): 408 merges, 129 of
them >250 m apart, 53 >3 km, worst 362 km («р-н Синарский, улица Кирова, 4» of
Каменск-Уральский into Екатеринбург), plus 67 with no coordinates on a side.
TWO PASSES (2026-07-02 follow-up): the SAME clusterkeeperre-pointcarry-identitydelete TWO PASSES (2026-07-02 follow-up): the SAME clusterkeeperre-pointcarry-identitydelete
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression 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: посёлок truncation (Кедровка/Шувакиш cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse «Сулимова, 3/2» same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
(«Сулимова, 32» vs «Сулимова, 3/2» same canon «сулимова32»). colliding with a same-named ЕКБ street).
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 slash-collapse class). Critical anti-over-merge fix. The 250 m geo guard applies to the
BOTH passes (#2690; the fias-pass exemption of #2187 is reverted — see GEO GUARD). CANON pass ONLY (#2187): the FIAS pass merges on UUID identity regardless of geom — a
боевой прогон left 130 same-fias groups (335 houses, 1 529 listings) split because the
guard blocked them (NULL geom on a side, or >250 m from a broken coordinate).
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
@ -115,9 +114,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. It is what exposed the fias pass merging without a distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
proximity check (#2690, 2026-09-05): «how many merges happened beyond N metres, on which key» asymmetry merge allowed without a proximity check was invisible in data before; now
is one query. «how many merges happened beyond N metres, on which key» is one query.
KEY there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690): 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
@ -225,8 +224,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 — посёлок truncation (Кедровка/Шувакиш) and, until migration 322 # duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
# (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters. # truncation (Кедровка/Шувакиш). 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)
@ -312,15 +311,35 @@ def _ranked_cte(cluster_key_case: str) -> str:
)""" )"""
def _mapping_sql(cluster_key_case: str) -> str: def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. """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). So does the 250 m GEO GUARD on BOTH passes (#2690): row shares one fias by construction).
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)}
@ -331,22 +350,16 @@ def _mapping_sql(cluster_key_case: str) -> 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 what showed the fias pass merging 362 km apart while it had no guard (#2690). -- distance_m is computed even when the geo guard is OFF for this pass that is precisely
-- the case where nothing else records the distance, and #2690 had no way to ask
-- «how many merges happened at distances the guard would have blocked» from data.
SELECT id AS loser_id, keeper_id, norm_address, cluster_key, 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 AND id <> keeper_id{geo_guard}
-- 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
@ -355,10 +368,12 @@ def _mapping_sql(cluster_key_case: str) -> str:
""" """
# Canon-pass mapping. # Canon-pass mapping — geo guard ON (cross-town over-merge protection for street+number canons).
_BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR)) _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
# Fias-pass mapping — same pipeline and same guards, clustered by the ФИАС UUID (runs first). # Fias-pass mapping — same pipeline, clustered by the ФИАС building UUID (runs first). Geo guard
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR)) # OFF (#2187): a shared ГАР UUID IS the building identity and outranks proximity — same-fias rows
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ─────────────────────────────────────────── # ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
# #
@ -855,6 +870,7 @@ 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,
@ -895,7 +911,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": True, # both passes are guarded since #2690 "geo_guard": geo_guard,
}, },
) )
@ -1016,8 +1032,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. 2. CANON pass cluster by canonical address (250 m geo guard + cross-fias anti-over-merge
Both passes carry the 250 m geo guard and the cross-fias anti-over-merge guard. 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
@ -1041,6 +1057,7 @@ 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,
@ -1051,6 +1068,7 @@ 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,

View file

@ -357,23 +357,7 @@ 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)
) )
# #3511: пустая витрина — не успех. Результатного ключа кита ctx.runs.mark_done(db, run_id, counters)
# (`_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", {})
@ -544,9 +528,6 @@ 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
),
) )

View file

@ -16,8 +16,7 @@ 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())
@ -70,7 +69,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, attribute_run_proxy from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -278,21 +277,6 @@ 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)

View file

@ -275,13 +275,12 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой affinity (#2600). чужой affinity (#2600).
Fallback НЕ трогает последний пригодный узел выделенной (не-'any') affinity: если Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
fallback заберёт его под чужой источник, «свой» останется без прокси вообще хуже, fallback заберёт его под чужой источник, «свой» останется без прокси вообще хуже,
чем голодание исходного источника, которое фикс призван устранить. Кандидат чем голодание исходного источника, которое фикс призван устранить. Кандидат
участвует в fallback, только если его affinity='any' ИЛИ у источника этой affinity участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
без него останется ДРУГОЙ кандидат (EXISTS-подзапрос): узел той же affinity или enabled-узел (EXISTS-подзапрос) т.е. выдача не обнулит доступность выделенной
'any', здоровый, с живой арендой порта и не забаненный этим источником (#3299 — affinity целиком.
до него '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
@ -402,29 +401,17 @@ 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 IN (sp.provider_affinity, 'any') WHERE other.provider_affinity = sp.provider_affinity
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 = sp.provider_affinity AND b2.source = other.provider_affinity
AND b2.banned_until > now() AND b2.banned_until > now()
) )
) )
@ -459,9 +446,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 при ротации узла mid-run). Путь без # вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
# аренды (proxy_egress.resolve_proxy_url) пишет атрибуцию сам — см. docstring. # mid-run) — см. attribute_run_proxy 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(
@ -499,12 +486,11 @@ 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'а: покрывает и curl-путь Единственный писатель `acquire()` сразу после выдачи lease'а: покрывает и
(acquire на каждый вызов), и браузерный sticky lease (один acquire на весь прогон), curl-путь (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` ПОСЛЕДНИЙ использованный узел (перезаписывается при
каждой новой выдаче); полная цепочка узлов, если она менялась, в каждой новой выдаче); полная цепочка узлов, если она менялась, в
@ -549,7 +535,8 @@ 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 finalized?)", "proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
"finalized?)",
run_id, run_id,
) )
except Exception: except Exception:
@ -996,10 +983,6 @@ 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
@ -1016,22 +999,17 @@ 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 IN (sp.provider_affinity, 'any') WHERE other.provider_affinity = sp.provider_affinity
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 = sp.provider_affinity AND b2.source = other.provider_affinity
AND b2.banned_until > now() AND b2.banned_until > now()
) )
) )

View file

@ -14,7 +14,6 @@ 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 (цена не менялась).
@ -42,14 +41,6 @@ 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.137 тыс/м².
# Посев синтетический (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(
""" """
@ -105,12 +96,6 @@ 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.
@ -130,7 +115,6 @@ 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:
@ -156,29 +140,20 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
{"listing_id": listing_id}, {"listing_id": listing_id},
).fetchone() ).fetchone()
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история # ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
# есть» на проде не записала ни одной строки (из 28 558 строк этого # сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер # к предыдущей точке и подтверждения у следующей. На этом пути серия
# record_listing_price_change (save_listings идёт первым), и сюда # максимум из двух точек (последняя лежащая в БД + текущая), у
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории # последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
# — пары «посев из price.previous → текущая цена», ошибочна нижняя. # точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
# То есть проводка была бы декорацией: ветка, которая по построению
# не может выбросить ни одной точки. Отлов ×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 ( if prev is not None and prev != lot.price_rub:
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,
@ -216,11 +191,10 @@ 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 seed_rejected=%d errors=%d", "yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d",
len(lots), len(lots),
inserted, inserted,
skipped, skipped,
seed_rejected,
errors, errors,
) )
return inserted return inserted

View file

@ -42,12 +42,6 @@ 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
@ -104,9 +98,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, avito_detail_backfill_use_curl) молча # без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же # avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
# класс бага что #2322/#2310. # datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -238,10 +232,12 @@ async def _rotate_current_proxy(
settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не
зовёт эту функцию в том режиме вовсе. зовёт эту функцию в том режиме вовсе.
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
админки scrape_avito_detail_backfill в tradein-backend, поэтому режим задаётся SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
дефолтом кода, а не environment'ом одного сервиса. активна. Значение true стоит только у tradein-backend, который добор не запускает.
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом»
это предсуществующее заблуждение, а не описание текущего прода.
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост) Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ
@ -333,8 +329,7 @@ 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);
@ -554,10 +549,6 @@ 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-критерий
@ -714,7 +705,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,
@ -1034,12 +1025,6 @@ 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()
# досчитывает хвостовую пачку, если прогон оборвался посреди серии. # досчитывает хвостовую пачку, если прогон оборвался посреди серии.
@ -1051,28 +1036,21 @@ 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
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)" runs_mod.mark_failed(
elif abort_reason == "infra_streak": db,
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала. run_id,
stop_reason = ( "пул прокси пуст — к площадке не ходили (#3288)",
f"тракт недоступен: {breaker.abort_explanation()}, обогащено " current_counters,
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 (%s) -- attempted=%d " "avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- 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,

View file

@ -1,8 +1,7 @@
"""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 строку на КАЖДОЕ ИЗМЕНЕНИЕ (#2993, см. и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в
_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.
@ -73,28 +72,9 @@ 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))
# ── Snapshot upsert: строка только на изменение (#2993) ─────────────────────── # ── Daily snapshot upsert ─────────────────────────────────────────────────────
# Снимок на (listing_source_id, CURRENT_DATE) пишется, только если у источника ещё нет # Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
# ни одного снимка ИЛИ хоть одно из четырёх значений (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).
@ -105,33 +85,15 @@ _SNAPSHOT_SQL = text(
last_seen_at, payload_hash, observed_at, run_id last_seen_at, payload_hash, observed_at, run_id
) )
SELECT SELECT
cur.id, id,
CURRENT_DATE, CURRENT_DATE,
cur.price_rub, price_rub,
cur.is_active, (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active,
cur.last_seen_at, last_seen_at,
cur.payload_hash, md5(raw_payload::text),
now(), now(),
CAST(:run_id AS bigint) CAST(:run_id AS bigint)
FROM ( FROM listing_sources
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,
@ -146,9 +108,6 @@ _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,
@ -290,8 +249,7 @@ 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) только для источников, 1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins.
изменившихся с последнего снимка (#2993) — last-write-wins.
2. diff сегодняшнего снимка против последнего предыдущего три события, 2. diff сегодняшнего снимка против последнего предыдущего три события,
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL. они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL.

View file

@ -52,13 +52,6 @@ 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.0916.09): карточка, которая не догружается НИКОГДА, без
счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд
выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь
недогруз пишется на объявление (миграция 321): сутки его не берём, после
INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки
не двигает брейкер это сведения о карточке, а не о площадке.
""" """
from __future__ import annotations from __future__ import annotations
@ -144,13 +137,6 @@ 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:
@ -175,8 +161,6 @@ 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]:
@ -188,7 +172,6 @@ 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),
} }
@ -245,8 +228,7 @@ 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
@ -257,15 +239,6 @@ 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)
""" """
@ -276,8 +249,6 @@ 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()
@ -312,19 +283,6 @@ 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 объявлений сохранённый "
@ -485,59 +443,26 @@ async def run_yandex_detail_backfill(
# объявление уедет в БД с detail_enriched_at, выбыв из очереди # объявление уедет в БД с detail_enriched_at, выбыв из очереди
# навсегда. Здесь оно исхода 'enriched' не получает, значит в # навсегда. Здесь оно исхода 'enriched' не получает, значит в
# следующем прогоне снова попадёт в снапшот (detail_enriched_at # следующем прогоне снова попадёт в снапшот (detail_enriched_at
# IS NULL) — но не раньше чем через сутки: недогруз пишется на # IS NULL). Серия таких страниц двигает consecutive_none — тот же
# объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт. # брейкер, что у parse→None: вечно недогружаемая карточка упрётся
# в 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 (попытка %d/%d, " "-> недогруженная карточка, отказ: %s (consecutive=%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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1674,16 +1674,12 @@ 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,
} }
@ -1979,11 +1975,12 @@ 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 and module constants (so a change is caught as a ``estimator.settings`` defaults (so a settings change is caught as a metric
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378 drift, not silently honoured). ЕДИНСТВЕННОЕ исключение
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON ``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
(закоммиченная такая); более старая упадёт на «control flow diverged from настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
capture» её надо перезахватить. Deterministic: same fixture identical dict. потока (см. комментарий у пина ниже). Deterministic: same fixture identical
dict.
""" """
est = _import_estimator_full() est = _import_estimator_full()
m = est.m m = est.m
@ -1994,60 +1991,83 @@ 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-вызовов без записи в фикстуре
for rec in deals: # #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
kw = dict(rec["kwargs"]) # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
sold_ppm2_all.append(float(rec["sold_ppm2"])) # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
kw["geo"] = est.GeocodeResult(**kw["geo"]) # подрезал бы listings до quarter_indexes_lookup и записанная последовательность
kw["ratio_resolver"] = _make_call_stub( # вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return # сам, а документированная регенерация baseline (--from-fixture --update-baseline)
) # — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых # #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт # захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, # НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в # уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает # вызывается, а реплей с dedup=False их оценивает и упирается в
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. # «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. # False — для старых фикстур без settings_at_capture поведение прежнее).
kw["quarter_index_lookup"] = _make_call_stub( _dedup_capture = bool(
rec.get("qi_calls") or [], (fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
label="quarter_index_lookup", )
coerce=_coerce_qi_return, _dedup_saved = m.settings.estimate_dedup_analogs_enabled
on_exhausted=None, m.settings.estimate_dedup_analogs_enabled = _dedup_capture
unrecorded_counter=unrecorded, try:
) for rec in deals:
kw["quarter_indexes_lookup"] = _make_call_stub( kw = dict(rec["kwargs"])
rec.get("qis_calls") or [], sold_ppm2_all.append(float(rec["sold_ppm2"]))
label="quarter_indexes_lookup", kw["geo"] = est.GeocodeResult(**kw["geo"])
coerce=_coerce_qis_return, kw["ratio_resolver"] = _make_call_stub(
on_exhausted={}, rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
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)
@ -2103,6 +2123,7 @@ 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
@ -2191,7 +2212,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=m.QUARTER_INDEX_MIN_N_DEALS, min_n_deals=settings.estimate_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)
@ -2202,7 +2223,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=m.QUARTER_INDEX_MIN_N_DEALS, min_n_deals=settings.estimate_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))])
@ -2554,18 +2575,6 @@ 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,
@ -2632,11 +2641,10 @@ 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_type_from_houses=%d", "house_id resolution: resolved=%d/%d imv_reachable=%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:
@ -2650,9 +2658,7 @@ 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 plus the ``settings_at_capture`` records every ``estimate_*`` Settings field as an
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)``.
@ -2662,11 +2668,6 @@ 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",

View file

@ -2,19 +2,19 @@
"calibration": { "calibration": {
"high": { "high": {
"coverage_pct": 75.0, "coverage_pct": 75.0,
"mape_pct": 19.97, "mape_pct": 19.6,
"n": 8, "n": 8,
"n_covered": 6 "n_covered": 6
}, },
"low": { "low": {
"coverage_pct": 83.67, "coverage_pct": 83.59,
"mape_pct": 15.64, "mape_pct": 15.64,
"n": 1562, "n": 1562,
"n_covered": 1030 "n_covered": 1029
}, },
"medium": { "medium": {
"coverage_pct": 70.0, "coverage_pct": 70.0,
"mape_pct": 26.53, "mape_pct": 27.59,
"n": 30, "n": 30,
"n_covered": 21 "n_covered": 21
} }
@ -26,17 +26,17 @@
], ],
"expected_sold": { "expected_sold": {
"overall": { "overall": {
"mape_pct": 15.92, "mape_pct": 15.88,
"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.94, "p25_pct": -10.88,
"p75_pct": 22.81 "p75_pct": 22.98
}, },
"per_area_bucket": { "per_area_bucket": {
"0 <30": { "0 <30": {
"mape_pct": 19.76, "mape_pct": 19.76,
"median_bias_pct": -1.02, "median_bias_pct": -1.49,
"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.56, "p25_pct": -10.58,
"p75_pct": 22.86 "p75_pct": 22.86
}, },
"2 44-62": { "2 44-62": {
"mape_pct": 13.93, "mape_pct": 13.86,
"median_bias_pct": 4.13, "median_bias_pct": 4.15,
"n": 379, "n": 379,
"p25_pct": -8.12, "p25_pct": -7.92,
"p75_pct": 23.5 "p75_pct": 23.85
}, },
"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": -11.99, "p25_pct": -12.1,
"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.02, "median_bias_pct": -1.49,
"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.56, "p25_pct": -10.58,
"p75_pct": 22.86 "p75_pct": 22.86
}, },
"2": { "2": {
"label": "2к", "label": "2к",
"mape_pct": 13.93, "mape_pct": 13.86,
"median_bias_pct": 4.13, "median_bias_pct": 4.15,
"n": 379, "n": 379,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -8.12, "p25_pct": -7.92,
"p75_pct": 23.5 "p75_pct": 23.85
}, },
"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": -11.99, "p25_pct": -12.1,
"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.69, "mape_pct": 10.68,
"median_bias_pct": -5.78, "median_bias_pct": -5.78,
"n": 425, "n": 425,
"p25_pct": -15.98, "p25_pct": -15.88,
"p75_pct": 4.54 "p75_pct": 4.49
}, },
"премиум": { "премиум": {
"mape_pct": null, "mape_pct": null,
@ -140,11 +140,11 @@
"p75_pct": null "p75_pct": null
}, },
"эконом": { "эконом": {
"mape_pct": 20.22, "mape_pct": 20.17,
"median_bias_pct": 18.98, "median_bias_pct": 18.98,
"n": 697, "n": 697,
"p25_pct": 0.96, "p25_pct": 1.25,
"p75_pct": 42.31 "p75_pct": 41.97
}, },
"элит": { "элит": {
"mape_pct": 36.11, "mape_pct": 36.11,
@ -162,9 +162,9 @@
}, },
"range_coverage": { "range_coverage": {
"overall": { "overall": {
"coverage_pct": 83.29, "coverage_pct": 83.22,
"n": 1269, "n": 1269,
"n_covered": 1057 "n_covered": 1056
}, },
"per_confidence": { "per_confidence": {
"high": { "high": {
@ -173,9 +173,9 @@
"n_covered": 6 "n_covered": 6
}, },
"low": { "low": {
"coverage_pct": 83.67, "coverage_pct": 83.59,
"n": 1231, "n": 1231,
"n_covered": 1030 "n_covered": 1029
}, },
"medium": { "medium": {
"coverage_pct": 70.0, "coverage_pct": 70.0,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,7 +36,6 @@ 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,
) )
@ -103,7 +102,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("scraper_kit.providers._base.AsyncSession", _fake_session): with patch("curl_cffi.requests.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
@ -124,15 +123,10 @@ 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("scraper_kit.providers._base.AsyncSession", _fake_session): with patch("curl_cffi.requests.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

View file

@ -83,9 +83,9 @@ _SSR_LITERAL = """{
}, },
"legalOptions": {"saleType": "Свободная продажа"}, "legalOptions": {"saleType": "Свободная продажа"},
"egrnData": { "egrnData": {
"area": {"status": "success", "value": 38.2}, "area": 38.2,
"floor": {"status": "success", "value": 5}, "floor": 5,
"owners_count": {"status": "success", "value": 1}, "owners_count": 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"] == {"status": "success", "value": 38.2} assert raw["egrn_area"] == 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

View file

@ -15,7 +15,6 @@ 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.
@ -29,8 +28,6 @@ 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"
@ -117,7 +114,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, config=_CONFIG, session=session) url = await resolve_cian_zhk_url_via_search(48853, 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()
@ -131,14 +128,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, config=_CONFIG, session=session) is None assert await resolve_cian_zhk_url_via_search(999, 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, config=_CONFIG, session=session) is None assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -147,4 +144,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, config=_CONFIG, session=session) is None assert await resolve_cian_zhk_url_via_search(48853, session=session) is None

View file

@ -167,9 +167,7 @@ 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)
# primary: своя affinity ИЛИ 'any'. Полный фрагмент, а не "provider_affinity IN": if "provider_affinity IN" in sql: # primary: своя affinity ИЛИ 'any'
# с #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
@ -191,27 +189,17 @@ 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"] in affinities other["provider_affinity"] == row["provider_affinity"]
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"], row["provider_affinity"]) and self._has_active_ban(other["id"], other["provider_affinity"])
) )
for other in self.rows for other in self.rows
) )
@ -404,24 +392,13 @@ 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"] in affinities other["provider_affinity"] == sp["provider_affinity"]
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"], sp["provider_affinity"]) and self._has_active_ban(other["id"], other["provider_affinity"])
) )
for other in self.rows for other in self.rows
) )
@ -735,16 +712,11 @@ 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]
@ -763,9 +735,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("expired" in r.message and "id=1" in r.message for r in caplog.records), ( assert any(
"ожидался WARNING про просроченный proxy id=1" "expired" in r.message and "id=1" in r.message for r in caplog.records
) ), "ожидался WARNING про просроченный proxy id=1"
# ── release ────────────────────────────────────────────────────────────────── # ── release ──────────────────────────────────────────────────────────────────
@ -1577,7 +1549,9 @@ 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("http://u:p@h1:8080") ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
"http://u:p@h1:8080"
)
assert ok is False assert ok is False
assert exit_ip is None assert exit_ip is None

View file

@ -31,19 +31,12 @@ 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_keeps_geo_guard tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_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
@ -153,49 +146,3 @@ 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

View file

@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts """#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 curl path (avito_detail_backfill_use_curl), at the call site. use_curl=True is the prod-default 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)."""

View file

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

View file

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

View file

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

View file

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

View file

@ -186,12 +186,6 @@ 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:

View file

@ -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 == [] # #3310: отказ площадки не копит consecutive_fails assert spy.mark_health_calls == [(1, False)]
assert spy.release_calls == [1] # lease не течёт даже на бане assert spy.release_calls == [1] # lease не течёт даже на бане

View file

@ -179,7 +179,7 @@ async def test_price_history_403_bans_the_node_for_cian() -> None:
pool = _SpyPool() 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 == [] # #3310: бан вместо mark_health(False) assert pool.mark_health_calls == [(9, 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 == [] # #3310: бан вместо mark_health(False) assert spy.mark_health_calls == [(9, False)]
assert spy.release_calls == [9] assert spy.release_calls == [9]

View file

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

View file

@ -1,246 +0,0 @@
"""#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