Домклик: число собственников снова читается, жилая площадь и балконы не стираются переобходом, нули в площадях — не площадь #3569
119 changed files with 5181 additions and 1393 deletions
|
|
@ -233,6 +233,10 @@ jobs:
|
||||||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||||
- '.forgejo/workflows/deploy-metrics.yml'
|
- '.forgejo/workflows/deploy-metrics.yml'
|
||||||
- 'docker-compose.metrics.yml'
|
- 'docker-compose.metrics.yml'
|
||||||
|
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
||||||
|
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
||||||
|
# экспортёра) без этой строки прошла бы мимо гейта.
|
||||||
|
- 'docker-compose.metrics-agent.yml'
|
||||||
# #3443: тот же класс, третий раз. Гейт
|
# #3443: тот же класс, третий раз. Гейт
|
||||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,15 @@ jobs:
|
||||||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
|
||||||
|
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
|
||||||
|
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
|
||||||
|
# на диск и не вступала в силу до случайного пересоздания.
|
||||||
|
# Пересоздаём только при расхождении инода.
|
||||||
|
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||||
|
gendesign-metrics docker-compose.metrics.yml \
|
||||||
|
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
|
||||||
|
|
||||||
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
||||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||||
|
|
@ -406,15 +415,6 @@ jobs:
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Приёмка ────────────────────────────────────────────────────
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
|
||||||
|
|
||||||
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||||
# перечитывает ────────────────────────────────────────────────
|
# перечитывает ────────────────────────────────────────────────
|
||||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||||
|
|
@ -424,27 +424,41 @@ jobs:
|
||||||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||||
#
|
#
|
||||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
||||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
||||||
# после `git reset --hard` подхватывается без пересоздания
|
# файла: после `git reset --hard` за путём в контейнере остаётся
|
||||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
||||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
||||||
# никто не звал сам reload.
|
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
||||||
|
# --web.enable-lifecycle включён в compose ради этого шага.
|
||||||
#
|
#
|
||||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
||||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
||||||
#
|
# контейнером того же образа. `docker exec` в работающий проверял
|
||||||
# Плюс юнит-тесты правил (#3493): синтаксически верное правило может
|
# бы тот самый старый инод, а не то, что сейчас применится.
|
||||||
# врать по смыслу — `count(x == 1) == 0` от пустого вектора не
|
# Синтаксически верное правило может врать по смыслу —
|
||||||
# срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
|
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
||||||
# экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
|
# отсюда test rules.
|
||||||
# одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
|
if docker run --rm --entrypoint sh \
|
||||||
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
||||||
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \
|
|
||||||
&& docker run --rm --entrypoint promtool \
|
|
||||||
-v /opt/gendesign/ops/metrics/prometheus:/work:ro \
|
|
||||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
||||||
test rules /work/tests/infra_test.yml; then
|
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
|
||||||
|
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||||
|
gendesign-metrics docker-compose.metrics.yml \
|
||||||
|
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
# ── Приёмка ────────────────────────────────────────────────
|
||||||
|
# После возможного пересоздания выше — ждём оба процесса,
|
||||||
|
# которые дальше перечитывают конфиг по HTTP.
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
|
||||||
|
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||||
|
|
||||||
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||||
|
|
||||||
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||||
|
|
@ -470,6 +484,30 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Grafana: датасорсы применяются только при старте (#3467) ────
|
||||||
|
# Каталог provisioning смонтирован целиком, новый datasources.yml
|
||||||
|
# контейнер видит сразу. Дашборды провайдер пересканирует сам
|
||||||
|
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
|
||||||
|
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
|
||||||
|
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
|
||||||
|
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
|
||||||
|
#
|
||||||
|
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
|
||||||
|
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
|
||||||
|
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
|
||||||
|
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
|
||||||
|
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
|
||||||
|
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
|
||||||
|
case "$GRAFANA_RELOAD" in
|
||||||
|
*"Datasources config reloaded"*)
|
||||||
|
echo "Grafana: датасорсы перечитаны."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||||
agent-apps:
|
agent-apps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -561,6 +599,21 @@ jobs:
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||||
|
|
||||||
|
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
|
||||||
|
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
|
||||||
|
# `git reset --hard` пишет правку новым инодом: без пересоздания
|
||||||
|
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
|
||||||
|
# Пересоздаём только при расхождении инода. Под гейтом профиля:
|
||||||
|
# `up` с явным именем сервиса включает его профиль сам.
|
||||||
|
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||||
|
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
|
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||||
|
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||||
|
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
|
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||||
|
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||||
|
fi
|
||||||
|
|
||||||
agent-infra:
|
agent-infra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: server
|
needs: server
|
||||||
|
|
@ -630,3 +683,10 @@ jobs:
|
||||||
|
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||||
|
|
||||||
|
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
|
||||||
|
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||||
|
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
|
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||||
|
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ on:
|
||||||
# исполнял бы старую версию — молча и без единого сигнала.
|
# исполнял бы старую версию — молча и без единого сигнала.
|
||||||
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
||||||
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
||||||
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
|
# backup.sh, restore-drill.sh, restore.sh — точечное
|
||||||
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
|
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
|
||||||
# снова забыли бы (см. как этот самый комментарий выше был точечным про
|
# снова забыли бы (см. как этот самый комментарий выше был точечным про
|
||||||
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
||||||
|
|
|
||||||
|
|
@ -1910,17 +1910,19 @@ def trigger_ekburg_permits(
|
||||||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||||
|
|
||||||
|
|
||||||
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
|
||||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
|
||||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
|
||||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
|
||||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
|
||||||
_WAF_COOLDOWN_GUARD_MSG = (
|
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
|
||||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
_DOMRF_BLOCK_GUARD_MSG = (
|
||||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
|
||||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
|
||||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
|
||||||
"передай i_understand_waf_risk=true в теле запроса."
|
"не снимает, запуск отсюда даст только отказы. Передавай "
|
||||||
|
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
|
||||||
|
"принят по числу строк (#3307)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects(
|
||||||
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
|
|
||||||
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||||
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||||
объектов до next beat fire).
|
свежесозданных объектов).
|
||||||
|
|
||||||
- max_objects=None → дефолтный лимит таска (300).
|
- max_objects=None → дефолтный лимит таска (300).
|
||||||
- max_objects=3 → smoke-тест.
|
- max_objects=3 → smoke-тест.
|
||||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
|
|
||||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||||
re-trigger без явного подтверждения оператора запрещён.
|
запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
if not payload.i_understand_waf_risk:
|
||||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
||||||
|
|
||||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||||
|
|
||||||
|
|
@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats(
|
||||||
- max_flats=3 → smoke-тест.
|
- max_flats=3 → smoke-тест.
|
||||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
|
|
||||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||||
того же WAF-бана.
|
блокировкой StormWall (условие — #3307).
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
if not payload.i_understand_waf_risk:
|
||||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
||||||
|
|
||||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
|
||||||
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
||||||
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
||||||
|
|
||||||
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
|
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
|
||||||
_RE_ACT_NUMBER = re.compile(
|
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
|
||||||
r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
|
||||||
re.UNICODE | re.IGNORECASE,
|
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
|
||||||
)
|
_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])"
|
||||||
|
_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}")
|
||||||
|
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
|
||||||
|
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
|
||||||
|
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$")
|
||||||
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
||||||
|
|
||||||
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
||||||
|
|
@ -233,7 +237,9 @@ def extract_izyatie_records(
|
||||||
normalized = _normalize_ocr_text(ocr_text)
|
normalized = _normalize_ocr_text(ocr_text)
|
||||||
|
|
||||||
# Реквизиты акта из заголовка или текста.
|
# Реквизиты акта из заголовка или текста.
|
||||||
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
act_number = _extract_act_number(doc_title) or _extract_act_number(
|
||||||
|
normalized, require_act_context=True
|
||||||
|
)
|
||||||
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||||
normalized, require_act_context=True
|
normalized, require_act_context=True
|
||||||
)
|
)
|
||||||
|
|
@ -289,9 +295,26 @@ def extract_izyatie_records(
|
||||||
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_number(text: str) -> str | None:
|
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
|
"""Извлекает номер акта.
|
||||||
|
|
||||||
|
Без 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)
|
m = _RE_ACT_NUMBER.search(text)
|
||||||
|
else:
|
||||||
|
date_m = _act_date_match(text, require_act_context=True)
|
||||||
|
if date_m is None:
|
||||||
|
return None
|
||||||
|
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
|
||||||
|
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
|
||||||
|
)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return re.sub(r"\s+", "", m.group(1))
|
return re.sub(r"\s+", "", m.group(1))
|
||||||
|
|
@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool:
|
||||||
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
|
||||||
|
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
|
||||||
|
for m in _RE_ACT_DATE.finditer(text):
|
||||||
|
if require_act_context and not _act_context_matches(text, m.start()):
|
||||||
|
continue
|
||||||
|
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||||
|
return m
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
||||||
|
|
||||||
|
|
@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
|
||||||
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
||||||
актуальные изъятия, ни сверить срок.
|
актуальные изъятия, ни сверить срок.
|
||||||
"""
|
"""
|
||||||
for m in _RE_ACT_DATE.finditer(text):
|
m = _act_date_match(text, require_act_context=require_act_context)
|
||||||
if require_act_context and not _act_context_matches(text, m.start()):
|
if m is None:
|
||||||
continue
|
|
||||||
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
|
return None
|
||||||
|
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
||||||
|
|
||||||
|
|
||||||
def _extract_purpose(text: str) -> str | None:
|
def _extract_purpose(text: str) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
|
||||||
|
|
||||||
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
||||||
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
||||||
|
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
|
||||||
|
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
|
||||||
|
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
|
||||||
|
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
|
||||||
|
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
|
||||||
_RE_ACT_NUMBER = re.compile(
|
_RE_ACT_NUMBER = re.compile(
|
||||||
r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
|
||||||
re.UNICODE,
|
re.UNICODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,11 +346,13 @@ def build_beat_schedule() -> dict:
|
||||||
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
||||||
# за ~5 недель полного обновления.
|
# за ~5 недель полного обновления.
|
||||||
#
|
#
|
||||||
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
|
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
|
||||||
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
|
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
|
||||||
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
|
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
|
||||||
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
|
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
|
||||||
# cooldown 24-48h (проверить через targeted test).
|
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
|
||||||
|
# Включать только после решения по прокси и принятого по числу строк
|
||||||
|
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
|
||||||
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
||||||
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||||
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
||||||
|
|
@ -362,11 +364,11 @@ def build_beat_schedule() -> dict:
|
||||||
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
||||||
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
||||||
#
|
#
|
||||||
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
|
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
|
||||||
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||||
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
||||||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||||
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
|
||||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||||
# напр. четверг 04:00 МСК.
|
# напр. четверг 04:00 МСК.
|
||||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
|
||||||
|
#
|
||||||
|
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
|
||||||
|
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
|
||||||
|
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
|
||||||
|
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
|
||||||
|
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
|
||||||
|
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
|
||||||
|
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
|
||||||
|
_DELETE_STALE_PARSE_SQL = text(
|
||||||
|
"""
|
||||||
|
DELETE FROM land_reservation
|
||||||
|
WHERE cad_num = CAST(:cad_num AS text)
|
||||||
|
AND doc_url = CAST(:doc_url AS text)
|
||||||
|
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||||
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
||||||
count = 0
|
count = 0
|
||||||
|
|
@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||||
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
||||||
try:
|
try:
|
||||||
with db.begin_nested(): # SAVEPOINT per-row
|
with db.begin_nested(): # SAVEPOINT per-row
|
||||||
|
db.execute(_DELETE_STALE_PARSE_SQL, row)
|
||||||
db.execute(upsert_sql, row)
|
db.execute(upsert_sql, row)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует
|
||||||
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
||||||
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
||||||
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
||||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
|
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
|
||||||
VPS IP; включается вручную после проверки targeted-тестом.
|
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
|
||||||
|
включать после прокси и принятого kn-прогона (#3307).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -116,7 +117,7 @@ def scrape_kn_catalog_flats(
|
||||||
|
|
||||||
Concurrency:
|
Concurrency:
|
||||||
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
||||||
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
|
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне
|
||||||
маловероятно. Если случится:
|
маловероятно. Если случится:
|
||||||
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
||||||
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Selector logic:
|
||||||
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
||||||
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
||||||
|
|
||||||
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
|
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
||||||
|
|
||||||
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
||||||
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
|
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
|
||||||
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
|
для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в
|
||||||
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
|
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор
|
||||||
smoke-тест/re-trigger не углубил бан.
|
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
|
||||||
|
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
|
||||||
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_refusal_names_real_condition(detail: str) -> None:
|
||||||
|
assert "2443" in detail
|
||||||
|
assert "i_understand_waf_risk" in detail
|
||||||
|
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
|
||||||
|
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
|
||||||
|
assert "3307" in detail
|
||||||
|
assert "cooldown" not in detail.lower()
|
||||||
|
|
||||||
|
|
||||||
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.id = task_id
|
result.id = task_id
|
||||||
|
|
@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
|
||||||
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
detail = resp.json()["detail"]
|
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||||
assert "2443" in detail
|
|
||||||
assert "i_understand_waf_risk" in detail
|
|
||||||
mock_apply_async.assert_not_called()
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
|
||||||
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
detail = resp.json()["detail"]
|
_assert_refusal_names_real_condition(resp.json()["detail"])
|
||||||
assert "2443" in detail
|
|
||||||
assert "i_understand_waf_risk" in detail
|
|
||||||
mock_apply_async.assert_not_called()
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
|
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
|
||||||
|
|
||||||
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
||||||
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
||||||
|
|
@ -16,9 +16,7 @@
|
||||||
реально ушло бы в сеть.
|
реально ушло бы в сеть.
|
||||||
|
|
||||||
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
||||||
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у
|
бы с оригиналом на первой же правке.
|
||||||
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
|
|
||||||
настоящие сетевые проверки.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -34,7 +32,6 @@ import pytest
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
SENDERS = {
|
SENDERS = {
|
||||||
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
||||||
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
|
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
|
||||||
|
|
@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
|
||||||
f'ARGS_DUMP="{_posix(dump)}"',
|
f'ARGS_DUMP="{_posix(dump)}"',
|
||||||
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
||||||
'TELEGRAM_CHAT_ID="-1004443088679"',
|
'TELEGRAM_CHAT_ID="-1004443088679"',
|
||||||
f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
|
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"',
|
||||||
]
|
]
|
||||||
if topic is not None:
|
if topic is not None:
|
||||||
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
||||||
|
|
@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
|
||||||
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
||||||
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
||||||
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
||||||
|
|
||||||
|
|
||||||
|
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
|
||||||
|
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
|
||||||
|
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
|
||||||
|
# откуда следующий, кто заводит хост, узнает правильный адрес.
|
||||||
|
INFRA_TOPIC = "245"
|
||||||
|
BACKUP_ENV_EXAMPLES = [
|
||||||
|
"gendesign-backup.default.example",
|
||||||
|
"gendesign-backup-couchdb.default.example",
|
||||||
|
"gendesign-backup-forgejo.default.example",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
|
||||||
|
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
|
||||||
|
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
|
||||||
|
|
||||||
|
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
|
||||||
|
маркером, а env-файл — образец, заполненный так, как он велит человеку:
|
||||||
|
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
|
||||||
|
|
||||||
|
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
|
||||||
|
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
|
||||||
|
окружении, — тема из другого файла молча не подхватится.
|
||||||
|
"""
|
||||||
|
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
|
||||||
|
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
|
||||||
|
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
|
||||||
|
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
|
||||||
|
env_file = tmp_path / "backup.env"
|
||||||
|
env_file.write_text(filled, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
fake = bin_dir / "curl"
|
||||||
|
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
dump = tmp_path / "args.txt"
|
||||||
|
|
||||||
|
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
|
||||||
|
# перебили бы файл, и тест проверял бы не образец.
|
||||||
|
prefix = [
|
||||||
|
f'PATH="{_posix(bin_dir)}:$PATH"',
|
||||||
|
f'ARGS_DUMP="{_posix(dump)}"',
|
||||||
|
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
|
||||||
|
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
|
||||||
|
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
|
||||||
|
]
|
||||||
|
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
" ".join(prefix)
|
||||||
|
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
|
||||||
|
],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env=dict(os.environ),
|
||||||
|
)
|
||||||
|
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
|
||||||
|
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
|
||||||
|
threads = [a for a in args if a.startswith("message_thread_id=")]
|
||||||
|
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
|
||||||
|
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
|
||||||
|
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
|
||||||
|
|
||||||
Как это ломало алерты:
|
Как это ломало алерты:
|
||||||
|
|
||||||
* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый
|
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был
|
||||||
|
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН
|
||||||
|
`curl`, дальше `|| log WARN`. Каждый
|
||||||
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
||||||
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
||||||
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
||||||
|
|
@ -51,7 +53,6 @@ import pytest
|
||||||
# backend/tests/ops/<этот файл> → корень репозитория
|
# backend/tests/ops/<этот файл> → корень репозитория
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
UPTIME = "ops/uptime-healthcheck.sh"
|
|
||||||
LIB_BACKUP = "ops/lib-backup.sh"
|
LIB_BACKUP = "ops/lib-backup.sh"
|
||||||
|
|
||||||
# См. подробное обоснование shutil.which в
|
# См. подробное обоснование shutil.which в
|
||||||
|
|
@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за
|
||||||
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
||||||
"""Достаёт тело одной функции из скрипта — не весь файл.
|
"""Достаёт тело одной функции из скрипта — не весь файл.
|
||||||
|
|
||||||
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
|
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
|
||||||
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
|
|
||||||
|
|
||||||
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
||||||
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
||||||
|
|
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
|
||||||
return calls, out, err
|
return calls, out, err
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
||||||
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
||||||
"""Один отказ — алерт всё равно доставляется со второй попытки.
|
"""Один отказ — алерт всё равно доставляется со второй попытки.
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
||||||
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
|
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
||||||
def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
||||||
"""Повторы ограничены: три попытки, а не бесконечный цикл.
|
"""Повторы ограничены: три попытки, а не бесконечный цикл.
|
||||||
|
|
||||||
|
|
@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
||||||
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
|
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
|
||||||
|
|
||||||
|
|
||||||
def test_uptime_reports_undelivered_alert_loudly() -> None:
|
|
||||||
"""Когда все три попытки провалились — это видно в логе, а не молча."""
|
|
||||||
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
|
|
||||||
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
||||||
"""Транзиентный таймаут не должен трогать запасной канал.
|
"""Транзиентный таймаут не должен трогать запасной канал.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в
|
||||||
|
|
||||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||||
в этом же workflow — только для Prometheus починка не пересоздание
|
в этом же workflow. `POST /-/reload` лечит правила — они подключены
|
||||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает
|
||||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
|
||||||
без даунтайма.
|
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
|
||||||
|
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
|
||||||
|
поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С
|
||||||
|
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
|
||||||
|
|
||||||
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
|
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
|
||||||
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан
|
||||||
подтверждён сменой lastConfigTime.
|
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
|
||||||
|
Grafana перечитываются, и отказ этого шага роняет деплой.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
|
||||||
|
|
||||||
|
|
||||||
def _text() -> str:
|
def _text() -> str:
|
||||||
return WORKFLOW.read_text(encoding="utf-8")
|
return WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_start(text: str) -> int:
|
||||||
|
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
|
||||||
|
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
|
||||||
|
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
|
||||||
|
return guard
|
||||||
|
|
||||||
|
|
||||||
def test_promtool_checks_config_and_rules() -> None:
|
def test_promtool_checks_config_and_rules() -> None:
|
||||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||||
text = _text()
|
text = _text()
|
||||||
|
|
@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None:
|
||||||
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||||
text = _text()
|
text = _text()
|
||||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
guard_start = _guard_start(text)
|
||||||
else_pos = text.index("else", guard_start)
|
else_pos = text.index("else", guard_start)
|
||||||
reload_pos = text.index("localhost:9090/-/reload")
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
assert guard_start < reload_pos < else_pos, (
|
assert guard_start < reload_pos < else_pos, (
|
||||||
|
|
@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||||
text = _text()
|
text = _text()
|
||||||
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
guard_start = _guard_start(text)
|
||||||
else_pos = text.index("else", guard_start)
|
else_pos = text.index("else", guard_start)
|
||||||
fi_pos = text.index("fi", else_pos)
|
fi_pos = text.index("fi", else_pos)
|
||||||
else_branch = text[else_pos:fi_pos]
|
else_branch = text[else_pos:fi_pos]
|
||||||
|
|
@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
||||||
assert "exit 1" in verify_block, (
|
assert "exit 1" in verify_block, (
|
||||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
|
||||||
|
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
|
||||||
|
|
||||||
|
prometheus.yml смонтирован одним файлом: `docker exec … promtool check config`
|
||||||
|
в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку
|
||||||
|
и уехала бы в пересоздание.
|
||||||
|
"""
|
||||||
|
text = _text()
|
||||||
|
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
|
||||||
|
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
|
||||||
|
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
|
||||||
|
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
|
||||||
|
)
|
||||||
|
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
|
||||||
|
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
|
||||||
|
"""Пересоздание по иноду — после проверки promtool и до reload."""
|
||||||
|
text = _text()
|
||||||
|
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
||||||
|
recreate = re.search(
|
||||||
|
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
|
||||||
|
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
|
||||||
|
else_pos = text.index("else", _guard_start(text))
|
||||||
|
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
|
||||||
|
"пересоздание Prometheus стоит не между успешной проверкой и reload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
|
||||||
|
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
|
||||||
|
|
||||||
|
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
|
||||||
|
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
|
||||||
|
v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go).
|
||||||
|
"""
|
||||||
|
text = _text()
|
||||||
|
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
|
||||||
|
assert call != -1, "деплой не перечитывает датасорсы Grafana"
|
||||||
|
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
|
||||||
|
assert block, "результат перезагрузки датасорсов не разбирается"
|
||||||
|
ok, _, failed = block.group(1).partition(";;")
|
||||||
|
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
|
||||||
|
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"
|
||||||
|
|
|
||||||
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
|
||||||
|
|
||||||
|
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||||
|
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
|
||||||
|
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
|
||||||
|
POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
|
||||||
|
job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467).
|
||||||
|
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
|
||||||
|
postgres-экспортёров (#3486).
|
||||||
|
|
||||||
|
ЧТО ПРОВЕРЯЕТСЯ.
|
||||||
|
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
|
||||||
|
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
|
||||||
|
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
|
||||||
|
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
|
||||||
|
добавят новый — тест покраснеет, пока путь не появится.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
|
||||||
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
|
SRC = "ops/metrics/loki/loki-config.yml"
|
||||||
|
DST = "/etc/loki/loki-config.yml"
|
||||||
|
|
||||||
|
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
|
||||||
|
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
|
||||||
|
# код из $FAKE_PS_RC;
|
||||||
|
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
|
||||||
|
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
|
||||||
|
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
|
||||||
|
FAKE_DOCKER = r"""#!/bin/bash
|
||||||
|
printf '%s\n' "$*" >> "$FAKE_LOG"
|
||||||
|
cmd="$1"; shift
|
||||||
|
case "$cmd" in
|
||||||
|
exec)
|
||||||
|
view="$FAKE_VIEW/$1"
|
||||||
|
[ -f "$view" ] || exit 1
|
||||||
|
cat "$view"
|
||||||
|
;;
|
||||||
|
compose)
|
||||||
|
case " $* " in
|
||||||
|
*" ps "*)
|
||||||
|
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
|
||||||
|
cat "$FAKE_CID_FILE"
|
||||||
|
;;
|
||||||
|
*" up "*)
|
||||||
|
printf 'cid-new\n' > "$FAKE_CID_FILE"
|
||||||
|
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
|
||||||
|
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
|
||||||
|
STAT_SHIM = (
|
||||||
|
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
|
||||||
|
'exec /usr/bin/stat "$@"\n'
|
||||||
|
)
|
||||||
|
NO_SLEEP = "#!/bin/sh\nexit 0\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_exec(path: Path, text: str) -> None:
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
|
||||||
|
def _gnu_stat() -> bool:
|
||||||
|
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def host(tmp_path: Path) -> Path:
|
||||||
|
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
|
||||||
|
tree = tmp_path / "opt" / "gendesign"
|
||||||
|
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
|
||||||
|
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
|
||||||
|
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
|
||||||
|
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
||||||
|
_write_exec(bin_dir / "sleep", NO_SLEEP)
|
||||||
|
if not _gnu_stat():
|
||||||
|
_write_exec(bin_dir / "stat", STAT_SHIM)
|
||||||
|
|
||||||
|
view = tmp_path / "view"
|
||||||
|
view.mkdir()
|
||||||
|
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
|
||||||
|
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
|
||||||
|
(tmp_path / "log").write_text("", encoding="utf-8")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def _ino(tree: Path) -> int:
|
||||||
|
return os.stat(tree / SRC).st_ino
|
||||||
|
|
||||||
|
|
||||||
|
def _stale(tree: Path) -> None:
|
||||||
|
"""Контейнер держит прежний инод — как после `git reset --hard`."""
|
||||||
|
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
||||||
|
root = tree.parent.parent
|
||||||
|
env = {
|
||||||
|
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||||
|
"FAKE_LOG": str(root / "log"),
|
||||||
|
"FAKE_CID_FILE": str(root / "cid"),
|
||||||
|
"FAKE_VIEW": str(root / "view"),
|
||||||
|
**env_extra,
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
str(tree / "ops" / "metrics" / SCRIPT.name),
|
||||||
|
"gendesign-metrics",
|
||||||
|
"docker-compose.metrics.yml",
|
||||||
|
"loki",
|
||||||
|
SRC,
|
||||||
|
DST,
|
||||||
|
],
|
||||||
|
cwd="/", # скрипт обязан сам встать в корень дерева
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
||||||
|
return proc.returncode, proc.stdout + proc.stderr, calls
|
||||||
|
|
||||||
|
|
||||||
|
def _recreates(calls: list[str]) -> list[str]:
|
||||||
|
return [c for c in calls if "--force-recreate" in c]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_inode_touches_nothing(host: Path) -> None:
|
||||||
|
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
|
||||||
|
rc, out, calls = _run(host)
|
||||||
|
assert rc == 0, out
|
||||||
|
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
|
||||||
|
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
|
||||||
|
_stale(host)
|
||||||
|
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||||
|
assert rc == 0, out
|
||||||
|
recreates = _recreates(calls)
|
||||||
|
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
|
||||||
|
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
|
||||||
|
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
|
||||||
|
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
|
||||||
|
assert any(c.startswith("exec cid-new ") for c in calls), (
|
||||||
|
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
|
||||||
|
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
|
||||||
|
_stale(host)
|
||||||
|
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
|
||||||
|
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
|
||||||
|
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
|
||||||
|
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
|
||||||
|
(host.parent.parent / "view" / "cid-old").unlink()
|
||||||
|
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||||
|
assert rc == 0, out
|
||||||
|
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_running_container_is_left_alone(host: Path) -> None:
|
||||||
|
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
|
||||||
|
(host.parent.parent / "cid").write_text("", encoding="utf-8")
|
||||||
|
rc, out, calls = _run(host)
|
||||||
|
assert rc == 0, out
|
||||||
|
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
|
||||||
|
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
|
||||||
|
_stale(host)
|
||||||
|
rc, out, calls = _run(host, FAKE_PS_RC="1")
|
||||||
|
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
|
||||||
|
assert not _recreates(calls), calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_host_file_is_not_success(host: Path) -> None:
|
||||||
|
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
|
||||||
|
(host / SRC).unlink()
|
||||||
|
rc, out, _calls = _run(host)
|
||||||
|
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
|
||||||
|
|
||||||
|
# Производные файлы: их рендерит сам деплой, в git их нет.
|
||||||
|
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
|
||||||
|
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
|
||||||
|
RENDERED = {
|
||||||
|
"ops/metrics/alertmanager/alertmanager.yml",
|
||||||
|
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
|
||||||
|
}
|
||||||
|
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
|
||||||
|
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
|
||||||
|
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
|
||||||
|
COMPOSE_FILES = {
|
||||||
|
"docker-compose.metrics.yml": "gendesign-metrics",
|
||||||
|
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
|
||||||
|
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
|
||||||
|
if compose_name == "docker-compose.metrics.yml":
|
||||||
|
return ["server"]
|
||||||
|
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
|
||||||
|
|
||||||
|
|
||||||
|
def _commands(job: str) -> str:
|
||||||
|
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
|
||||||
|
|
||||||
|
Комментарии — потому что разбор дефекта содержит его формулировку дословно.
|
||||||
|
"""
|
||||||
|
spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||||
|
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||||||
|
assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп"
|
||||||
|
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
|
||||||
|
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
||||||
|
|
||||||
|
|
||||||
|
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
|
||||||
|
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
|
||||||
|
found = []
|
||||||
|
for service, body in spec["services"].items():
|
||||||
|
for vol in body.get("volumes") or []:
|
||||||
|
m = MOUNT.match(vol) if isinstance(vol, str) else None
|
||||||
|
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
|
||||||
|
continue
|
||||||
|
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
|
||||||
|
script_call = re.search(
|
||||||
|
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
|
||||||
|
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
|
||||||
|
commands,
|
||||||
|
)
|
||||||
|
forced = re.search(
|
||||||
|
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
|
||||||
|
)
|
||||||
|
return script_call or forced
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
|
||||||
|
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
|
||||||
|
project = COMPOSE_FILES[compose_name]
|
||||||
|
mounts = _file_mounts(compose_name)
|
||||||
|
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
|
||||||
|
missing = []
|
||||||
|
for service, src, dst, profiles in mounts:
|
||||||
|
for job in _jobs_for(compose_name, profiles):
|
||||||
|
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
|
||||||
|
if not hit:
|
||||||
|
missing.append(f"{job}: {service} {src} → {dst}")
|
||||||
|
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
|
||||||
|
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
|
||||||
|
assert not missing, (
|
||||||
|
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
|
||||||
|
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_sees_the_known_mounts() -> None:
|
||||||
|
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
|
||||||
|
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
|
||||||
|
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
|
||||||
|
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
|
||||||
|
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
|
||||||
|
for exporter in ("gendesign", "tradein", "infra"):
|
||||||
|
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_would_notice_a_mount_without_arrival() -> None:
|
||||||
|
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
|
||||||
|
commands = _commands("server")
|
||||||
|
assert not _arrival(
|
||||||
|
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
|
||||||
|
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"
|
||||||
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
103
backend/tests/services/scrapers/test_2982_act_number.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
|
||||||
|
|
||||||
|
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
|
||||||
|
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
|
||||||
|
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
|
||||||
|
|
||||||
|
Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation
|
||||||
|
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF:
|
||||||
|
1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1»
|
||||||
|
(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен.
|
||||||
|
|
||||||
|
Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main
|
||||||
|
неверным значением (None вместо номера), а не ошибкой сигнатуры.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
|
||||||
|
from app.services.scrapers.page_reservation_parser import extract_reservations
|
||||||
|
from tests.services.scrapers.test_2464_act_date_citation import (
|
||||||
|
ДОК_ПЕРЕМЕШАННЫЙ,
|
||||||
|
ДОК_ПЕРЕМЕШАННЫЙ_2,
|
||||||
|
ДОК_ПРЯМОЙ,
|
||||||
|
ДОК_РАЗВЯЗКА,
|
||||||
|
ДОК_ЭНЕРГОСТРОИТЕЛЕЙ,
|
||||||
|
ЗАГОЛОВОК,
|
||||||
|
КАД,
|
||||||
|
)
|
||||||
|
|
||||||
|
ПЯТЬ_ДОКУМЕНТОВ = [
|
||||||
|
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
|
||||||
|
pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
|
||||||
|
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
|
||||||
|
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
|
||||||
|
pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _запись(текст: str) -> dict:
|
||||||
|
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
|
||||||
|
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
|
||||||
|
return записи[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||||
|
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
|
||||||
|
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
|
||||||
|
чья дата стоит в act_date, — а не Решения Думы или приказа Министерства."""
|
||||||
|
запись = _запись(текст)
|
||||||
|
assert запись["act_number"] == номер
|
||||||
|
assert запись["act_date"] == дата
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
||||||
|
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None:
|
||||||
|
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
|
||||||
|
|
||||||
|
Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и
|
||||||
|
«№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
|
||||||
|
"""
|
||||||
|
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
|
||||||
|
|
||||||
|
|
||||||
|
def test_citations_alone_give_no_number() -> None:
|
||||||
|
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
|
||||||
|
текст = (
|
||||||
|
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
|
||||||
|
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
|
||||||
|
"области от 30.12.2021 № 746-П"
|
||||||
|
)
|
||||||
|
assert _запись(текст)["act_number"] is None
|
||||||
|
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
|
||||||
|
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
|
||||||
|
|
||||||
|
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
|
||||||
|
Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413:
|
||||||
|
в строке оказалась бы пара реквизитов от двух разных документов.
|
||||||
|
"""
|
||||||
|
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
|
||||||
|
запись = _запись(текст)
|
||||||
|
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
|
||||||
|
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
|
||||||
|
следующего «№ …-ПП» — номера цитируемого постановления Правительства."""
|
||||||
|
текст = (
|
||||||
|
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
|
||||||
|
"В соответствии с постановлением Правительства Свердловской области "
|
||||||
|
f"от 26.02.2020 № 100-ПП\n{КАД}"
|
||||||
|
)
|
||||||
|
assert extract_reservations(текст)[0].act_number == "1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_regional_suffix_still_extracted() -> None:
|
||||||
|
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
|
||||||
|
assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
|
||||||
|
assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"
|
||||||
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
134
backend/tests/sql/test_2982_land_reservation_reparse.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Починка разбора номера не удваивает land_reservation (#2982).
|
||||||
|
|
||||||
|
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
|
||||||
|
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
|
||||||
|
строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон
|
||||||
|
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
|
||||||
|
|
||||||
|
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
|
||||||
|
формы: боевые данные не читаются и не меняются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn() -> str:
|
||||||
|
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
||||||
|
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
raw
|
||||||
|
if raw.startswith("postgresql+")
|
||||||
|
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||||
|
with eng.connect() as c:
|
||||||
|
c.execute(text("SELECT 1"))
|
||||||
|
return True, ""
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
_DB_OK, _DB_ERR = _db_reachable()
|
||||||
|
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
|
||||||
|
|
||||||
|
_TABLE = """
|
||||||
|
CREATE TEMP TABLE land_reservation (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
cad_num text NOT NULL,
|
||||||
|
reservation_kind text NOT NULL,
|
||||||
|
basis_act text NOT NULL,
|
||||||
|
act_number text,
|
||||||
|
act_date date,
|
||||||
|
purpose text,
|
||||||
|
doc_url text,
|
||||||
|
source text NOT NULL DEFAULT 'page_pdf',
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
raw_excerpt text,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
"""
|
||||||
|
|
||||||
|
КАД = "66:41:0303004:22"
|
||||||
|
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
|
||||||
|
|
||||||
|
|
||||||
|
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
|
||||||
|
return {
|
||||||
|
"cad_num": cad,
|
||||||
|
"reservation_kind": "изъятие",
|
||||||
|
"basis_act": "Сообщение о планируемом изъятии",
|
||||||
|
"act_number": act,
|
||||||
|
"act_date": "2022-05-27",
|
||||||
|
"purpose": None,
|
||||||
|
"doc_url": url,
|
||||||
|
"source": "izyatie_ekb_ocr",
|
||||||
|
"raw_excerpt": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db():
|
||||||
|
engine = create_engine(_dsn())
|
||||||
|
session = sessionmaker(bind=engine)()
|
||||||
|
try:
|
||||||
|
session.execute(text(_TABLE))
|
||||||
|
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
||||||
|
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(db) -> list[tuple[str, str | None, str]]:
|
||||||
|
return [
|
||||||
|
(r[0], r[1], r[2])
|
||||||
|
for r in db.execute(
|
||||||
|
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
|
||||||
|
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
|
||||||
|
_upsert_records(db, [_row(act=None)])
|
||||||
|
assert _rows(db) == [(КАД, None, ДОК)]
|
||||||
|
|
||||||
|
assert _upsert_records(db, [_row(act="1413")]) == 1
|
||||||
|
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
|
||||||
|
|
||||||
|
_upsert_records(db, [_row(act="1413")])
|
||||||
|
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_parcel_in_another_document_is_kept(db) -> None:
|
||||||
|
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
|
||||||
|
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
|
||||||
|
_upsert_records(db, [_row(act="259", url=другой)])
|
||||||
|
_upsert_records(db, [_row(act=None)])
|
||||||
|
_upsert_records(db, [_row(act="1413")])
|
||||||
|
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_parcels_of_same_document_are_kept(db) -> None:
|
||||||
|
"""Контроль: чистка идёт по участку, а не по всему документу."""
|
||||||
|
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
|
||||||
|
_upsert_records(db, [_row(act="1413")])
|
||||||
|
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]
|
||||||
38
backend/tests/workers/test_beat_schedule_domrf_catalog.py
Normal file
38
backend/tests/workers/test_beat_schedule_domrf_catalog.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
|
||||||
|
|
||||||
|
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
|
||||||
|
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
|
||||||
|
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
|
||||||
|
значению: включить запись = осознанно поправить этот тест после #3307.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
_BLOCKED_TASKS = (
|
||||||
|
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||||
|
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
|
||||||
|
from app.workers import beat_schedule
|
||||||
|
|
||||||
|
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
|
||||||
|
schedule = beat_schedule.build_beat_schedule()
|
||||||
|
|
||||||
|
# Контроль: статическая часть расписания реально построилась, иначе
|
||||||
|
# отсутствие задач ниже было бы зелёным по построению.
|
||||||
|
assert schedule["refresh-quarter-price-index"]["task"] == (
|
||||||
|
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
|
||||||
|
)
|
||||||
|
|
||||||
|
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
|
||||||
|
scheduled = {
|
||||||
|
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
|
||||||
|
}
|
||||||
|
assert scheduled == {}, (
|
||||||
|
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
|
||||||
|
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
|
||||||
|
)
|
||||||
|
|
@ -1919,17 +1919,17 @@ export interface paths {
|
||||||
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
*
|
*
|
||||||
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
||||||
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
* Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
||||||
* объектов до next beat fire).
|
* свежесозданных объектов).
|
||||||
*
|
*
|
||||||
* - max_objects=None → дефолтный лимит таска (300).
|
* - max_objects=None → дефолтный лимит таска (300).
|
||||||
* - max_objects=3 → smoke-тест.
|
* - max_objects=3 → smoke-тест.
|
||||||
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
*
|
*
|
||||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
||||||
* re-trigger без явного подтверждения оператора запрещён.
|
* запуск без явного подтверждения оператора запрещён (условие — #3307).
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
@ -1960,9 +1960,9 @@ export interface paths {
|
||||||
* - max_flats=3 → smoke-тест.
|
* - max_flats=3 → smoke-тест.
|
||||||
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
*
|
*
|
||||||
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
||||||
* того же WAF-бана.
|
* блокировкой StormWall (условие — #3307).
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@
|
||||||
# script says so loudly instead of the silence that let backups break for
|
# script says so loudly instead of the silence that let backups break for
|
||||||
# weeks undetected before (#71).
|
# weeks undetected before (#71).
|
||||||
#
|
#
|
||||||
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
|
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
|
||||||
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
|
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
|
||||||
# Telegram once a backup is already known to be stale.
|
|
||||||
#
|
#
|
||||||
# Usage (cron — one line per sentinel, run more often than the backup itself
|
# Usage (cron — one line per sentinel, run more often than the backup itself
|
||||||
# so a stale state is caught promptly; hourly is a reasonable default for a
|
# so a stale state is caught promptly; hourly is a reasonable default for a
|
||||||
|
|
@ -27,9 +26,10 @@
|
||||||
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
||||||
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
#
|
#
|
||||||
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
|
# Alert channel: notify() in ops/lib-backup.sh — creds in
|
||||||
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
|
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
|
||||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
|
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first
|
||||||
|
# two, logs only.
|
||||||
#
|
#
|
||||||
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
||||||
# used as a plain healthcheck by anything that just wants the exit code).
|
# used as a plain healthcheck by anything that just wants the exit code).
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@
|
||||||
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
|
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
|
||||||
|
|
||||||
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
|
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
|
||||||
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env
|
||||||
|
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
|
||||||
|
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
|
||||||
|
# этом хосте нет, — алерты молча не уходят никуда.
|
||||||
|
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||||
|
|
||||||
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
||||||
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
||||||
|
|
@ -33,11 +37,11 @@
|
||||||
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
||||||
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
||||||
# чтобы три задачи не дрались за диск и сеть одновременно.
|
# чтобы три задачи не дрались за диск и сеть одновременно.
|
||||||
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
||||||
|
|
||||||
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
|
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
|
||||||
# сутки плюс запас на сдвиг прогона.
|
# сутки плюс запас на сдвиг прогона.
|
||||||
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||||
|
|
||||||
# ── Уборка докера ───────────────────────────────────────────────────────────
|
# ── Уборка докера ───────────────────────────────────────────────────────────
|
||||||
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
||||||
|
|
|
||||||
|
|
@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY=
|
||||||
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
||||||
#TELEGRAM_BOT_TOKEN=
|
#TELEGRAM_BOT_TOKEN=
|
||||||
#TELEGRAM_CHAT_ID=
|
#TELEGRAM_CHAT_ID=
|
||||||
|
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||||
|
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||||
|
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||||
|
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||||
|
#TELEGRAM_TOPIC_ID=245
|
||||||
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
||||||
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@
|
||||||
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
||||||
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
||||||
|
|
||||||
|
# --- оповещения (Telegram) ---
|
||||||
|
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
|
||||||
|
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
|
||||||
|
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
|
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||||
|
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||||
|
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||||
|
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||||
|
#TELEGRAM_TOPIC_ID=245
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
||||||
# the policy's Resource path exactly
|
# the policy's Resource path exactly
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,8 @@
|
||||||
#
|
#
|
||||||
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
||||||
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
||||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
|
||||||
# /etc/default/gendesign-uptime — deliberately a separate config so backup
|
# Without the first two set, ops/check-backup-staleness.sh still logs, just
|
||||||
# alerting doesn't depend on the uptime watchdog's env file existing).
|
|
||||||
# SAME variable names as ops/gendesign-uptime.default.example — point both
|
|
||||||
# files at the same bot/chat if you want one Telegram destination for
|
|
||||||
# everything, or use different bots/chats if you'd rather split the noise.
|
|
||||||
# Without these two set, ops/check-backup-staleness.sh still logs, just
|
|
||||||
# doesn't send a Telegram alert.
|
# doesn't send a Telegram alert.
|
||||||
|
|
||||||
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
||||||
|
|
@ -41,9 +36,14 @@
|
||||||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
||||||
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
||||||
|
|
||||||
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
|
# --- missed-run alerting (Telegram) ---
|
||||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
#TELEGRAM_CHAT_ID=123456789
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
|
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
||||||
|
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
||||||
|
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
||||||
|
# чат: notify() читает env-файл, только если их нет в окружении.
|
||||||
|
#TELEGRAM_TOPIC_ID=245
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
|
|
||||||
#
|
|
||||||
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
|
|
||||||
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
|
|
||||||
# VPS outage):
|
|
||||||
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
|
|
||||||
# sudo chmod 600 /etc/default/gendesign-uptime
|
|
||||||
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
|
|
||||||
#
|
|
||||||
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
|
|
||||||
# it still logs up/down but sends no alert (useful for a dry run first).
|
|
||||||
#
|
|
||||||
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
|
|
||||||
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
|
|
||||||
|
|
||||||
# --- Telegram alerting (both required to enable alerts) ---
|
|
||||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
|
||||||
#TELEGRAM_CHAT_ID=123456789
|
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
|
||||||
#BASE_URL=https://gendsgn.ru
|
|
||||||
#STATE_FILE=/var/tmp/gendesign-uptime-state
|
|
||||||
#CURL_TIMEOUT=15
|
|
||||||
#RETRIES=2
|
|
||||||
#RETRY_SLEEP=5
|
|
||||||
# Custom check list (newline-separated "label|path|expected_status"):
|
|
||||||
#CHECKS="health|/health|200
|
|
||||||
#market-pulse|/api/v1/analytics/market-pulse|200"
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
||||||
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
||||||
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
||||||
# notify() when Telegram vars are actually set — curl is already a hard
|
# notify() when Telegram vars are actually set).
|
||||||
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
|
|
||||||
# new dependency).
|
|
||||||
#
|
#
|
||||||
# Load with (script computes its own dir so this works regardless of cron's
|
# Load with (script computes its own dir so this works regardless of cron's
|
||||||
# CWD or which repo subdir the caller lives in):
|
# CWD or which repo subdir the caller lives in):
|
||||||
|
|
@ -18,14 +16,8 @@
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||||
|
|
||||||
# --- notify ------------------------------------------------------------
|
# --- notify ------------------------------------------------------------
|
||||||
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
|
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
|
||||||
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
|
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
|
||||||
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
|
|
||||||
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
|
|
||||||
# backup alerting doesn't depend on the uptime watchdog's config being
|
|
||||||
# present, and vice versa. Point both files at the same bot/chat if you want
|
|
||||||
# one Telegram destination for everything — that's an ops choice, not this
|
|
||||||
# script's concern.
|
|
||||||
#
|
#
|
||||||
# No-op (logs only) when unset — this is the extension point: to wire a
|
# No-op (logs only) when unset — this is the extension point: to wire a
|
||||||
# different channel later, edit ONLY this function; every caller in this repo
|
# different channel later, edit ONLY this function; every caller in this repo
|
||||||
|
|
@ -153,12 +145,9 @@ sentinel_age_hours() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- transition-tracked alert state --------------------------------------
|
# --- transition-tracked alert state --------------------------------------
|
||||||
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
|
# A flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||||
# flat "<label> <status>" file, one line per label, so repeated runs alert
|
|
||||||
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
||||||
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
||||||
# Intentionally a separate, independent implementation (not shared code with
|
|
||||||
# uptime-healthcheck.sh) — that script is out of scope for this change.
|
|
||||||
backup_prev_status() {
|
backup_prev_status() {
|
||||||
local state_file="$1" label="$2" v
|
local state_file="$1" label="$2" v
|
||||||
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Конверсия оценка → заявка, скользящие 7 суток",
|
||||||
|
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
|
||||||
|
"legendFormat": "заявки / оценки"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,40 @@ groups:
|
||||||
- name: app
|
- name: app
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
|
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
|
||||||
|
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
|
||||||
|
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
|
||||||
|
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
|
||||||
|
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
|
||||||
|
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
|
||||||
|
# молчат по построению.
|
||||||
|
#
|
||||||
|
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
|
||||||
|
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
|
||||||
|
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
|
||||||
|
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
|
||||||
|
# тихо пропала из alloy-apps.alloy.
|
||||||
|
#
|
||||||
|
# for: 5m — по истории up{app="mera"} 26.08–17.09 (61 857 точек): 140
|
||||||
|
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
|
||||||
|
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
|
||||||
|
#
|
||||||
|
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
|
||||||
|
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
|
||||||
|
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
|
||||||
|
# заводится в UI GlitchTip, а не кодом.
|
||||||
|
- alert: MeraBackendDown
|
||||||
|
expr: |
|
||||||
|
up{job="app", app="mera"} == 0
|
||||||
|
or absent(up{job="app", app="mera"})
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
host: apps
|
||||||
|
annotations:
|
||||||
|
summary: "Бэкенд «Меры» не отвечает"
|
||||||
|
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||||
|
|
||||||
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
||||||
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
||||||
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,58 @@ tests:
|
||||||
exp_annotations:
|
exp_annotations:
|
||||||
summary: "Ни одного живого воркера Celery"
|
summary: "Ни одного живого воркера Celery"
|
||||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||||||
|
|
||||||
|
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
|
||||||
|
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
|
||||||
|
- interval: 30s
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
||||||
|
values: '1x9 0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 15m
|
||||||
|
alertname: MeraBackendDown
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
host: apps
|
||||||
|
job: app
|
||||||
|
app: mera
|
||||||
|
instance: tradein-backend:8000
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Бэкенд «Меры» не отвечает"
|
||||||
|
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||||
|
|
||||||
|
# Окно деплоя: самый длинный провал за 26.08–17.09 — 4 нулевые точки подряд
|
||||||
|
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
|
||||||
|
# поднимать тревогу про «Меру».
|
||||||
|
- interval: 30s
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
||||||
|
values: '1x19 0 0 0 0 1x40'
|
||||||
|
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
||||||
|
values: '0x64'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 11m
|
||||||
|
alertname: MeraBackendDown
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: MeraBackendDown
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
|
||||||
|
- interval: 30s
|
||||||
|
input_series:
|
||||||
|
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
||||||
|
values: '1x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 10m
|
||||||
|
alertname: MeraBackendDown
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
host: apps
|
||||||
|
job: app
|
||||||
|
app: mera
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Бэкенд «Меры» не отвечает"
|
||||||
|
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||||||
|
|
|
||||||
65
ops/metrics/recreate-stale-mount.sh
Executable file
65
ops/metrics/recreate-stale-mount.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
|
||||||
|
# (#3467, #3486).
|
||||||
|
#
|
||||||
|
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
|
||||||
|
#
|
||||||
|
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||||
|
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
|
||||||
|
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
|
||||||
|
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
|
||||||
|
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
|
||||||
|
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
|
||||||
|
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
|
||||||
|
# расхождении, чтобы не рвать работу на каждом деплое.
|
||||||
|
#
|
||||||
|
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
|
||||||
|
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
|
||||||
|
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
|
||||||
|
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
|
||||||
|
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
|
||||||
|
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
|
||||||
|
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 5 ]; then
|
||||||
|
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
|
||||||
|
# «контейнер не запущен» — и зелёный выход со старым конфигом.
|
||||||
|
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
|
||||||
|
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
id=$(cid)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "$service: контейнер не запущен — старый инод $src держать некому."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
want=$(stat -c %i "$src")
|
||||||
|
if [ "$(seen "$id")" = "$want" ]; then
|
||||||
|
echo "$service: $src доехал до контейнера (инод $want)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
|
||||||
|
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
|
||||||
|
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 15 ]; do
|
||||||
|
id=$(cid)
|
||||||
|
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
|
||||||
|
echo "$service: пересоздан, $src доехал (инод $want)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
|
||||||
|
exit 1
|
||||||
|
|
@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
|
||||||
#
|
#
|
||||||
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
||||||
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
||||||
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
|
# ops/lib-backup.sh (уведомления о бэкапах).
|
||||||
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
|
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
|
||||||
# мы узнали бы о любой другой поломке.
|
# мы узнали бы о любой другой поломке.
|
||||||
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
|
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
|
|
||||||
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
|
|
||||||
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
|
|
||||||
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
|
|
||||||
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
|
|
||||||
# this covers the case Kuma structurally can't.
|
|
||||||
#
|
|
||||||
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
|
|
||||||
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
|
|
||||||
# (up→down, down→up) — not every run — to avoid Telegram spam.
|
|
||||||
#
|
|
||||||
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
|
|
||||||
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
|
|
||||||
#
|
|
||||||
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
|
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
|
||||||
# TELEGRAM_CHAT_ID=123456789
|
|
||||||
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
|
|
||||||
# A redacted template lives at ops/gendesign-uptime.default.example.
|
|
||||||
# Without a token set, the script still logs up/down but sends no alert.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --- config (env-overridable) ---
|
|
||||||
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
|
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
|
|
||||||
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
|
|
||||||
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
|
|
||||||
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
|
|
||||||
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
|
|
||||||
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
|
||||||
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
|
||||||
|
|
||||||
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
|
|
||||||
# Override the whole list via CHECKS env (same newline-separated format).
|
|
||||||
#
|
|
||||||
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
|
|
||||||
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
|
|
||||||
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
|
|
||||||
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
|
|
||||||
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
|
|
||||||
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
|
|
||||||
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
|
|
||||||
CHECKS="${CHECKS:-health|/health|200}"
|
|
||||||
|
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
|
||||||
|
|
||||||
# --- telegram (no-op if token/chat unset) ---
|
|
||||||
notify() {
|
|
||||||
local text="$1"
|
|
||||||
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
|
|
||||||
log "NOTIFY (telegram disabled — no token/chat): $text"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
|
|
||||||
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
|
|
||||||
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
|
|
||||||
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
|
|
||||||
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
|
|
||||||
#
|
|
||||||
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
|
|
||||||
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
|
|
||||||
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
|
|
||||||
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
|
|
||||||
# тем вероятнее, что о них не сообщат.
|
|
||||||
#
|
|
||||||
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
|
|
||||||
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
|
|
||||||
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
|
|
||||||
# применяется к КАЖДОЙ попытке.
|
|
||||||
#
|
|
||||||
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
|
|
||||||
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
|
|
||||||
# запроса, так что повтор почти никогда не дублирует уже доставленное
|
|
||||||
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
|
|
||||||
local attempt
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if curl -fsS --max-time "$CURL_TIMEOUT" \
|
|
||||||
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
|
||||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
|
||||||
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
|
|
||||||
-d "disable_web_page_preview=true" \
|
|
||||||
--data-urlencode "text=${text}" \
|
|
||||||
>/dev/null 2>&1; then
|
|
||||||
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
|
|
||||||
done
|
|
||||||
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- state helpers (last status per check) ---
|
|
||||||
prev_status() {
|
|
||||||
local label="$1"
|
|
||||||
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
|
|
||||||
# Line format: "<label> <status>". grep the latest for this label.
|
|
||||||
local v
|
|
||||||
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
|
|
||||||
echo "${v:-unknown}"
|
|
||||||
}
|
|
||||||
|
|
||||||
set_status() {
|
|
||||||
local label="$1" status="$2" tmp
|
|
||||||
tmp="$(mktemp)"
|
|
||||||
# Drop any prior line for this label, then append the fresh one.
|
|
||||||
if [[ -f "$STATE_FILE" ]]; then
|
|
||||||
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "${label} ${status}" >> "$tmp"
|
|
||||||
mv "$tmp" "$STATE_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
|
|
||||||
probe() {
|
|
||||||
local url="$1" expect="$2" attempt code
|
|
||||||
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
|
|
||||||
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
|
|
||||||
# never need a `|| echo` fallback (which previously concatenated → "401000").
|
|
||||||
for attempt in $(seq 1 "$((RETRIES + 1))"); do
|
|
||||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
|
|
||||||
code="${code:-000}"
|
|
||||||
if [[ "$code" == "$expect" ]]; then
|
|
||||||
echo "up"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
|
|
||||||
done
|
|
||||||
# Report the last code seen for the alert body.
|
|
||||||
echo "down code=${code}"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- run ---
|
|
||||||
overall_rc=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[[ -z "$line" ]] && continue
|
|
||||||
label="${line%%|*}"
|
|
||||||
rest="${line#*|}"
|
|
||||||
path="${rest%%|*}"
|
|
||||||
expect="${rest##*|}"
|
|
||||||
url="${BASE_URL}${path}"
|
|
||||||
|
|
||||||
result="$(probe "$url" "$expect")"
|
|
||||||
now="up"
|
|
||||||
[[ "$result" == up ]] || now="down"
|
|
||||||
prev="$(prev_status "$label")"
|
|
||||||
|
|
||||||
if [[ "$now" == "up" ]]; then
|
|
||||||
log "OK $label ($url)"
|
|
||||||
if [[ "$prev" == "down" ]]; then
|
|
||||||
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
overall_rc=1
|
|
||||||
log "DOWN $label ($url) — $result"
|
|
||||||
# Alert on transition into down (or first-ever observation that is down).
|
|
||||||
if [[ "$prev" != "down" ]]; then
|
|
||||||
notify "🔴 DOWN: gendesign $label — ${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
set_status "$label" "$now"
|
|
||||||
done <<< "$CHECKS"
|
|
||||||
|
|
||||||
exit "$overall_rc"
|
|
||||||
|
|
@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
|
||||||
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
|
from scraper_kit.providers.yandex.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
|
||||||
|
|
||||||
|
|
@ -516,16 +517,13 @@ async def cian_auto_login(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
|
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
|
||||||
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
|
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
|
||||||
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
|
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
|
||||||
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
|
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
|
||||||
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
|
async with build_browser_fetcher(
|
||||||
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
|
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
|
||||||
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
|
) as fetcher:
|
||||||
# `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,
|
||||||
|
|
@ -537,6 +535,13 @@ 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(
|
||||||
|
|
@ -1252,8 +1257,11 @@ async def start_avito_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_imv=payload.enrich_imv,
|
enrich_imv=payload.enrich_imv,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
||||||
|
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||||
|
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||||
|
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1344,8 +1352,11 @@ async def start_cian_city_sweep(
|
||||||
detail_top_n=payload.detail_top_n,
|
detail_top_n=payload.detail_top_n,
|
||||||
enrich_houses=payload.enrich_houses,
|
enrich_houses=payload.enrich_houses,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
||||||
|
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||||
|
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||||
|
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1487,8 +1498,11 @@ async def start_cian_full_load(
|
||||||
resume_run_id=payload.resume_run_id,
|
resume_run_id=payload.resume_run_id,
|
||||||
secondary_only=payload.secondary_only,
|
secondary_only=payload.secondary_only,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
||||||
|
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||||
|
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||||
|
runs_mod.mark_crashed(task_db, run_id, exc)
|
||||||
finally:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1590,8 +1604,11 @@ async def start_yandex_full_load(
|
||||||
concurrency=payload.concurrency,
|
concurrency=payload.concurrency,
|
||||||
resume_run_id=payload.resume_run_id,
|
resume_run_id=payload.resume_run_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
|
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
|
||||||
|
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||||
|
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||||
|
runs_mod.mark_crashed(task_db, run_id, exc)
|
||||||
finally:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1658,8 +1675,11 @@ async def start_yandex_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_address=payload.enrich_address,
|
enrich_address=payload.enrich_address,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
||||||
|
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
||||||
|
# до финализатора пайплайна прогон висел 'running' до zombie.
|
||||||
|
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||||
|
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
|
||||||
|
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
|
||||||
|
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
|
||||||
|
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
|
||||||
|
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
|
||||||
|
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
|
||||||
|
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
|
||||||
|
_INTERACTIVE_SEND_DEADLINE_S = 7.0
|
||||||
|
|
||||||
|
|
||||||
class GlitchTipAttachment(BaseModel):
|
class GlitchTipAttachment(BaseModel):
|
||||||
|
|
@ -234,6 +243,7 @@ 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,
|
||||||
|
|
@ -243,10 +253,11 @@ async def glitchtip_webhook(
|
||||||
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). 502 ОСТАЁТСЯ — это честный
|
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
|
||||||
|
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
|
||||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
||||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
||||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,16 @@
|
||||||
|
|
||||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||||
телефон + явное согласие на обработку персональных данных. Persist в
|
телефон + явное согласие на обработку персональных данных. Persist в
|
||||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
trade_in_leads.
|
||||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
|
||||||
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
|
||||||
|
уходит сообщением в support-топик Telegram — тот же бот и топик, что у веб-чата
|
||||||
|
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
|
||||||
|
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
|
||||||
|
Telegram не трогает ни ответ, ни лид — остаётся лог и пустой `notified_at` как
|
||||||
|
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
|
||||||
|
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id —
|
||||||
|
телефон оператор берёт из trade_in_leads.
|
||||||
|
|
||||||
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||||
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||||
|
|
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.v1.support import _bot_configured
|
||||||
from app.api.v1.trade_in import _assert_estimate_access
|
from app.api.v1.trade_in import _assert_estimate_access
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import SessionLocal, get_db
|
||||||
from app.observability.metrics import LEADS
|
from app.observability.metrics import LEADS
|
||||||
|
from app.services.tgbot.shared import get_telegram_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
|
||||||
|
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
|
||||||
|
|
||||||
|
Идёт после ответа клиенту, поэтому ретраи клиента — штатные воркерные, без
|
||||||
|
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
|
||||||
|
сессия запроса к этому моменту уже закрыта. Любой отказ — только в лог.
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
"Новая заявка на трейд-ин\n"
|
||||||
|
f"id: {lead_id}\n"
|
||||||
|
f"пользователь: {username or '—'}\n"
|
||||||
|
f"оценка: {estimate_id or 'без привязки'}\n"
|
||||||
|
"Телефон — в trade_in_leads по id."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await get_telegram_client().send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=message,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
|
||||||
|
{"id": lead_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lead")
|
@router.post("/lead")
|
||||||
async def create_trade_in_lead(
|
async def create_trade_in_lead(
|
||||||
payload: TradeInLeadInput,
|
payload: TradeInLeadInput,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||||
|
|
@ -185,6 +232,15 @@ async def create_trade_in_lead(
|
||||||
_CONSENT_POLICY_VERSION,
|
_CONSENT_POLICY_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if _bot_configured():
|
||||||
|
background_tasks.add_task(
|
||||||
|
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"created_at": row["created_at"].isoformat(),
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -718,6 +718,7 @@ def load_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
|
analog_tier_from_explanation,
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -896,6 +897,10 @@ def load_estimate(
|
||||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||||
search_radius_m=search_radius_m,
|
search_radius_m=search_radius_m,
|
||||||
|
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
|
||||||
|
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
|
||||||
|
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
|
||||||
|
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
|
||||||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
||||||
|
|
||||||
|
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
|
||||||
|
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
|
||||||
|
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
|
||||||
|
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
|
||||||
|
CORRIDOR_CLAMP_MIN_N = 10
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
@ -378,17 +384,6 @@ class Settings(BaseSettings):
|
||||||
dadata_api_token: str | None = None
|
dadata_api_token: str | None = None
|
||||||
dadata_api_secret: str | None = None
|
dadata_api_secret: str | None = None
|
||||||
|
|
||||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
|
||||||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
|
||||||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
|
||||||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
|
||||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
|
||||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
|
||||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
|
||||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
|
||||||
|
|
||||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||||
|
|
@ -408,25 +403,6 @@ class Settings(BaseSettings):
|
||||||
# False ⇒ прежнее поведение целиком, без релиза.
|
# False ⇒ прежнее поведение целиком, без релиза.
|
||||||
estimate_require_local_evidence: bool = True
|
estimate_require_local_evidence: bool = True
|
||||||
|
|
||||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
|
||||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
|
||||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
|
||||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
|
||||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
|
||||||
# и tighter FSD-диапазон.
|
|
||||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
|
||||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
|
||||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
|
||||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
|
||||||
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
|
||||||
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
|
||||||
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
|
||||||
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
|
||||||
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
|
||||||
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
|
||||||
estimate_sb_floor_sigma: float = 0.25
|
|
||||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
|
||||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
|
||||||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||||
|
|
@ -521,128 +497,13 @@ class Settings(BaseSettings):
|
||||||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||||
estimate_hedonic_factor_min: float = 0.75
|
estimate_hedonic_factor_min: float = 0.75
|
||||||
estimate_hedonic_factor_max: float = 1.30
|
estimate_hedonic_factor_max: float = 1.30
|
||||||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
|
||||||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
|
||||||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
|
||||||
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
|
|
||||||
#
|
|
||||||
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
|
|
||||||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
|
||||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
|
||||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
|
||||||
estimate_corridor_clamp_min_n: int = 10
|
|
||||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
|
||||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
|
||||||
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
|
||||||
estimate_corridor_clamp_slack: float = 0.40
|
|
||||||
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
|
|
||||||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
|
||||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
|
||||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
|
||||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
|
||||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
|
||||||
estimate_radius_floor_factor: float = 0.8
|
|
||||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
|
||||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
|
||||||
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
|
||||||
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
|
||||||
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
|
||||||
# к point И range (expected_sold_low/high/price) консистентно.
|
|
||||||
# False -> старое поведение без clamp (backward-compat).
|
|
||||||
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
|
||||||
estimate_expected_sold_le_asking: bool = Field(
|
|
||||||
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
|
||||||
)
|
|
||||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
|
||||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
|
||||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
|
||||||
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
|
||||||
estimate_sb_mad_k_small_n: float = 2.5
|
|
||||||
estimate_sb_small_n_threshold: int = 10
|
|
||||||
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
|
||||||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
|
||||||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
|
||||||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
|
||||||
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
|
|
||||||
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
|
|
||||||
estimate_outlier_small_n_threshold: int = 15
|
|
||||||
estimate_outlier_tukey_k_small: float = 1.0
|
|
||||||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
|
||||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
|
||||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
|
||||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
|
||||||
# ratio — не затрагиваются.
|
|
||||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
|
||||||
|
|
||||||
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
|
||||||
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
|
||||||
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
|
||||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
|
||||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
|
||||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
|
||||||
estimate_sb_gate_min_n: int = 3
|
|
||||||
estimate_sb_gate_max_fsd: float = 0.20
|
|
||||||
|
|
||||||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||||
estimate_price_trend_max_age_months: int = 6
|
estimate_price_trend_max_age_months: int = 6
|
||||||
|
|
||||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
|
||||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
|
||||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
|
||||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
|
||||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
|
||||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
|
||||||
|
|
||||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
|
||||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
|
||||||
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
|
|
||||||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
|
||||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
|
||||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
|
||||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
|
||||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
|
||||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
|
||||||
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
|
||||||
estimate_manual_review_elite_ppm2: int = 250000
|
|
||||||
|
|
||||||
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
|
||||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
|
||||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
|
||||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
|
||||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
|
||||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
|
||||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
|
||||||
|
|
||||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
|
||||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
|
||||||
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
|
|
||||||
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
|
|
||||||
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
|
|
||||||
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
|
|
||||||
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
|
|
||||||
# building (building_cadastral_number | нормализованный address)
|
|
||||||
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
|
|
||||||
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
|
|
||||||
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
|
|
||||||
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
|
|
||||||
# («лот считается один раз»).
|
|
||||||
#
|
|
||||||
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
|
|
||||||
# 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
|
|
||||||
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
|
|
||||||
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
|
|
||||||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
|
||||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
|
||||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
|
||||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
|
||||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
|
||||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
|
||||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
|
||||||
estimate_dedup_analogs_enabled: bool = True
|
|
||||||
|
|
||||||
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||||||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||||||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||||||
|
|
@ -711,6 +572,16 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||||
estimate_unknown_attr_penalty_enabled: bool = False
|
estimate_unknown_attr_penalty_enabled: bool = False
|
||||||
estimate_unknown_attr_penalty_min_n: int = 5
|
estimate_unknown_attr_penalty_min_n: int = 5
|
||||||
|
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
|
||||||
|
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
|
||||||
|
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
|
||||||
|
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
|
||||||
|
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
|
||||||
|
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
|
||||||
|
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
|
||||||
|
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
|
||||||
|
estimate_tier_h_min_comps: int = 5
|
||||||
|
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
|
||||||
#
|
#
|
||||||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||||
|
|
@ -725,20 +596,6 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||||||
estimate_is_apartments_filter_enabled: bool = False
|
estimate_is_apartments_filter_enabled: bool = False
|
||||||
|
|
||||||
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
|
|
||||||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
|
||||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
|
||||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
|
||||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
|
||||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
|
||||||
# disclosure в explanation. НЕ трогает point/median/range.
|
|
||||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
|
||||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
|
||||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
|
||||||
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
|
|
||||||
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
|
|
||||||
estimate_wide_corridor_threshold: float = 1.2
|
|
||||||
|
|
||||||
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||||||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||||||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||||||
|
|
@ -763,26 +620,7 @@ class Settings(BaseSettings):
|
||||||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||||
avito_imv_use_browser_fetcher: bool = False
|
avito_imv_use_browser_fetcher: bool = False
|
||||||
|
|
||||||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
||||||
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
|
|
||||||
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
|
|
||||||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
|
||||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
|
||||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
|
||||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
|
||||||
estimate_quarter_index_min_n_deals: int = 10
|
|
||||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
|
||||||
# аналоги уже несут локацию квартала → skip (location in median).
|
|
||||||
estimate_quarter_match_skip_ratio: float = 0.6
|
|
||||||
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
|
|
||||||
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
|
|
||||||
estimate_quarter_index_max_for_small_n: float = 2.0
|
|
||||||
estimate_quarter_index_small_n_threshold: int = 50
|
|
||||||
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
|
|
||||||
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
|
|
||||||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
|
||||||
estimate_quarter_index_factor_min: float = 0.6
|
|
||||||
estimate_quarter_index_factor_max: float = 1.8
|
|
||||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||||
|
|
@ -971,7 +809,7 @@ class Settings(BaseSettings):
|
||||||
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
||||||
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
|
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
|
||||||
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
||||||
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
|
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
|
||||||
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
||||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
||||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
||||||
|
|
@ -1194,14 +1032,6 @@ class Settings(BaseSettings):
|
||||||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||||||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
||||||
|
|
||||||
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
|
|
||||||
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
|
|
||||||
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
|
|
||||||
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
|
|
||||||
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
|
|
||||||
# ENV: YANDEX_COOKIES_FILE.
|
|
||||||
yandex_cookies_file: str | None = None
|
|
||||||
|
|
||||||
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||||||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||||||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||||||
|
|
@ -1221,25 +1051,19 @@ class Settings(BaseSettings):
|
||||||
cian_login_success_cookie: str = "DMIR_AUTH"
|
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||||
cian_login_wait_ms: int = 4000
|
cian_login_wait_ms: int = 4000
|
||||||
|
|
||||||
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||||
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
|
||||||
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
# True = curl_cffi через settings.scraper_proxy_url.
|
||||||
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
|
|
||||||
# 1 коннект/запрос) развязывает прокси-аккаунты.
|
|
||||||
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
|
|
||||||
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
|
|
||||||
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
|
||||||
#
|
#
|
||||||
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
|
||||||
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
|
||||||
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
|
||||||
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
|
||||||
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
|
||||||
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
# environment'ом сервиса scraper, а tradein-backend (ручка админки
|
||||||
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
|
||||||
# других окружений вслепую.
|
|
||||||
avito_detail_backfill_use_curl: bool = Field(
|
avito_detail_backfill_use_curl: bool = Field(
|
||||||
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
|
||||||
|
|
||||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
||||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
||||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
|
||||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
ценовой шкале. None если для дома нет свежей IMV-записи.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -163,9 +163,13 @@ class DkpCorridor(BaseModel):
|
||||||
[low,high]×slack — добавляется текстовая пометка.
|
[low,high]×slack — добавляется текстовая пометка.
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
None / count=0 если по улице нет сопоставимых сделок.
|
||||||
|
|
||||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
|
||||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
|
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
|
||||||
|
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
|
||||||
|
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
|
||||||
|
из коридора, а поле обязано быть верным и на GET-rehydrate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
count: int # число ДКП-сделок в выборке
|
||||||
|
|
@ -192,10 +196,13 @@ class DkpCorridor(BaseModel):
|
||||||
def advisory_only(self) -> bool:
|
def advisory_only(self) -> bool:
|
||||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||||
|
|
||||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
|
||||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
цену — зона n=1..9 на экране была неотличима от работающей.
|
||||||
|
|
||||||
|
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
|
||||||
|
обе страховки выключены якорем, а не порогом (#3466).
|
||||||
|
|
||||||
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||||
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||||
|
|
@ -207,9 +214,9 @@ class DkpCorridor(BaseModel):
|
||||||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
||||||
вторым числом.
|
вторым числом.
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings # локально: schemas остаётся import-light
|
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
|
||||||
|
|
||||||
return self.count < settings.estimate_corridor_clamp_min_n
|
return self.count < CORRIDOR_CLAMP_MIN_N
|
||||||
|
|
||||||
|
|
||||||
class PriceTrendPoint(BaseModel):
|
class PriceTrendPoint(BaseModel):
|
||||||
|
|
@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel):
|
||||||
# "city" — radius Tier W: широкий fallback
|
# "city" — radius Tier W: широкий fallback
|
||||||
# null — нет данных / оценка не построена
|
# null — нет данных / оценка не построена
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||||
|
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
|
||||||
|
# восстанавливаются из подписи якорного блока в confidence_explanation
|
||||||
|
# (estimator.analog_tier_from_explanation), радиусные остаются null.
|
||||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ class BlockRatioBreaker:
|
||||||
ratio_threshold: float
|
ratio_threshold: float
|
||||||
safety_min: int
|
safety_min: int
|
||||||
snapshot_size: int
|
snapshot_size: int
|
||||||
|
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
|
||||||
|
# площадки, рвут прогон. 0 — критерий выключен.
|
||||||
|
infra_streak_limit: int = 0
|
||||||
_window: deque[bool] = field(init=False, repr=False)
|
_window: deque[bool] = field(init=False, repr=False)
|
||||||
_consecutive_blocks: int = field(default=0, init=False)
|
_consecutive_blocks: int = field(default=0, init=False)
|
||||||
|
_consecutive_infra: int = field(default=0, init=False)
|
||||||
_pure_block_run: bool = field(default=True, init=False)
|
_pure_block_run: bool = field(default=True, init=False)
|
||||||
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
|
||||||
|
|
||||||
|
|
@ -106,10 +110,16 @@ class BlockRatioBreaker:
|
||||||
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
короче окна рвал бы прогон по тем же infra-отказам, только другим
|
||||||
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
|
||||||
не считают.
|
не считают.
|
||||||
|
|
||||||
|
Свой предохранитель у такого отказа — серия infra_streak_limit (#3439): без
|
||||||
|
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
|
||||||
|
1600, status='done'). Серию гасит любой ответ площадки — успех, её блок, 404.
|
||||||
"""
|
"""
|
||||||
if kind != BAN_KIND_PLATFORM:
|
if kind != BAN_KIND_PLATFORM:
|
||||||
|
self._consecutive_infra += 1
|
||||||
self.record_failure()
|
self.record_failure()
|
||||||
return
|
return
|
||||||
|
self._consecutive_infra = 0
|
||||||
self._consecutive_blocks += 1
|
self._consecutive_blocks += 1
|
||||||
self._window.append(True)
|
self._window.append(True)
|
||||||
|
|
||||||
|
|
@ -117,6 +127,7 @@ class BlockRatioBreaker:
|
||||||
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
|
||||||
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
|
||||||
self._flush_streak()
|
self._flush_streak()
|
||||||
|
self._consecutive_infra = 0
|
||||||
self._pure_block_run = False
|
self._pure_block_run = False
|
||||||
self._window.append(False)
|
self._window.append(False)
|
||||||
|
|
||||||
|
|
@ -131,8 +142,11 @@ class BlockRatioBreaker:
|
||||||
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
|
||||||
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
|
||||||
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
|
||||||
задачи -- своя калибровка, свои ограничения прокси-пула)."""
|
задачи -- своя калибровка, свои ограничения прокси-пула).
|
||||||
return
|
|
||||||
|
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
|
||||||
|
значит наш тракт жив."""
|
||||||
|
self._consecutive_infra = 0
|
||||||
|
|
||||||
def _flush_streak(self) -> None:
|
def _flush_streak(self) -> None:
|
||||||
if self._consecutive_blocks:
|
if self._consecutive_blocks:
|
||||||
|
|
@ -142,9 +156,11 @@ class BlockRatioBreaker:
|
||||||
def abort_reason(self) -> str | None:
|
def abort_reason(self) -> str | None:
|
||||||
"""Какой критерий требует обрыва прямо сейчас, или None.
|
"""Какой критерий требует обрыва прямо сейчас, или None.
|
||||||
|
|
||||||
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому
|
Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
|
||||||
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
|
||||||
"""
|
"""
|
||||||
|
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
|
||||||
|
return "infra_streak"
|
||||||
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
|
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
|
||||||
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
# короче окна), иначе пачка safety_min блоков в начале длинного прогона
|
||||||
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
|
||||||
|
|
@ -176,6 +192,11 @@ class BlockRatioBreaker:
|
||||||
should_abort(), так что в логи не попадает.
|
should_abort(), так что в логи не попадает.
|
||||||
"""
|
"""
|
||||||
reason = self.abort_reason()
|
reason = self.abort_reason()
|
||||||
|
if reason == "infra_streak":
|
||||||
|
return (
|
||||||
|
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
|
||||||
|
"без единого ответа площадки"
|
||||||
|
)
|
||||||
if reason == "ratio":
|
if reason == "ratio":
|
||||||
return (
|
return (
|
||||||
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
f"доля блоков {self.window_blocks}/{self.window_len} в окне "
|
||||||
|
|
|
||||||
|
|
@ -55,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 LISTINGS_FRESH_DAYS, Settings, settings
|
from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings
|
||||||
from app.core.db import SessionLocal, run_db_thread
|
from app.core.db import SessionLocal, run_db_thread
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
|
|
@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier).
|
||||||
|
# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок
|
||||||
|
# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние.
|
||||||
|
# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём,
|
||||||
|
# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не
|
||||||
|
# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка
|
||||||
|
# ?id= показывала бы такой коридор как работающий.
|
||||||
|
# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся
|
||||||
|
# радиусные тиры на GET или подпись начнут переписывать мимо этой константы.
|
||||||
|
ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"}
|
||||||
|
ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"}
|
||||||
|
|
||||||
|
|
||||||
|
def analog_tier_from_explanation(explanation: str | None) -> str | None:
|
||||||
|
"""Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None.
|
||||||
|
|
||||||
|
Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того
|
||||||
|
же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой
|
||||||
|
путь цены — коридор там клампит. None = радиусный путь либо старая формулировка.
|
||||||
|
"""
|
||||||
|
for tier, label in ANCHOR_TIER_LABELS.items():
|
||||||
|
if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""):
|
||||||
|
return ANCHOR_API_TIERS[tier]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def rehydrate_search_radius_m(
|
def rehydrate_search_radius_m(
|
||||||
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|
@ -316,7 +342,7 @@ def rehydrate_search_radius_m(
|
||||||
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
||||||
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
||||||
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
||||||
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor
|
# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor
|
||||||
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
||||||
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
||||||
|
|
||||||
|
|
@ -416,6 +442,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
|
||||||
("премиум", float("inf")),
|
("премиум", float("inf")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
|
||||||
|
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
|
||||||
|
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
|
||||||
|
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
|
||||||
|
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
|
||||||
|
# число = правка здесь + регрессионный гейт бэктеста
|
||||||
|
# (tests/test_backtest_regression_gate.py).
|
||||||
|
|
||||||
|
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
|
||||||
|
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
|
||||||
|
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
|
||||||
|
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
|
||||||
|
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
|
||||||
|
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
|
||||||
|
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
|
||||||
|
WIDE_CORRIDOR_THRESHOLD = 1.2
|
||||||
|
|
||||||
|
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
|
||||||
|
# метаданные — median/expected_sold/ranges не трогают.
|
||||||
|
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
|
||||||
|
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
|
||||||
|
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
|
||||||
|
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||||
|
MANUAL_REVIEW_ELITE_PPM2 = 250_000
|
||||||
|
|
||||||
|
# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на
|
||||||
|
# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует
|
||||||
|
# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов:
|
||||||
|
# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||||
|
# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет).
|
||||||
|
QUARTER_INDEX_MIN_N_DEALS = 10
|
||||||
|
# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога —
|
||||||
|
# локация квартала уже в медиане → skip.
|
||||||
|
QUARTER_MATCH_SKIP_RATIO = 0.6
|
||||||
|
# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают
|
||||||
|
# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия.
|
||||||
|
QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0
|
||||||
|
QUARTER_INDEX_SMALL_N_THRESHOLD = 50
|
||||||
|
# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные
|
||||||
|
# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал.
|
||||||
|
QUARTER_INDEX_FACTOR_MIN = 0.6
|
||||||
|
QUARTER_INDEX_FACTOR_MAX = 1.8
|
||||||
|
|
||||||
|
# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline
|
||||||
|
# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок
|
||||||
|
# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в
|
||||||
|
# app.core.config (его же читает DkpCorridor.advisory_only, #3452).
|
||||||
|
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack)
|
||||||
|
# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) →
|
||||||
|
# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без
|
||||||
|
# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию.
|
||||||
|
CORRIDOR_CLAMP_SLACK = 0.40
|
||||||
|
# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n):
|
||||||
|
# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10
|
||||||
|
# коридора: достаточно мягко для эконома, ловит явный undershoot.
|
||||||
|
RADIUS_FLOOR_FACTOR = 0.8
|
||||||
|
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо
|
||||||
|
# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх.
|
||||||
|
OUTLIER_SMALL_N_THRESHOLD = 15
|
||||||
|
OUTLIER_TUKEY_K_SMALL = 1.0
|
||||||
|
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
|
||||||
|
# для trade-in физически невозможен, это артефакт high-price бакета.
|
||||||
|
|
||||||
|
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
|
||||||
|
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
|
||||||
|
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
|
||||||
|
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
|
||||||
|
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
|
||||||
|
IMV_BLEND_WEIGHT = 0.5
|
||||||
|
IMV_BLEND_THRESHOLD = 1.15
|
||||||
|
|
||||||
|
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
|
||||||
|
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2
|
||||||
|
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
|
||||||
|
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
|
||||||
|
# по ppm²), hard guardrail и tighter FSD-диапазон.
|
||||||
|
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||||
|
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
|
||||||
|
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||||
|
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
|
||||||
|
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
|
||||||
|
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
|
||||||
|
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
|
||||||
|
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
|
||||||
|
SB_FLOOR_SIGMA = 0.25
|
||||||
|
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||||
|
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||||
|
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
|
||||||
|
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
|
||||||
|
SB_MAD_K_SMALL_N = 2.5
|
||||||
|
SB_SMALL_N_THRESHOLD = 10
|
||||||
|
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
|
||||||
|
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
|
||||||
|
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
|
||||||
|
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
|
||||||
|
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
|
||||||
|
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
|
||||||
|
# FSD<0.15) проходят без изменений.
|
||||||
|
SB_GATE_MIN_N = 3
|
||||||
|
SB_GATE_MAX_FSD = 0.20
|
||||||
|
|
||||||
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||||
|
|
@ -2013,7 +2140,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
|
||||||
Returns None если ни один город не распознан (адрес вне известного
|
Returns None если ни один город не распознан (адрес вне известного
|
||||||
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
||||||
city-фильтра. NB: коридор НЕ чисто advisory — при
|
city-фильтра. NB: коридор НЕ чисто advisory — при
|
||||||
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor +
|
n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor +
|
||||||
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
||||||
"""
|
"""
|
||||||
if not address_text:
|
if not address_text:
|
||||||
|
|
@ -2223,7 +2350,7 @@ def _fetch_dkp_corridor(
|
||||||
latest_deal = dd
|
latest_deal = dd
|
||||||
ppm2_values = sorted(adjusted)
|
ppm2_values = sorted(adjusted)
|
||||||
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||||
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
|
||||||
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||||
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||||
scope = "street"
|
scope = "street"
|
||||||
|
|
@ -2592,7 +2719,7 @@ def _fetch_anchor_comps(
|
||||||
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
||||||
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
||||||
"""
|
"""
|
||||||
min_comps = settings.estimate_sb_min_comps
|
min_comps = SB_MIN_COMPS
|
||||||
|
|
||||||
# ── Tier A: same building ────────────────────────────────────────────────
|
# ── Tier A: same building ────────────────────────────────────────────────
|
||||||
street, base_no, letter = _normalize_building_key(address)
|
street, base_no, letter = _normalize_building_key(address)
|
||||||
|
|
@ -2975,12 +3102,8 @@ def _compute_same_building_anchor(
|
||||||
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
||||||
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
||||||
effective_mad_k = mad_k
|
effective_mad_k = mad_k
|
||||||
if (
|
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
|
||||||
tier != "A"
|
effective_mad_k = SB_MAD_K_SMALL_N
|
||||||
and settings.estimate_sb_mad_k_small_n < mad_k
|
|
||||||
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
|
|
||||||
):
|
|
||||||
effective_mad_k = settings.estimate_sb_mad_k_small_n
|
|
||||||
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
||||||
if len(surviving_idx) < min_comps:
|
if len(surviving_idx) < min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -3394,7 +3517,7 @@ def _price_from_inputs(
|
||||||
"""
|
"""
|
||||||
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
|
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
|
||||||
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
||||||
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
# по кросс-постам.
|
||||||
listings = _dedup_cross_source(listings)
|
listings = _dedup_cross_source(listings)
|
||||||
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
||||||
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
||||||
|
|
@ -3591,32 +3714,28 @@ def _price_from_inputs(
|
||||||
area_target=area_m2,
|
area_target=area_m2,
|
||||||
rooms_target=rooms,
|
rooms_target=rooms,
|
||||||
tier=anchor_tier or "",
|
tier=anchor_tier or "",
|
||||||
sigma=settings.estimate_sb_area_sigma,
|
sigma=SB_AREA_SIGMA,
|
||||||
rooms_boost=settings.estimate_sb_rooms_match_boost,
|
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||||
floor_target=floor,
|
floor_target=floor,
|
||||||
total_floors_target=total_floors,
|
total_floors_target=total_floors,
|
||||||
floor_sigma=settings.estimate_sb_floor_sigma,
|
floor_sigma=SB_FLOOR_SIGMA,
|
||||||
min_comps=settings.estimate_sb_min_comps,
|
min_comps=SB_MIN_COMPS,
|
||||||
mad_k=settings.estimate_sb_mad_k,
|
mad_k=SB_MAD_K,
|
||||||
)
|
)
|
||||||
|
|
||||||
# #1795 шаг 3: гейт Tier C.
|
# #1795 шаг 3: гейт Tier C.
|
||||||
if (
|
if anchor is not None and anchor_tier == "C":
|
||||||
anchor is not None
|
|
||||||
and anchor_tier == "C"
|
|
||||||
and settings.estimate_anchor_tier_c_corridor_mult > 0
|
|
||||||
):
|
|
||||||
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
||||||
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
||||||
else:
|
else:
|
||||||
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
||||||
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
|
gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
|
||||||
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
||||||
logger.info(
|
logger.info(
|
||||||
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
||||||
" → keep radius median (anchor suppressed)",
|
" → keep radius median (anchor suppressed)",
|
||||||
int(anchor["anchor_ppm2"]),
|
int(anchor["anchor_ppm2"]),
|
||||||
settings.estimate_anchor_tier_c_corridor_mult,
|
ANCHOR_TIER_C_CORRIDOR_MULT,
|
||||||
int(gate_threshold),
|
int(gate_threshold),
|
||||||
)
|
)
|
||||||
anchor = None
|
anchor = None
|
||||||
|
|
@ -3624,10 +3743,7 @@ def _price_from_inputs(
|
||||||
# #audit-1: low-confidence gate.
|
# #audit-1: low-confidence gate.
|
||||||
if anchor is not None:
|
if anchor is not None:
|
||||||
gate_low = anchor["confidence"] == "low"
|
gate_low = anchor["confidence"] == "low"
|
||||||
gate_thin = (
|
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
|
||||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
|
||||||
)
|
|
||||||
if gate_low or gate_thin:
|
if gate_low or gate_thin:
|
||||||
logger.info(
|
logger.info(
|
||||||
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
||||||
|
|
@ -3667,13 +3783,13 @@ def _price_from_inputs(
|
||||||
anchor_comps_used = anchor.get("comps") or anchor_comps
|
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||||
est_ppm2 = anchor["anchor_ppm2"]
|
est_ppm2 = anchor["anchor_ppm2"]
|
||||||
# PREMIUM GUARDRAIL (hard).
|
# PREMIUM GUARDRAIL (hard).
|
||||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
|
||||||
if est_ppm2 < floor_ppm2:
|
if est_ppm2 < floor_ppm2:
|
||||||
est_ppm2 = floor_ppm2
|
est_ppm2 = floor_ppm2
|
||||||
new_ppm2 = est_ppm2 * repair_coef
|
new_ppm2 = est_ppm2 * repair_coef
|
||||||
point = int(new_ppm2 * area_m2)
|
point = int(new_ppm2 * area_m2)
|
||||||
# FSD-диапазон.
|
# FSD-диапазон.
|
||||||
half = settings.estimate_fsd_k * anchor["fsd"]
|
half = FSD_K * anchor["fsd"]
|
||||||
new_range_low = int(point * max(0.0, 1.0 - half))
|
new_range_low = int(point * max(0.0, 1.0 - half))
|
||||||
new_range_high = int(point * (1.0 + half))
|
new_range_high = int(point * (1.0 + half))
|
||||||
# Спред комплов.
|
# Спред комплов.
|
||||||
|
|
@ -3699,10 +3815,10 @@ def _price_from_inputs(
|
||||||
range_low = new_range_low
|
range_low = new_range_low
|
||||||
range_high = new_range_high
|
range_high = new_range_high
|
||||||
confidence = anchor["confidence"]
|
confidence = anchor["confidence"]
|
||||||
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
|
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
|
||||||
# #695: explanation описывает ИМЕННО якорные комплы.
|
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
|
||||||
explanation = (
|
explanation = (
|
||||||
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
|
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
|
||||||
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
||||||
) + repair_note
|
) + repair_note
|
||||||
# #695 (QA fixup): n_analogs по anchor-популяции.
|
# #695 (QA fixup): n_analogs по anchor-популяции.
|
||||||
|
|
@ -3726,7 +3842,7 @@ def _price_from_inputs(
|
||||||
# #1871 P2: split-дома wide-corridor disclosure.
|
# #1871 P2: split-дома wide-corridor disclosure.
|
||||||
if anchor_tier == "A" and median_price > 0:
|
if anchor_tier == "A" and median_price > 0:
|
||||||
corridor_pct = (range_high - range_low) / median_price
|
corridor_pct = (range_high - range_low) / median_price
|
||||||
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
|
||||||
confidence = _downgrade_confidence(confidence)
|
confidence = _downgrade_confidence(confidence)
|
||||||
explanation = (explanation or "") + (
|
explanation = (explanation or "") + (
|
||||||
" Очень широкий ценовой диапазон по дому (вероятно, дом "
|
" Очень широкий ценовой диапазон по дому (вероятно, дом "
|
||||||
|
|
@ -3830,8 +3946,8 @@ def _price_from_inputs(
|
||||||
area=area_m2,
|
area=area_m2,
|
||||||
anchor_total=anchor_total,
|
anchor_total=anchor_total,
|
||||||
anchor_higher=anchor_higher,
|
anchor_higher=anchor_higher,
|
||||||
weight=settings.estimate_imv_blend_weight,
|
weight=IMV_BLEND_WEIGHT,
|
||||||
threshold=settings.estimate_imv_blend_threshold,
|
threshold=IMV_BLEND_THRESHOLD,
|
||||||
market_count=(
|
market_count=(
|
||||||
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||||
),
|
),
|
||||||
|
|
@ -3843,7 +3959,7 @@ def _price_from_inputs(
|
||||||
median_price,
|
median_price,
|
||||||
new_median,
|
new_median,
|
||||||
anchor_used,
|
anchor_used,
|
||||||
settings.estimate_imv_blend_weight,
|
IMV_BLEND_WEIGHT,
|
||||||
range_high,
|
range_high,
|
||||||
new_range_high,
|
new_range_high,
|
||||||
)
|
)
|
||||||
|
|
@ -3911,15 +4027,15 @@ def _price_from_inputs(
|
||||||
|
|
||||||
# Bimodal/nominal guard (Guard-4).
|
# Bimodal/nominal guard (Guard-4).
|
||||||
if (
|
if (
|
||||||
target_qi > settings.estimate_quarter_index_max_for_small_n
|
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
|
||||||
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
|
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: bimodal guard triggered "
|
"quarter_index: bimodal guard triggered "
|
||||||
"(index=%.3f n=%d < %d) for %s — no-op",
|
"(index=%.3f n=%d < %d) for %s — no-op",
|
||||||
target_qi,
|
target_qi,
|
||||||
target_n_deals,
|
target_n_deals,
|
||||||
settings.estimate_quarter_index_small_n_threshold,
|
QUARTER_INDEX_SMALL_N_THRESHOLD,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3944,11 +4060,11 @@ def _price_from_inputs(
|
||||||
if lot_quarters_for_guard2
|
if lot_quarters_for_guard2
|
||||||
else 0.0
|
else 0.0
|
||||||
)
|
)
|
||||||
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
|
||||||
logger.info(
|
logger.info(
|
||||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||||
same_quarter_ratio,
|
same_quarter_ratio,
|
||||||
settings.estimate_quarter_match_skip_ratio,
|
QUARTER_MATCH_SKIP_RATIO,
|
||||||
target_quarter,
|
target_quarter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -4001,8 +4117,8 @@ def _price_from_inputs(
|
||||||
base_range_high=range_high,
|
base_range_high=range_high,
|
||||||
target_index=target_qi,
|
target_index=target_qi,
|
||||||
avg_analog_index=avg_analog_index,
|
avg_analog_index=avg_analog_index,
|
||||||
min_factor=settings.estimate_quarter_index_factor_min,
|
min_factor=QUARTER_INDEX_FACTOR_MIN,
|
||||||
max_factor=settings.estimate_quarter_index_factor_max,
|
max_factor=QUARTER_INDEX_FACTOR_MAX,
|
||||||
)
|
)
|
||||||
analogs_with_qi = sum(
|
analogs_with_qi = sum(
|
||||||
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
||||||
|
|
@ -4024,7 +4140,7 @@ def _price_from_inputs(
|
||||||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
||||||
|
|
||||||
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
||||||
slack = settings.estimate_corridor_clamp_slack
|
slack = CORRIDOR_CLAMP_SLACK
|
||||||
if dkp_raw is not None and median_ppm2 > 0:
|
if dkp_raw is not None and median_ppm2 > 0:
|
||||||
old_ppm2 = median_ppm2
|
old_ppm2 = median_ppm2
|
||||||
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
||||||
|
|
@ -4036,7 +4152,7 @@ def _price_from_inputs(
|
||||||
corridor_count=dkp_raw["count"],
|
corridor_count=dkp_raw["count"],
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=settings.estimate_corridor_clamp_min_n,
|
min_n=CORRIDOR_CLAMP_MIN_N,
|
||||||
)
|
)
|
||||||
if clamped:
|
if clamped:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4059,9 +4175,9 @@ def _price_from_inputs(
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("low_ppm2", 0) > 0
|
and dkp_raw.get("low_ppm2", 0) > 0
|
||||||
and median_ppm2 > 0
|
and median_ppm2 > 0
|
||||||
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
|
and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N
|
||||||
):
|
):
|
||||||
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
|
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR
|
||||||
if median_ppm2 < radius_floor_ppm2:
|
if median_ppm2 < radius_floor_ppm2:
|
||||||
floor_factor = radius_floor_ppm2 / median_ppm2
|
floor_factor = radius_floor_ppm2 / median_ppm2
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -4069,7 +4185,7 @@ def _price_from_inputs(
|
||||||
" → lifting (factor=%.3f)",
|
" → lifting (factor=%.3f)",
|
||||||
int(median_ppm2),
|
int(median_ppm2),
|
||||||
dkp_raw["low_ppm2"],
|
dkp_raw["low_ppm2"],
|
||||||
settings.estimate_radius_floor_factor,
|
RADIUS_FLOOR_FACTOR,
|
||||||
int(radius_floor_ppm2),
|
int(radius_floor_ppm2),
|
||||||
floor_factor,
|
floor_factor,
|
||||||
)
|
)
|
||||||
|
|
@ -4089,7 +4205,7 @@ def _price_from_inputs(
|
||||||
|
|
||||||
if asking_to_sold_ratio is not None and median_price > 0:
|
if asking_to_sold_ratio is not None and median_price > 0:
|
||||||
effective_ratio = asking_to_sold_ratio
|
effective_ratio = asking_to_sold_ratio
|
||||||
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
|
if effective_ratio > 1.0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
||||||
effective_ratio,
|
effective_ratio,
|
||||||
|
|
@ -4132,7 +4248,7 @@ def _price_from_inputs(
|
||||||
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
|
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
|
||||||
# the headline so the calibrated PI range below wraps the clamped point.
|
# the headline so the calibrated PI range below wraps the clamped point.
|
||||||
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
|
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
|
||||||
if settings.estimate_expected_sold_le_asking and expected_sold_price:
|
if expected_sold_price:
|
||||||
expected_sold_price = min(expected_sold_price, median_price)
|
expected_sold_price = min(expected_sold_price, median_price)
|
||||||
if expected_sold_per_m2:
|
if expected_sold_per_m2:
|
||||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
||||||
|
|
@ -4214,7 +4330,7 @@ def _price_from_inputs(
|
||||||
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
||||||
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
||||||
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
||||||
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
|
# own MAD-clip drops comps below SB_MIN_COMPS), it returns None
|
||||||
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
||||||
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
||||||
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
||||||
|
|
@ -5166,14 +5282,14 @@ async def estimate_quality(
|
||||||
return _lookup_quarter_index(
|
return _lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
quarter_cad_number=q,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
||||||
return _lookup_quarter_indexes(
|
return _lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
quarter_cad_numbers=qs,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Deterministic pricing orchestration ──────────────────────────────────
|
# ── Deterministic pricing orchestration ──────────────────────────────────
|
||||||
|
|
@ -5311,10 +5427,11 @@ async def estimate_quality(
|
||||||
estimate_id = uuid4()
|
estimate_id = uuid4()
|
||||||
|
|
||||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
|
||||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
# Зона n=1..9 на экране неотличима от работающего коридора, поэтому
|
||||||
# попадание в неё пишется явной строкой: число оценок за сутки —
|
# попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный
|
||||||
|
# коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки —
|
||||||
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
||||||
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
||||||
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
||||||
|
|
@ -5322,7 +5439,7 @@ async def estimate_quality(
|
||||||
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||||
estimate_id,
|
estimate_id,
|
||||||
dkp_corridor.count,
|
dkp_corridor.count,
|
||||||
settings.estimate_corridor_clamp_min_n,
|
CORRIDOR_CLAMP_MIN_N,
|
||||||
(dkp_raw or {}).get("scope", "unknown"),
|
(dkp_raw or {}).get("scope", "unknown"),
|
||||||
target_city or "unknown",
|
target_city or "unknown",
|
||||||
)
|
)
|
||||||
|
|
@ -5573,10 +5690,8 @@ async def estimate_quality(
|
||||||
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
||||||
# None только если нет аналогов (median=0, insufficient_data=True).
|
# None только если нет аналогов (median=0, insufficient_data=True).
|
||||||
if median_price > 0:
|
if median_price > 0:
|
||||||
if anchor_tier == "A":
|
if anchor_tier in ANCHOR_API_TIERS:
|
||||||
api_analog_tier: str | None = "same_building"
|
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
|
||||||
elif anchor_tier == "C":
|
|
||||||
api_analog_tier = "micro_radius"
|
|
||||||
elif analog_tier == "W":
|
elif analog_tier == "W":
|
||||||
api_analog_tier = "city"
|
api_analog_tier = "city"
|
||||||
else:
|
else:
|
||||||
|
|
@ -5641,7 +5756,6 @@ async def estimate_quality(
|
||||||
confidence,
|
confidence,
|
||||||
range_low,
|
range_low,
|
||||||
range_high,
|
range_high,
|
||||||
settings,
|
|
||||||
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
||||||
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
||||||
asking_ppm2=int(median_ppm2),
|
asking_ppm2=int(median_ppm2),
|
||||||
|
|
@ -5781,8 +5895,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
|
||||||
None) — premium_building флаг не выставится, даже если дом объективно элитный по
|
None) — premium_building флаг не выставится, даже если дом объективно элитный по
|
||||||
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
|
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
|
||||||
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
|
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
|
||||||
`estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и
|
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price ≥ порога) и
|
||||||
`estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от
|
`MANUAL_REVIEW_ELITE_PPM2` (asking ₽/м² ≥ порога) — независимо от
|
||||||
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
|
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
|
||||||
почти всегда подхватывается value-based триггером той же функции.
|
почти всегда подхватывается value-based триггером той же функции.
|
||||||
"""
|
"""
|
||||||
|
|
@ -5819,7 +5933,6 @@ def _manual_review(
|
||||||
confidence: str,
|
confidence: str,
|
||||||
range_low: int | None,
|
range_low: int | None,
|
||||||
range_high: int | None,
|
range_high: int | None,
|
||||||
settings: Settings,
|
|
||||||
asking_ppm2: int | None = None,
|
asking_ppm2: int | None = None,
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
||||||
|
|
@ -5849,15 +5962,13 @@ def _manual_review(
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if premium_building:
|
if premium_building:
|
||||||
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
||||||
high_value = settings.estimate_manual_review_high_value_rub
|
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
|
||||||
if expected_sold_price and expected_sold_price >= high_value:
|
|
||||||
reasons.append("высокая стоимость (≥20 млн ₽)")
|
reasons.append("высокая стоимость (≥20 млн ₽)")
|
||||||
if confidence == "low":
|
if confidence == "low":
|
||||||
reasons.append("низкая уверенность оценки")
|
reasons.append("низкая уверенность оценки")
|
||||||
wide_ratio = settings.estimate_manual_review_wide_range_ratio
|
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
|
||||||
if range_low and range_high and range_high / range_low >= wide_ratio:
|
|
||||||
reasons.append("широкий диапазон цены")
|
reasons.append("широкий диапазон цены")
|
||||||
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
|
if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
||||||
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
||||||
|
|
@ -6551,8 +6662,9 @@ def _fetch_analogs(
|
||||||
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
|
||||||
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
|
||||||
|
|
||||||
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
|
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
|
||||||
Если ≥5 результатов → возвращаем; tier='H'.
|
±estimate_tier_h_floors_tol (0.30). Если ≥ estimate_tier_h_min_comps (5)
|
||||||
|
результатов → возвращаем; tier='H'.
|
||||||
Пропускается если year_built или total_floors неизвестны.
|
Пропускается если year_built или total_floors неизвестны.
|
||||||
|
|
||||||
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
|
||||||
|
|
@ -6747,12 +6859,12 @@ def _fetch_analogs(
|
||||||
)
|
)
|
||||||
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
|
||||||
|
|
||||||
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
|
# ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings) ─
|
||||||
if year_built is not None and total_floors is not None:
|
if year_built is not None and total_floors is not None:
|
||||||
year_min = year_built - 15
|
year_min = year_built - 15
|
||||||
year_max = year_built + 15
|
year_max = year_built + 15
|
||||||
tf_min = math.floor(total_floors * 0.7)
|
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
|
||||||
tf_max = math.ceil(total_floors * 1.3)
|
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
|
||||||
|
|
||||||
tier_h_rows = (
|
tier_h_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -6860,7 +6972,7 @@ def _fetch_analogs(
|
||||||
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
min_n=settings.estimate_unknown_attr_penalty_min_n,
|
||||||
)
|
)
|
||||||
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
|
||||||
if len(tier_h) >= 5:
|
if len(tier_h) >= settings.estimate_tier_h_min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
"analogs tier=H year=%d±15 tf=%d-%d → %d results",
|
||||||
year_built,
|
year_built,
|
||||||
|
|
@ -7328,15 +7440,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
|
||||||
)
|
)
|
||||||
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
|
||||||
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
|
||||||
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
|
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
|
||||||
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
|
||||||
|
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
|
||||||
|
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
|
||||||
|
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
|
||||||
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
|
||||||
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
|
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
|
||||||
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
|
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
|
||||||
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
|
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
|
||||||
_DEDUP_STREET_NAME_RE = re.compile(
|
_DEDUP_STREET_NAME_RE = re.compile(
|
||||||
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
|
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
|
||||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
|
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
|
||||||
|
|
@ -7348,10 +7463,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
|
||||||
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
|
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
|
||||||
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
|
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
|
||||||
r"(?:^|[,\s])"
|
r"(?:^|[,\s])"
|
||||||
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
|
||||||
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
|
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица
|
||||||
|
# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в
|
||||||
|
# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков»
|
||||||
|
# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков»
|
||||||
|
# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома.
|
||||||
|
_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile(
|
||||||
|
rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
|
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
|
||||||
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
|
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
|
||||||
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
|
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
|
||||||
|
|
@ -7537,6 +7661,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
||||||
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
|
||||||
# «Минометчиков» должны схлопываться в один канонический токен.
|
# «Минометчиков» должны схлопываться в один канонический токен.
|
||||||
s = s.replace("ё", "е")
|
s = s.replace("ё", "е")
|
||||||
|
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
|
||||||
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
|
||||||
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
|
||||||
m = _DEDUP_STREET_NAME_RE.search(s)
|
m = _DEDUP_STREET_NAME_RE.search(s)
|
||||||
|
|
@ -7655,8 +7780,7 @@ def _union_find_phys_dedup(
|
||||||
не дедупятся — остаются уникальными.
|
не дедупятся — остаются уникальными.
|
||||||
|
|
||||||
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
||||||
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
|
вызывающий — _dedup_cross_source). include_price=False — display-only ключ без
|
||||||
_dedup_cross_source). include_price=False — display-only ключ без
|
|
||||||
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
||||||
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
||||||
пары для merge (require_diff_source) — без цены единственный оставшийся
|
пары для merge (require_diff_source) — без цены единственный оставшийся
|
||||||
|
|
@ -7777,13 +7901,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
||||||
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
||||||
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
||||||
из выборки — по нему нет независимой ценовой точки). Флаг
|
из выборки — по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
|
||||||
estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно
|
снят (#2378) — бэктест #1966 OFF vs ON был
|
||||||
для frozen backtest regression gate). Ключ включает price_bucket
|
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%), дедуп меняет
|
||||||
|
только честность счёта n_analogs. Ключ включает price_bucket
|
||||||
(_lot_dedup_components include_price=True по умолчанию) — см.
|
(_lot_dedup_components include_price=True по умолчанию) — см.
|
||||||
_union_find_phys_dedup.
|
_union_find_phys_dedup.
|
||||||
"""
|
"""
|
||||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
if len(lots) < 2:
|
||||||
return lots
|
return lots
|
||||||
return _union_find_phys_dedup(lots, include_price=True)
|
return _union_find_phys_dedup(lots, include_price=True)
|
||||||
|
|
||||||
|
|
@ -7829,9 +7954,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
||||||
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
||||||
|
|
||||||
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
|
Независим от _dedup_cross_source: тот управляет статистикой
|
||||||
статистикой (median/n_analogs), этот — только честностью UI-карточек и
|
(median/n_analogs), этот — только честностью UI-карточек. НЕ трогает
|
||||||
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
|
|
||||||
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
||||||
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
||||||
"""
|
"""
|
||||||
|
|
@ -7843,10 +7967,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
||||||
|
|
||||||
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
|
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
|
||||||
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных
|
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных
|
||||||
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
||||||
тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение).
|
тянут радиусную медиану вверх.
|
||||||
"""
|
"""
|
||||||
if len(lots) < 5:
|
if len(lots) < 5:
|
||||||
return lots # на маленькой выборке нечего фильтровать
|
return lots # на маленькой выборке нечего фильтровать
|
||||||
|
|
@ -7856,11 +7980,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
tukey_k = 1.5
|
tukey_k = 1.5
|
||||||
if (
|
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
|
||||||
settings.estimate_outlier_tukey_k_small < 1.5
|
tukey_k = OUTLIER_TUKEY_K_SMALL
|
||||||
and len(prices) < settings.estimate_outlier_small_n_threshold
|
|
||||||
):
|
|
||||||
tukey_k = settings.estimate_outlier_tukey_k_small
|
|
||||||
|
|
||||||
q1 = _percentile(prices, 0.25)
|
q1 = _percentile(prices, 0.25)
|
||||||
q3 = _percentile(prices, 0.75)
|
q3 = _percentile(prices, 0.75)
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,22 @@ 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: пустая витрина — не успех. Результатного ключа кита
|
||||||
|
# (`_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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Idempotent-логика (на каждый yandex-lot):
|
||||||
- история ПУСТА:
|
- история ПУСТА:
|
||||||
* price_previous задан И != price_rub → seed previous-точку (change_time = now-1d,
|
* price_previous задан И != price_rub → seed previous-точку (change_time = now-1d,
|
||||||
timestamp синтетический — gate реального времени смены не несёт), затем current-точку;
|
timestamp синтетический — gate реального времени смены не несёт), затем current-точку;
|
||||||
|
НО previous, отличающаяся от текущей цены на порядок и больше, не сеется (#3385);
|
||||||
* иначе → только current-точку (now).
|
* иначе → только current-точку (now).
|
||||||
- история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену.
|
- история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену.
|
||||||
- история ЕСТЬ и latest_price == price_rub → skip (цена не менялась).
|
- история ЕСТЬ и latest_price == price_rub → skip (цена не менялась).
|
||||||
|
|
@ -41,6 +42,14 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SOURCE = "yandex"
|
_SOURCE = "yandex"
|
||||||
|
|
||||||
|
# #3385: price.previous, отличающаяся от текущей цены в 9.5 раза и больше (в любую
|
||||||
|
# сторону), — не рынок, а потерянный разряд или цена в тысячах. Прод 17.09.2026, все
|
||||||
|
# 8313 посевов yandex: самый большой честный скачок ×2.13, дальше пусто до ×9.74; выше
|
||||||
|
# порога 11 посевов (×10 — 7, ×175…×1000 — 4), и у всех нижняя цена 0.1–37 тыс/м².
|
||||||
|
# Посев синтетический (now-1d, реального времени смены gate не несёт), без него
|
||||||
|
# теряется только фиктивная точка — текущая цена пишется всё равно.
|
||||||
|
_SEED_IMPLAUSIBLE_RATIO = 9.5
|
||||||
|
|
||||||
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
|
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
|
||||||
_RESOLVE_SQL = text(
|
_RESOLVE_SQL = text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -96,6 +105,12 @@ def _insert_point(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _implausible_seed(previous: float, current: float) -> bool:
|
||||||
|
"""previous и current разнятся на порядок и больше (или одна из цен не > 0)."""
|
||||||
|
low, high = sorted((float(previous), float(current)))
|
||||||
|
return low <= 0 or high / low >= _SEED_IMPLAUSIBLE_RATIO
|
||||||
|
|
||||||
|
|
||||||
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend.
|
"""Дописать offer_price_history по yandex-lots из gate price.previous/trend.
|
||||||
|
|
||||||
|
|
@ -115,6 +130,7 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
seed_rejected = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
|
|
@ -140,20 +156,29 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
{"listing_id": listing_id},
|
{"listing_id": listing_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
|
# Сдвиг разряда у yandex ловится ТОЛЬКО на посеве (#3385). Ветка «история
|
||||||
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
|
# есть» на проде не записала ни одной строки (из 28 558 строк этого
|
||||||
# к предыдущей точке и подтверждения у следующей. На этом пути серия
|
# писателя на 17.09): смену listings.price_rub раньше неё пишет триггер
|
||||||
# максимум из двух точек (последняя лежащая в БД + текущая), у
|
# record_listing_price_change (save_listings идёт первым), и сюда
|
||||||
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
|
# приходит latest == lot.price_rub. Все 7 скачков ×10 в yandex-истории
|
||||||
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
|
# — пары «посев из price.previous → текущая цена», ошибочна нижняя.
|
||||||
# То есть проводка была бы декорацией: ветка, которая по построению
|
|
||||||
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
|
|
||||||
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
|
|
||||||
# DELETE уже вставленной строки. Отдельная задача: #3385.
|
|
||||||
if latest is None:
|
if latest is None:
|
||||||
# История пуста — seed (+ опционально previous-точка).
|
# История пуста — seed (+ опционально previous-точка).
|
||||||
prev = lot.price_previous_rub
|
prev = lot.price_previous_rub
|
||||||
if prev is not None and prev != lot.price_rub:
|
if (
|
||||||
|
prev is not None
|
||||||
|
and prev != lot.price_rub
|
||||||
|
and _implausible_seed(prev, lot.price_rub)
|
||||||
|
):
|
||||||
|
seed_rejected += 1
|
||||||
|
logger.warning(
|
||||||
|
"yandex_price_history: price.previous=%s не сеется — на порядок "
|
||||||
|
"от текущей %s (source_id=%s)",
|
||||||
|
prev,
|
||||||
|
lot.price_rub,
|
||||||
|
lot.source_id,
|
||||||
|
)
|
||||||
|
elif prev is not None and prev != lot.price_rub:
|
||||||
_insert_point(
|
_insert_point(
|
||||||
db,
|
db,
|
||||||
listing_id=listing_id,
|
listing_id=listing_id,
|
||||||
|
|
@ -191,10 +216,11 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d",
|
"yandex_price_history: lots=%d inserted=%d skipped=%d seed_rejected=%d errors=%d",
|
||||||
len(lots),
|
len(lots),
|
||||||
inserted,
|
inserted,
|
||||||
skipped,
|
skipped,
|
||||||
|
seed_rejected,
|
||||||
errors,
|
errors,
|
||||||
)
|
)
|
||||||
return inserted
|
return inserted
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
|
||||||
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
|
||||||
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
|
||||||
"ABORT -- 1 consecutive blocks".
|
"ABORT -- 1 consecutive blocks".
|
||||||
|
|
||||||
|
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
|
||||||
|
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
|
||||||
|
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
|
||||||
|
7193/7264/7302 — по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
|
||||||
|
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
|
||||||
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
|
||||||
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
|
||||||
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
|
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
|
||||||
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
|
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
|
||||||
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
|
# класс бага что #2322/#2310.
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
|
||||||
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
settings.scraper_proxy_url БЕЗ lease — там ротировать нечего, вызывающий цикл не
|
||||||
зовёт эту функцию в том режиме вовсе.
|
зовёт эту функцию в том режиме вовсе.
|
||||||
|
|
||||||
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
|
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
|
||||||
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
|
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
|
||||||
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
|
админки scrape_avito_detail_backfill в tradein-backend, — поэтому режим задаётся
|
||||||
активна. Значение true стоит только у tradein-backend, который добор не запускает.
|
дефолтом кода, а не environment'ом одного сервиса.
|
||||||
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом» —
|
|
||||||
это предсуществующее заблуждение, а не описание текущего прода.
|
|
||||||
|
|
||||||
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
|
||||||
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
НЕ должен ронять прогон — логируем и продолжаем на текущем адресе. Эта попытка НЕ
|
||||||
|
|
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
|
||||||
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
скользящем окне, см. settings.detail_backfill_block_ratio_window/
|
||||||
_threshold (module docstring).
|
_threshold (module docstring).
|
||||||
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
||||||
default 25 (см. комментарий у чтения параметра ниже).
|
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
|
||||||
|
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
|
||||||
|
|
||||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||||
|
|
@ -549,6 +554,10 @@ async def run_avito_detail_backfill(
|
||||||
window_size=int(settings.detail_backfill_block_ratio_window),
|
window_size=int(settings.detail_backfill_block_ratio_window),
|
||||||
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
|
||||||
safety_min=max_consecutive_blocks,
|
safety_min=max_consecutive_blocks,
|
||||||
|
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
|
||||||
|
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
|
||||||
|
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
|
||||||
|
infra_streak_limit=max_consecutive_failures,
|
||||||
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
|
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
|
||||||
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
|
||||||
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
# safety-net включён только когда снапшот короче окна и ratio-критерий
|
||||||
|
|
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
|
||||||
# config.scraper_proxy_url=None для backconnect-gate'а
|
# config.scraper_proxy_url=None для backconnect-gate'а
|
||||||
# (elif not use_curl own-session path), теряя reconnect-
|
# (elif not use_curl own-session path), теряя reconnect-
|
||||||
# on-403 поведение legacy (settings.scraper_proxy_url
|
# on-403 поведение legacy (settings.scraper_proxy_url
|
||||||
# напрямую). Не влияет на use_curl=True (прод-дефолт) —
|
# напрямую). Не влияет на use_curl=True —
|
||||||
# там reconnect_on_block=False уже гасит backconnect.
|
# там reconnect_on_block=False уже гасит backconnect.
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
origin=serp_origin,
|
origin=serp_origin,
|
||||||
|
|
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
current_counters = counters.to_dict()
|
||||||
|
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
|
||||||
|
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
|
||||||
|
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
|
||||||
|
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
|
||||||
|
if block_ban_kinds:
|
||||||
|
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
|
||||||
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
|
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
|
||||||
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
|
||||||
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
|
||||||
|
|
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
|
||||||
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
# и без этого ключа "banned" опять не отличить по причине (#3178).
|
||||||
if abort_reason is not None:
|
if abort_reason is not None:
|
||||||
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
|
||||||
|
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
|
||||||
|
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
|
||||||
|
# утверждать про площадку то, чего не было.
|
||||||
|
stop_reason: str | None = None
|
||||||
if no_proxy_stop:
|
if no_proxy_stop:
|
||||||
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
|
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
|
||||||
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
|
|
||||||
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
|
||||||
current_counters["no_proxy_stop"] = 1
|
current_counters["no_proxy_stop"] = 1
|
||||||
runs_mod.mark_failed(
|
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
|
||||||
db,
|
elif abort_reason == "infra_streak":
|
||||||
run_id,
|
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
|
||||||
"пул прокси пуст — к площадке не ходили (#3288)",
|
stop_reason = (
|
||||||
current_counters,
|
f"тракт недоступен: {breaker.abort_explanation()}, обогащено "
|
||||||
|
f"{counters.enriched} из {counters.attempted}; причина: "
|
||||||
|
f"{_top_failure(failure_census)} (#3439)"
|
||||||
)
|
)
|
||||||
|
if stop_reason is not None:
|
||||||
|
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
|
"avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d "
|
||||||
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
|
stop_reason,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
counters.enriched,
|
counters.enriched,
|
||||||
counters.blocked,
|
counters.blocked,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- 310_yandex_seed_decimal_slips.sql
|
||||||
|
-- Удалить из offer_price_history yandex-посевы, отличающиеся от следующей точки на
|
||||||
|
-- порядок и больше (#3385).
|
||||||
|
--
|
||||||
|
-- Apply after: 307_domclick_msk_window_no_collision.sql
|
||||||
|
--
|
||||||
|
-- ЧТО НЕ ТАК
|
||||||
|
-- services/yandex_price_history.py при пустой истории сеет точку из gate-поля
|
||||||
|
-- price.previous: change_time = now − 1 сутки (синтетическое время), следом текущая
|
||||||
|
-- цена лота с change_time = now; обе строки одним INSERT-батчем, поэтому recorded_at у
|
||||||
|
-- них совпадает. У 11 объявлений price.previous — не рынок: 1 370 000 → 13 700 000
|
||||||
|
-- (87.8 м², нижняя цена 15.6 тыс/м²), 10 900 → 10 900 000 (цена в тысячах). Замер
|
||||||
|
-- прода 17.09.2026 по всем 8313 yandex-посевам: ×10 — 7 (одно из них ×9.74), ×175…×1000
|
||||||
|
-- — 4, «вниз» — 0; самый большой скачок ниже порога — ×2.13, между ×2.13 и ×9.74 нет
|
||||||
|
-- ни одного. Ошибочна именно НИЖНЯЯ, посевная точка: у нижних 0.1–37 тыс/м², у верхних
|
||||||
|
-- 118–375 тыс/м²; верхняя совпадает с listings.price_rub у 10 из 11, а 10387563 дальше
|
||||||
|
-- дешевеет от неё же (14M → 13M → 12M, триггерные строки).
|
||||||
|
--
|
||||||
|
-- Код починен в том же PR: посев на порядок и больше больше не пишется. Эта миграция —
|
||||||
|
-- задним числом по уже записанным строкам.
|
||||||
|
--
|
||||||
|
-- КРИТЕРИЙ — ПОДПИСЬ ПОСЕВА, А НЕ ТОЛЬКО ОТНОШЕНИЕ
|
||||||
|
-- Удаляется строка o, только если у того же объявления есть строка s, записанная тем же
|
||||||
|
-- батчем (s.recorded_at = o.recorded_at) ровно через 24 часа (s.change_time =
|
||||||
|
-- o.change_time + 24 hours), и цены разнятся в 9.5 раза и больше. Так посевы пишет
|
||||||
|
-- только этот писатель; триггерные строки (change_time = recorded_at) и строки других
|
||||||
|
-- источников условием не задеваются. Интервал '24 hours', а не '1 day': прибавление
|
||||||
|
-- суток к timestamptz зависит от таймзоны сессии, а Python считает ровно 24 часа.
|
||||||
|
-- diff_percent у второй строки не трогаем: писатель его не заполняет, у всех 11 он NULL,
|
||||||
|
-- и после удаления посева вторая строка становится первой — NULL там и есть верное.
|
||||||
|
--
|
||||||
|
-- СКОЛЬКО ЖДЁМ
|
||||||
|
-- 11 строк на 17.09.2026 (7 × 10 и 4 × 175…1000). До деплоя кода новые посевы ещё
|
||||||
|
-- могут появиться (06.09 → 17.09 прибавился один). Порог остановки 30: поймали больше —
|
||||||
|
-- критерий ловит не то, миграция падает, а не молча чистит историю.
|
||||||
|
--
|
||||||
|
-- ИДЕМПОТЕНТНОСТЬ
|
||||||
|
-- Удалённый посев второй раз не находится, у оставшейся строки пары больше нет →
|
||||||
|
-- повторный прогон удаляет 0. Новых объектов схемы нет.
|
||||||
|
BEGIN;
|
||||||
|
-- Конвенция проекта (#2752): DELETE берёт блокировки строк offer_price_history; без
|
||||||
|
-- lock_timeout деплой встанет в очередь за чужой сессией.
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
deleted_rows bigint;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM offer_price_history o
|
||||||
|
USING offer_price_history s
|
||||||
|
WHERE o.source = 'yandex'
|
||||||
|
AND s.source = 'yandex'
|
||||||
|
AND s.listing_id = o.listing_id
|
||||||
|
AND s.recorded_at = o.recorded_at
|
||||||
|
AND s.change_time = o.change_time + interval '24 hours'
|
||||||
|
AND o.price_rub > 0
|
||||||
|
AND s.price_rub > 0
|
||||||
|
AND greatest(o.price_rub, s.price_rub) / least(o.price_rub, s.price_rub) >= 9.5;
|
||||||
|
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'offer_price_history: удалено yandex-посевов на порядок = %', deleted_rows;
|
||||||
|
|
||||||
|
-- Исключение откатывает всю транзакцию вместе с DELETE выше.
|
||||||
|
IF deleted_rows > 30 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'offer_price_history: к удалению % yandex-посевов при замере 17.09.2026 = 11 — '
|
||||||
|
'критерий ловит не то, миграция остановлена', deleted_rows;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
||||||
``params`` block. Touches NO DB / network and does NOT consult
|
``params`` block. Touches NO DB / network and does NOT consult
|
||||||
``settings_at_capture`` — it prices against the live committed
|
``settings_at_capture`` — it prices against the live committed
|
||||||
``estimator.settings`` defaults (so a settings change is caught as a metric
|
``estimator.settings`` defaults and module constants (so a change is caught as a
|
||||||
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение —
|
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
|
||||||
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
|
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
|
||||||
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
|
(закоммиченная — такая); более старая упадёт на «control flow diverged from
|
||||||
потока (см. комментарий у пина ниже). Deterministic: same fixture → identical
|
capture» — её надо перезахватить. Deterministic: same fixture → identical dict.
|
||||||
dict.
|
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
m = est.m
|
m = est.m
|
||||||
|
|
@ -1991,26 +1990,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
pred_ppm2_all: list[float] = []
|
pred_ppm2_all: list[float] = []
|
||||||
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
||||||
|
|
||||||
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
|
|
||||||
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
|
|
||||||
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
|
|
||||||
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
|
|
||||||
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
|
|
||||||
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
|
|
||||||
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
|
|
||||||
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
|
|
||||||
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
|
|
||||||
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
|
|
||||||
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
|
|
||||||
# вызывается, а реплей с dedup=False их оценивает и упирается в
|
|
||||||
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
|
|
||||||
# False — для старых фикстур без settings_at_capture поведение прежнее).
|
|
||||||
_dedup_capture = bool(
|
|
||||||
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
|
|
||||||
)
|
|
||||||
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
|
|
||||||
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
|
|
||||||
try:
|
|
||||||
for rec in deals:
|
for rec in deals:
|
||||||
kw = dict(rec["kwargs"])
|
kw = dict(rec["kwargs"])
|
||||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||||
|
|
@ -2066,9 +2045,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
||||||
predictions.append(prediction)
|
predictions.append(prediction)
|
||||||
pred_ppm2_all.append(prediction.median_ppm2)
|
pred_ppm2_all.append(prediction.median_ppm2)
|
||||||
|
|
||||||
finally:
|
|
||||||
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
|
||||||
|
|
||||||
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
||||||
|
|
||||||
|
|
@ -2123,7 +2099,6 @@ def _predict_full_spine(
|
||||||
whether the house IMV anchor actually resolved a row for this deal.
|
whether the house IMV anchor actually resolved a row for this deal.
|
||||||
"""
|
"""
|
||||||
m = est.m
|
m = est.m
|
||||||
settings = est.settings
|
|
||||||
|
|
||||||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
||||||
db, deal, est, target_house_id=target_house_id
|
db, deal, est, target_house_id=target_house_id
|
||||||
|
|
@ -2212,7 +2187,7 @@ def _predict_full_spine(
|
||||||
res = m._lookup_quarter_index(
|
res = m._lookup_quarter_index(
|
||||||
db,
|
db,
|
||||||
quarter_cad_number=q,
|
quarter_cad_number=q,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
ret = _sanitize_json(list(res) if res is not None else None)
|
ret = _sanitize_json(list(res) if res is not None else None)
|
||||||
|
|
@ -2223,7 +2198,7 @@ def _predict_full_spine(
|
||||||
res = m._lookup_quarter_indexes(
|
res = m._lookup_quarter_indexes(
|
||||||
db,
|
db,
|
||||||
quarter_cad_numbers=qs,
|
quarter_cad_numbers=qs,
|
||||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||||
)
|
)
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
||||||
|
|
@ -2658,7 +2633,9 @@ def _write_fixture(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Freeze captured per-deal replay records into a committed JSON fixture.
|
"""Freeze captured per-deal replay records into a committed JSON fixture.
|
||||||
|
|
||||||
``settings_at_capture`` records every ``estimate_*`` Settings field as an
|
``settings_at_capture`` records every ``estimate_*`` Settings field plus the
|
||||||
|
estimator's numeric module constants (#1970: калибровочные пороги переехали из
|
||||||
|
Settings в код — без них снимок не говорит, с какими порогами захватили) as an
|
||||||
informational snapshot (NOT consulted by ``replay_fixture``). A final
|
informational snapshot (NOT consulted by ``replay_fixture``). A final
|
||||||
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
||||||
JSON-plain before ``json.dump(allow_nan=False)``.
|
JSON-plain before ``json.dump(allow_nan=False)``.
|
||||||
|
|
@ -2668,6 +2645,11 @@ def _write_fixture(
|
||||||
for name in sorted(type(est.settings).model_fields)
|
for name in sorted(type(est.settings).model_fields)
|
||||||
if name.startswith("estimate_")
|
if name.startswith("estimate_")
|
||||||
}
|
}
|
||||||
|
settings_at_capture.update(
|
||||||
|
(name, _sanitize_json(value))
|
||||||
|
for name, value in sorted(vars(est.m).items())
|
||||||
|
if name.isupper() and isinstance(value, int | float)
|
||||||
|
)
|
||||||
fixture = {
|
fixture = {
|
||||||
"schema_version": FIXTURE_SCHEMA_VERSION,
|
"schema_version": FIXTURE_SCHEMA_VERSION,
|
||||||
"engine": "full",
|
"engine": "full",
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,19 @@
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"high": {
|
"high": {
|
||||||
"coverage_pct": 75.0,
|
"coverage_pct": 75.0,
|
||||||
"mape_pct": 19.6,
|
"mape_pct": 19.97,
|
||||||
"n": 8,
|
"n": 8,
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 83.67,
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 15.64,
|
||||||
"n": 1562,
|
"n": 1562,
|
||||||
"n_covered": 1029
|
"n_covered": 1030
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 70.0,
|
||||||
"mape_pct": 27.59,
|
"mape_pct": 26.53,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_covered": 21
|
"n_covered": 21
|
||||||
}
|
}
|
||||||
|
|
@ -26,17 +26,17 @@
|
||||||
],
|
],
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 15.88,
|
"mape_pct": 15.92,
|
||||||
"median_bias_pct": 2.87,
|
"median_bias_pct": 2.87,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.88,
|
"p25_pct": -10.94,
|
||||||
"p75_pct": 22.98
|
"p75_pct": 22.81
|
||||||
},
|
},
|
||||||
"per_area_bucket": {
|
"per_area_bucket": {
|
||||||
"0 <30": {
|
"0 <30": {
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 19.76,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -1.02,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -16.45,
|
||||||
"p75_pct": 34.7
|
"p75_pct": 34.7
|
||||||
|
|
@ -45,21 +45,21 @@
|
||||||
"mape_pct": 15.64,
|
"mape_pct": 15.64,
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": 4.37,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -10.56,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 22.86
|
||||||
},
|
},
|
||||||
"2 44-62": {
|
"2 44-62": {
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 13.93,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": 4.13,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -8.12,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 23.5
|
||||||
},
|
},
|
||||||
"3 62-85": {
|
"3 62-85": {
|
||||||
"mape_pct": 15.97,
|
"mape_pct": 15.97,
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -2.33,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -11.99,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 18.18
|
||||||
},
|
},
|
||||||
"4 >=85": {
|
"4 >=85": {
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 19.76,
|
"mape_pct": 19.76,
|
||||||
"median_bias_pct": -1.49,
|
"median_bias_pct": -1.02,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -16.45,
|
"p25_pct": -16.45,
|
||||||
|
|
@ -86,17 +86,17 @@
|
||||||
"median_bias_pct": 4.37,
|
"median_bias_pct": 4.37,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.58,
|
"p25_pct": -10.56,
|
||||||
"p75_pct": 22.86
|
"p75_pct": 22.86
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 13.86,
|
"mape_pct": 13.93,
|
||||||
"median_bias_pct": 4.15,
|
"median_bias_pct": 4.13,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -7.92,
|
"p25_pct": -8.12,
|
||||||
"p75_pct": 23.85
|
"p75_pct": 23.5
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
"median_bias_pct": -2.33,
|
"median_bias_pct": -2.33,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -12.1,
|
"p25_pct": -11.99,
|
||||||
"p75_pct": 18.18
|
"p75_pct": 18.18
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
|
|
@ -126,11 +126,11 @@
|
||||||
"p75_pct": -12.79
|
"p75_pct": -12.79
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 10.68,
|
"mape_pct": 10.69,
|
||||||
"median_bias_pct": -5.78,
|
"median_bias_pct": -5.78,
|
||||||
"n": 425,
|
"n": 425,
|
||||||
"p25_pct": -15.88,
|
"p25_pct": -15.98,
|
||||||
"p75_pct": 4.49
|
"p75_pct": 4.54
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": null,
|
"mape_pct": null,
|
||||||
|
|
@ -140,11 +140,11 @@
|
||||||
"p75_pct": null
|
"p75_pct": null
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 20.17,
|
"mape_pct": 20.22,
|
||||||
"median_bias_pct": 18.98,
|
"median_bias_pct": 18.98,
|
||||||
"n": 697,
|
"n": 697,
|
||||||
"p25_pct": 1.25,
|
"p25_pct": 0.96,
|
||||||
"p75_pct": 41.97
|
"p75_pct": 42.31
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 36.11,
|
"mape_pct": 36.11,
|
||||||
|
|
@ -162,9 +162,9 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 83.22,
|
"coverage_pct": 83.29,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_covered": 1056
|
"n_covered": 1057
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
|
|
@ -173,9 +173,9 @@
|
||||||
"n_covered": 6
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 83.59,
|
"coverage_pct": 83.67,
|
||||||
"n": 1231,
|
"n": 1231,
|
||||||
"n_covered": 1029
|
"n_covered": 1030
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 70.0,
|
"coverage_pct": 70.0,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import pytest
|
||||||
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=None))
|
asyncio.run(_call_kit_own_session(config=None))
|
||||||
|
|
||||||
assert captured.get("proxies") is None
|
assert captured.get("proxies") is None
|
||||||
|
|
@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
||||||
|
|
||||||
assert captured.get("proxies") == {
|
assert captured.get("proxies") == {
|
||||||
"http": "http://test-proxy.local:8080",
|
"http": "http://test-proxy.local:8080",
|
||||||
"https": "http://test-proxy.local:8080",
|
"https": "http://test-proxy.local:8080",
|
||||||
}
|
}
|
||||||
|
# #2386: сессия строится общей build_document_session — параметры те же, что были у
|
||||||
|
# собственной (impersonate/timeout=25/document-заголовки).
|
||||||
|
assert captured.get("impersonate") == DEFAULT_IMPERSONATE
|
||||||
|
assert captured.get("timeout") == 25
|
||||||
|
assert captured.get("headers") == DOCUMENT_HEADERS
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
# DATABASE_URL required by config before any app import.
|
# DATABASE_URL required by config before any app import.
|
||||||
|
|
@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||||
resolve_cian_zhk_url_via_search,
|
resolve_cian_zhk_url_via_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# config обязателен (#2385); на пути с переданной session он не читается.
|
||||||
|
_CONFIG = SimpleNamespace(cian_proxy_url=None)
|
||||||
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None:
|
||||||
async def test_resolve_via_search_happy_path() -> None:
|
async def test_resolve_via_search_happy_path() -> None:
|
||||||
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
||||||
session = _mock_session(status_code=200, text=_serp_fixture())
|
session = _mock_session(status_code=200, text=_serp_fixture())
|
||||||
url = await resolve_cian_zhk_url_via_search(48853, session=session)
|
url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session)
|
||||||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||||
# caller owns a passed-in session — resolver must NOT close it.
|
# caller owns a passed-in session — resolver must NOT close it.
|
||||||
session.close.assert_not_awaited()
|
session.close.assert_not_awaited()
|
||||||
|
|
@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None:
|
||||||
async def test_resolve_via_search_no_match_returns_none() -> None:
|
async def test_resolve_via_search_no_match_returns_none() -> None:
|
||||||
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
||||||
session = _mock_session(status_code=200, text="<html>no results</html>")
|
session = _mock_session(status_code=200, text="<html>no results</html>")
|
||||||
assert await resolve_cian_zhk_url_via_search(999, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_via_search_non_200_returns_none() -> None:
|
async def test_resolve_via_search_non_200_returns_none() -> None:
|
||||||
"""A non-200 (block / 404) → None without attempting extraction."""
|
"""A non-200 (block / 404) → None without attempting extraction."""
|
||||||
session = _mock_session(status_code=429, text="too many requests")
|
session = _mock_session(status_code=429, text="too many requests")
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
||||||
session.close = AsyncMock()
|
session.close = AsyncMock()
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
|
||||||
|
|
@ -153,3 +153,9 @@ tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
|
||||||
# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip.
|
# В 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_serp_rescrape_keeps_detail_living_area_and_balconies
|
||||||
tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites
|
tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites
|
||||||
|
|
||||||
|
# Миграция 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
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
|
||||||
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
|
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
|
||||||
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
|
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
|
||||||
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
|
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
|
||||||
at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl),
|
at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl),
|
||||||
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
|
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
|
||||||
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
|
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
|
||||||
guard above, lines 141-148)."""
|
guard above, lines 141-148)."""
|
||||||
|
|
|
||||||
202
tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
Normal file
202
tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""Упавший хендлер не оставляет строку scrape_runs в 'running' до zombie-reaper'а (#1940).
|
||||||
|
|
||||||
|
Прод 17.09 06:58: avito_city_sweep run 7349 — через 10 мс после claim BrowserFetcher.__aenter__
|
||||||
|
бросил NoProxyAvailableError, планировщик записал «crashed run_id=7349» и всё. Строка
|
||||||
|
осталась 'running' с heartbeat_at == started_at; 21 сутки до этого так ушли в 'zombie'
|
||||||
|
18 avito-свипов, каждый через 6 ч. Проверяется значение статуса в строке прогона.
|
||||||
|
|
||||||
|
Финализатор — `runs.mark_crashed`. Его зовут оба запускающих: планировщик и ручные
|
||||||
|
запуски из админки (они идут мимо `_dispatch`). Здесь боевой `mark_crashed` над
|
||||||
|
двойником одной строки scrape_runs: `_Row` отвечает на те же SQL, что пишет runs.py,
|
||||||
|
с гейтом `WHERE status = 'running'`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import types
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.orchestration import runs as kit_runs
|
||||||
|
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||||
|
from scraper_kit.orchestration.scheduler import Handler, SchedulerContext, _dispatch
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
|
|
||||||
|
RUN_ID = 7349
|
||||||
|
|
||||||
|
|
||||||
|
class _Row:
|
||||||
|
"""Сессия над одной строкой scrape_runs (status/ban_kind/error/counters).
|
||||||
|
|
||||||
|
История источника для стрик-алерта — `CONSECUTIVE_FAILURE_ALERT_THRESHOLD` неудач:
|
||||||
|
ровно веха лестницы `_streak_alert_due`, на которой дубль и уходил бы в Sentry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.status = "running"
|
||||||
|
self.ban_kind: str | None = None
|
||||||
|
self.error: str | None = None
|
||||||
|
self.updates = 0
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
sql = str(stmt)
|
||||||
|
p = params or {}
|
||||||
|
res = MagicMock()
|
||||||
|
res.fetchone.return_value = None # гейт _claim_run: «не running»
|
||||||
|
res.scalar.return_value = True # advisory-lock взят
|
||||||
|
if "INSERT INTO scrape_runs" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(id=RUN_ID)
|
||||||
|
elif "SELECT status FROM scrape_runs WHERE id" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(status=self.status)
|
||||||
|
elif "UPDATE scrape_runs" in sql and "SET status" in sql:
|
||||||
|
hit = self.status == "running"
|
||||||
|
if hit:
|
||||||
|
m = re.search(r"SET status = '(\w+)'", sql)
|
||||||
|
assert m is not None
|
||||||
|
self.status = m.group(1)
|
||||||
|
self.ban_kind = p.get("ban_kind")
|
||||||
|
self.error = p.get("error")
|
||||||
|
self.updates += 1
|
||||||
|
res.first.return_value = types.SimpleNamespace(id=RUN_ID) if hit else None
|
||||||
|
elif "SELECT source FROM scrape_runs" in sql:
|
||||||
|
res.fetchone.return_value = types.SimpleNamespace(source="avito_city_sweep")
|
||||||
|
elif "SELECT status, counters FROM scrape_runs" in sql:
|
||||||
|
res.fetchall.return_value = [
|
||||||
|
types.SimpleNamespace(status="failed", counters={})
|
||||||
|
] * CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||||
|
return res
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_and_wait(job: Any, row: _Row) -> None:
|
||||||
|
ctx = SchedulerContext(
|
||||||
|
config=MagicMock(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
enrichment=MagicMock(),
|
||||||
|
session_factory=lambda: row,
|
||||||
|
)
|
||||||
|
sched = {
|
||||||
|
"id": 1,
|
||||||
|
"source": "avito_city_sweep",
|
||||||
|
"window_start_hour": 6,
|
||||||
|
"window_end_hour": 7,
|
||||||
|
"default_params": {},
|
||||||
|
}
|
||||||
|
run_id = await _dispatch(Handler(job, "avito_city_sweep"), row, sched, ctx) # type: ignore[arg-type]
|
||||||
|
assert run_id == RUN_ID
|
||||||
|
await asyncio.gather(*ctx._inflight_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── планировщик ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_proxy_crash_before_try_marks_run_banned_infra() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
raise NoProxyAvailableError("avito")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
|
assert row.error is not None and "no proxy available" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wrapped_no_proxy_is_still_infra() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
try:
|
||||||
|
raise NoProxyAvailableError("cian")
|
||||||
|
except NoProxyAvailableError as exc:
|
||||||
|
raise RuntimeError("sidecar fetch failed") from exc
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_other_crash_marks_run_failed() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
raise KeyError("anchors")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert row.status == "failed"
|
||||||
|
assert row.error is not None and "KeyError" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
async def test_handler_own_final_status_survives() -> None:
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
kit_runs.mark_done(db, run_id, {"done_buckets": ["center"]})
|
||||||
|
raise RuntimeError("after mark_done")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.updates) == ("done", 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pipeline_failure_is_alerted_once_not_twice() -> None:
|
||||||
|
"""Обычный путь пайплайна: mark_failed и raise. Повторная финализация не шлёт дубль.
|
||||||
|
|
||||||
|
Стрик после первой финализации стоит на вехе лестницы; no-op mark_failed из
|
||||||
|
планировщика позвал бы `_alert_on_run_id` ещё раз с тем же стриком.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
|
||||||
|
kit_runs.mark_failed(db, run_id, "cian-sweep fatal", {})
|
||||||
|
raise RuntimeError("cian-sweep fatal")
|
||||||
|
|
||||||
|
row = _Row()
|
||||||
|
sentry = MagicMock()
|
||||||
|
with patch.object(kit_runs, "sentry_sdk", sentry):
|
||||||
|
await _dispatch_and_wait(_job, row)
|
||||||
|
|
||||||
|
assert (row.status, row.updates) == ("failed", 1)
|
||||||
|
assert sentry.capture_message.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── ручные запуски из админки (мимо _dispatch) ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "runner"),
|
||||||
|
[
|
||||||
|
("avito-city-sweep", "run_avito_city_sweep"),
|
||||||
|
("cian-city-sweep", "run_cian_city_sweep"),
|
||||||
|
("cian-full-load", "run_cian_full_load"),
|
||||||
|
("yandex-full-load", "run_yandex_full_load"),
|
||||||
|
("yandex-city-sweep", "run_yandex_city_sweep"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_admin_manual_launch_crash_is_finalized(path: str, runner: str) -> None:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import admin as admin_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||||
|
row = _Row()
|
||||||
|
app.dependency_overrides[get_db] = lambda: row
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(kit_runs, "sentry_sdk", None),
|
||||||
|
patch.object(admin_module, "has_running_run", return_value=False),
|
||||||
|
patch.object(admin_module, "SessionLocal", lambda: row),
|
||||||
|
patch.object(admin_module, runner, AsyncMock(side_effect=NoProxyAvailableError("avito"))),
|
||||||
|
):
|
||||||
|
r = TestClient(app).post(f"/api/v1/admin/scrape/{path}", json={})
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
|
||||||
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""#1970: калибровочные константы движка, вынесенные из Settings, — значения.
|
||||||
|
|
||||||
|
Поля estimate_* жили в Settings и формально переопределялись через ENV. При
|
||||||
|
переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже
|
||||||
|
сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и
|
||||||
|
tradein-tgbot — совпали, ENV-оверрайдов нет), а не скопированы из кода.
|
||||||
|
|
||||||
|
Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не
|
||||||
|
проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок),
|
||||||
|
а поведенческие тесты ловят не все пороги. Поменять число — осознанно: здесь,
|
||||||
|
в estimator.py и с прогоном регрессионного гейта.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.services import estimator
|
||||||
|
|
||||||
|
# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026)
|
||||||
|
_PROD_VALUES: dict[str, tuple[str, float]] = {
|
||||||
|
# #2379, #764 квартальный индекс
|
||||||
|
"QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10),
|
||||||
|
"QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6),
|
||||||
|
"QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0),
|
||||||
|
"QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
|
||||||
|
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
|
||||||
|
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
|
||||||
|
# #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config)
|
||||||
|
"CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10),
|
||||||
|
"CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40),
|
||||||
|
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
|
||||||
|
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
|
||||||
|
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
|
||||||
|
# #2381, #651/#652 same-building anchor + IMV-blend
|
||||||
|
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
|
||||||
|
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
|
||||||
|
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
|
||||||
|
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
|
||||||
|
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
|
||||||
|
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
|
||||||
|
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
|
||||||
|
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
|
||||||
|
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
|
||||||
|
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
|
||||||
|
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
|
||||||
|
"FSD_K": ("estimate_fsd_k", 1.65),
|
||||||
|
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
|
||||||
|
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
|
||||||
|
_REMOVED_SWITCHES = (
|
||||||
|
"estimate_dedup_analogs_enabled", # #2378
|
||||||
|
"estimate_expected_sold_le_asking", # #2380
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||||
|
def test_constant_keeps_prod_value(const: str) -> None:
|
||||||
|
assert getattr(estimator, const) == _PROD_VALUES[const][1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||||
|
def test_settings_field_is_gone(const: str) -> None:
|
||||||
|
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
|
||||||
|
assert _PROD_VALUES[const][0] not in Settings.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", _REMOVED_SWITCHES)
|
||||||
|
def test_switch_is_gone(field: str) -> None:
|
||||||
|
assert field not in Settings.model_fields
|
||||||
248
tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
Normal file
248
tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""Крайние случаи 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:
|
||||||
|
"""Корзина без сохранённых строк не попадает в чекпоинт.
|
||||||
|
|
||||||
|
Лоты Домклика копятся в памяти и пишутся ОДНИМ save_listings после всех корзин.
|
||||||
|
Фаза, снятая watchdog'ом (или упавшая на save), не сохранила ничего — даже из
|
||||||
|
корзины 'st', которую скрейпер успел пройти. Отметить её пройденной значило бы,
|
||||||
|
что следующий прогон пропустит её через 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
|
||||||
|
return [object()]
|
||||||
|
|
||||||
|
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"
|
||||||
28
tradein-mvp/backend/tests/test_3045_use_curl_default.py
Normal file
28
tradein-mvp/backend/tests/test_3045_use_curl_default.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""#3045: дефолт avito_detail_backfill_use_curl — браузер, а не curl.
|
||||||
|
|
||||||
|
curl_cffi не решает QRATOR PoW Авито (замер 21.08: ~6% обогащений против ~74% на
|
||||||
|
браузере). Пока дефолт был True, браузер включал только environment сервиса scraper,
|
||||||
|
а tradein-backend (ручка админки scrape_avito_detail_backfill исполняет добор в своём
|
||||||
|
процессе) шёл curl'ом. Проверка по значению поля Settings без env и без .env-файла.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_backfill_defaults_to_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("AVITO_DETAIL_BACKFILL_USE_CURL", raising=False)
|
||||||
|
|
||||||
|
assert Settings(_env_file=None).avito_detail_backfill_use_curl is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_still_switches_to_curl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Контроль: переменная окружения по-прежнему переключает режим — поле живое."""
|
||||||
|
monkeypatch.setenv("AVITO_DETAIL_BACKFILL_USE_CURL", "true")
|
||||||
|
|
||||||
|
assert Settings(_env_file=None).avito_detail_backfill_use_curl is True
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""#3182: режим detail-добора Авито не зависит от того, какой контейнер взял задачу.
|
||||||
|
|
||||||
|
Добор исполняют два процесса образа backend: планировщик (tradein-scraper) и ручка
|
||||||
|
админки scrape_avito_detail_backfill (tradein-backend). Хостовый backend/.env.runtime
|
||||||
|
задаёт AVITO_DETAIL_BACKFILL_USE_CURL=true, а environment сервиса перекрывает env_file —
|
||||||
|
до правки "false" стояло только у scraper, и printenv на проде давал scraper=false,
|
||||||
|
backend=true. Проверка по значению: разобранный compose, каждый сервис образа backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_backend_image_service_pins_browser_transport() -> None:
|
||||||
|
services = yaml.safe_load(_COMPOSE.read_text())["services"]
|
||||||
|
backend_image = {
|
||||||
|
name: svc
|
||||||
|
for name, svc in services.items()
|
||||||
|
if "gendesign-tradein-backend" in svc.get("image", "")
|
||||||
|
}
|
||||||
|
# Контроль разбора: без него пустой словарь дал бы зелёный тест ни о чём.
|
||||||
|
assert {"backend", "scraper"} <= set(backend_image), sorted(backend_image)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
name: (svc.get("environment") or {}).get("AVITO_DETAIL_BACKFILL_USE_CURL")
|
||||||
|
for name, svc in backend_image.items()
|
||||||
|
}
|
||||||
|
assert values == dict.fromkeys(backend_image, "false"), values
|
||||||
|
|
||||||
|
|
||||||
|
def test_leftover_yandex_cookies_env_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""YANDEX_COOKIES_FILE удалён из Settings (никто не читал); хостовый .env.runtime
|
||||||
|
его ещё несёт — старт не должен падать, а поле не должно всплыть обратно."""
|
||||||
|
monkeypatch.setenv("YANDEX_COOKIES_FILE", "/app/yandex_cookies.json")
|
||||||
|
|
||||||
|
assert not hasattr(Settings(_env_file=None), "yandex_cookies_file")
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ.
|
"""#3197 (хвост) + #3410 — две служебные ручки admin.py подключают пул прокси.
|
||||||
|
|
||||||
`POST /admin/scrape/domclick/debug/detail-fetch` — **настоящий фикс**. Он ходит через
|
`POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который
|
||||||
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` — единственные методы, которые кладут
|
кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает
|
||||||
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
|
|
||||||
(`_resolve_proxy_override` → `_ensure_browser(provider, proxy_override=...)`). Прямая
|
(`_resolve_proxy_override` → `_ensure_browser(provider, proxy_override=...)`). Прямая
|
||||||
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
|
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
|
||||||
`proxy_provider`/`use_pool`/`environment` — без них сайдкар брал свой env-узел
|
`proxy_provider`/`use_pool`/`environment` — без них сайдкар брал свой env-узел
|
||||||
|
|
@ -10,17 +9,11 @@
|
||||||
прод-отказ «пул пуст → не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
|
прод-отказ «пул пуст → не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
|
||||||
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
|
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
|
||||||
|
|
||||||
`POST /admin/scrape/cian/auto-login` — **намеренно без пула**, и это проверяется обратными
|
`POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login`
|
||||||
по значению утверждениями. Логин идёт не через `fetch`, а через `login` → `_post_login`,
|
сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же
|
||||||
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler`
|
мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт
|
||||||
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и
|
туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде — честный 503 «нет
|
||||||
`_ensure_browser(provider)` без override — то есть **сайдкар на `/login` proxy-override не
|
узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел.
|
||||||
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
|
|
||||||
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
|
|
||||||
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST —
|
|
||||||
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
|
|
||||||
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
|
|
||||||
ради endpoint/environment из одного места); пул для логина — отдельная задача сайдкара.
|
|
||||||
|
|
||||||
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
|
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
|
||||||
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
|
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
|
||||||
|
|
@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
captured: ClassVar[list[dict[str, Any]]] = []
|
captured: ClassVar[list[dict[str, Any]]] = []
|
||||||
|
login_proxy: ClassVar[list[str | None]] = []
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
_RealLeaseFetcher.captured.append(kwargs)
|
_RealLeaseFetcher.captured.append(kwargs)
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
async def login(self, **_kwargs: Any) -> dict[str, str]:
|
async def login(self, **_kwargs: Any) -> dict[str, str]:
|
||||||
|
# Узел, который настоящий login() положил бы в тело /login (см. его код).
|
||||||
|
_RealLeaseFetcher.login_proxy.append(self._current_proxy()[0])
|
||||||
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
|
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
|
||||||
|
|
||||||
|
|
||||||
class _SpyProvider:
|
class _SpyProvider:
|
||||||
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None.
|
"""Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст).
|
||||||
|
|
||||||
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
|
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
|
||||||
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
|
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
|
||||||
|
|
@ -99,10 +95,11 @@ class _SpyProvider:
|
||||||
|
|
||||||
acquired: ClassVar[list[str]] = []
|
acquired: ClassVar[list[str]] = []
|
||||||
released: ClassVar[list[int]] = []
|
released: ClassVar[list[int]] = []
|
||||||
|
lease: ClassVar[ProxyLease | None] = None
|
||||||
|
|
||||||
def acquire(self, provider: str) -> ProxyLease | None:
|
def acquire(self, provider: str) -> ProxyLease | None:
|
||||||
_SpyProvider.acquired.append(provider)
|
_SpyProvider.acquired.append(provider)
|
||||||
return None
|
return _SpyProvider.lease
|
||||||
|
|
||||||
def release(self, lease: ProxyLease) -> None:
|
def release(self, lease: ProxyLease) -> None:
|
||||||
_SpyProvider.released.append(lease.id)
|
_SpyProvider.released.append(lease.id)
|
||||||
|
|
@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
|
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
|
||||||
_CapturingFetcher.captured = []
|
_CapturingFetcher.captured = []
|
||||||
_RealLeaseFetcher.captured = []
|
_RealLeaseFetcher.captured = []
|
||||||
|
_RealLeaseFetcher.login_proxy = []
|
||||||
_SpyProvider.acquired = []
|
_SpyProvider.acquired = []
|
||||||
_SpyProvider.released = []
|
_SpyProvider.released = []
|
||||||
|
_SpyProvider.lease = None
|
||||||
|
|
||||||
|
|
||||||
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
|
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
|
||||||
|
|
@ -149,13 +148,11 @@ def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None:
|
||||||
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
|
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
|
||||||
@pytest.mark.usefixtures("_pool_on")
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
async def test_cian_auto_login_does_not_lease_from_pool(
|
async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
|
"""#3410: прод + узел в пуле → логин идёт через него, аренда отпускается."""
|
||||||
) -> None:
|
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
||||||
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг)."""
|
_SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")
|
||||||
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
|
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
_patched_cian_login(stack, _RealLeaseFetcher)
|
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||||
|
|
@ -165,8 +162,12 @@ async def test_cian_auto_login_does_not_lease_from_pool(
|
||||||
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
|
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
|
||||||
kwargs = _RealLeaseFetcher.captured[0]
|
kwargs = _RealLeaseFetcher.captured[0]
|
||||||
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
|
||||||
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен"
|
assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла"
|
||||||
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)"
|
assert kwargs.get("use_pool") is True
|
||||||
|
assert kwargs.get("environment") == "production"
|
||||||
|
assert _SpyProvider.acquired == ["cian"]
|
||||||
|
assert _RealLeaseFetcher.login_proxy == ["http://u:p@pool:8080"]
|
||||||
|
assert _SpyProvider.released == [13], "аренда логина не должна течь"
|
||||||
# endpoint из фабрики не должен потеряться (#2322: без него TypeError).
|
# endpoint из фабрики не должен потеряться (#2322: без него TypeError).
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
@ -174,28 +175,40 @@ async def test_cian_auto_login_does_not_lease_from_pool(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_pool_on")
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
async def test_cian_auto_login_survives_empty_pool_in_production(
|
async def test_cian_auto_login_empty_pool_in_production_is_honest_503(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502).
|
"""Пустой пул на проде → 503 «нет узла для cian», логин не запускается.
|
||||||
|
|
||||||
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при
|
Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на
|
||||||
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`,
|
env-узел сайдкара (#2616: на проде он мёртв).
|
||||||
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` — то
|
|
||||||
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
|
|
||||||
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
|
|
||||||
"""
|
"""
|
||||||
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
_cian_login_settings(monkeypatch, use_pool=True, env="production")
|
||||||
|
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
_patched_cian_login(stack, _RealLeaseFetcher)
|
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||||
try:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||||
except HTTPException as exc:
|
|
||||||
# pytest.fail, а не re-raise: красное должно называть статус и detail.
|
|
||||||
pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}")
|
|
||||||
|
|
||||||
assert result["ok"] is True and result["userId"] == 7
|
assert (exc_info.value.status_code, "пул" in str(exc_info.value.detail)) == (503, True)
|
||||||
|
assert _SpyProvider.acquired == ["cian"]
|
||||||
|
assert _RealLeaseFetcher.login_proxy == [], "логин без узла запускаться не должен"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_pool_on")
|
||||||
|
async def test_cian_auto_login_dev_without_browser_pool_uses_env(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""dev + browser-пул выключен → аренды нет, логин без proxy в теле (env сайдкара)."""
|
||||||
|
_cian_login_settings(monkeypatch, use_pool=False, env="dev")
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
_patched_cian_login(stack, _RealLeaseFetcher)
|
||||||
|
result = await admin.cian_auto_login(db=MagicMock(), body=None)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert _SpyProvider.acquired == []
|
||||||
|
assert _RealLeaseFetcher.login_proxy == [None]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
|
||||||
|
|
|
||||||
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
78
tradein-mvp/backend/tests/test_3234_tier_h_settings.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы.
|
||||||
|
|
||||||
|
Tier H в _fetch_analogs отдаёт «тот же класс дома» только при ≥ N комплов, иначе
|
||||||
|
уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и
|
||||||
|
×0.7…×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения,
|
||||||
|
которые реально уходят в SQL, и тир, который реально возвращается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.services.estimator as est
|
||||||
|
|
||||||
|
|
||||||
|
def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]:
|
||||||
|
"""_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну."""
|
||||||
|
tier_h_params: dict = {}
|
||||||
|
|
||||||
|
def side_effect(stmt, params): # type: ignore[no-untyped-def]
|
||||||
|
result = MagicMock()
|
||||||
|
if "tf_min" in params:
|
||||||
|
tier_h_params.update(params)
|
||||||
|
rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)]
|
||||||
|
else:
|
||||||
|
rows = [{"source": "cian", "relevance_score": 0.0}]
|
||||||
|
result.mappings.return_value.all.return_value = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.side_effect = side_effect
|
||||||
|
_, _, tier = est._fetch_analogs(
|
||||||
|
db,
|
||||||
|
lat=56.83,
|
||||||
|
lon=60.6,
|
||||||
|
rooms=2,
|
||||||
|
area=50.0,
|
||||||
|
radius_m=1000,
|
||||||
|
year_built=1975,
|
||||||
|
total_floors=total_floors,
|
||||||
|
)
|
||||||
|
return tier, tier_h_params
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_keep_old_literals() -> None:
|
||||||
|
tier, params = _run(n_tier_h=5, total_floors=9)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7)
|
||||||
|
assert tier == "H"
|
||||||
|
tier, params = _run(n_tier_h=4, total_floors=16)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8)
|
||||||
|
assert tier == "W"
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4)
|
||||||
|
assert _run(n_tier_h=4, total_floors=9)[0] == "H"
|
||||||
|
assert _run(n_tier_h=3, total_floors=9)[0] == "W"
|
||||||
|
|
||||||
|
|
||||||
|
def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5)
|
||||||
|
_, params = _run(n_tier_h=5, total_floors=9)
|
||||||
|
assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать.
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4")
|
||||||
|
monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5")
|
||||||
|
s = Settings()
|
||||||
|
assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5)
|
||||||
|
|
@ -229,6 +229,25 @@ async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_done_run_keeps_ban_kinds_in_counters() -> None:
|
||||||
|
"""#3288 п.5: диагноз доезжает в counters и у прогона, который не 'banned'.
|
||||||
|
|
||||||
|
Прогоны 7193/7264/7302 (1600 попыток, ~1599 отказов сайдкара, status='done') легли
|
||||||
|
в БД без ban_kinds: перепись писалась в counters только на ветке бана.
|
||||||
|
"""
|
||||||
|
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
|
||||||
|
fetches += [MagicMock() for _ in range(10)]
|
||||||
|
|
||||||
|
_, runs = await _run_avito(fetches, snapshot_size=30)
|
||||||
|
|
||||||
|
written = runs.mark_backfill_finished.call_args.args[2]
|
||||||
|
assert written.get("ban_kinds") == {BAN_KIND_INFRA: 20}, (
|
||||||
|
f"counters={written}: прогон без обрыва уходит в mark_done с этими counters — "
|
||||||
|
"без переписи диагноз отказов теряется"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_real_platform_bans_still_abort_by_ratio() -> None:
|
async def test_real_platform_bans_still_abort_by_ratio() -> None:
|
||||||
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
|
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
|
||||||
|
|
@ -371,3 +390,113 @@ def test_platform_dominant_run_is_still_banned() -> None:
|
||||||
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
|
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
|
||||||
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
|
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3439: серия отказов НАШЕГО тракта рвёт прогон своим критерием ─────────────
|
||||||
|
#
|
||||||
|
# Прогоны 7193/7264/7302: по 1600 попыток, 1-2 карточки, ~1599 отказов сайдкара
|
||||||
|
# (InvalidIP-шторм на узле), status='done'. Infra ушёл из доли (#3288), а своего
|
||||||
|
# предохранителя не получил. Порог — max_consecutive_failures (дефолт 25): тот же
|
||||||
|
# разряд «отказ нашей стороны», что и отказы-не-блоки.
|
||||||
|
|
||||||
|
_INFRA_LIMIT = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _infra_breaker() -> BlockRatioBreaker:
|
||||||
|
return BlockRatioBreaker(
|
||||||
|
window_size=_WINDOW,
|
||||||
|
ratio_threshold=_THRESHOLD,
|
||||||
|
safety_min=5,
|
||||||
|
snapshot_size=1600,
|
||||||
|
infra_streak_limit=_INFRA_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_infra_streak_aborts_exactly_at_limit() -> None:
|
||||||
|
breaker = _infra_breaker()
|
||||||
|
breaker.record_success()
|
||||||
|
for _ in range(_INFRA_LIMIT - 1):
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
assert breaker.abort_reason() is None, "24 отказа подряд — ещё не порог 25"
|
||||||
|
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
assert breaker.abort_reason() == "infra_streak", (
|
||||||
|
f"abort_reason={breaker.abort_reason()!r} на 25-м отказе сайдкара подряд"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("answer", ["success", "platform", "gone"])
|
||||||
|
def test_any_platform_answer_resets_infra_streak(answer: str) -> None:
|
||||||
|
"""Площадка ответила (успех / её блок / 404) — тракт жив, серия с нуля."""
|
||||||
|
breaker = _infra_breaker()
|
||||||
|
for _ in range(_INFRA_LIMIT - 1):
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
{
|
||||||
|
"success": breaker.record_success,
|
||||||
|
"platform": lambda: breaker.record_block(BAN_KIND_PLATFORM),
|
||||||
|
"gone": breaker.record_neutral,
|
||||||
|
}[answer]()
|
||||||
|
for _ in range(_INFRA_LIMIT - 1):
|
||||||
|
breaker.record_block(BAN_KIND_INFRA)
|
||||||
|
|
||||||
|
assert breaker.abort_reason() is None, (
|
||||||
|
f"abort_reason={breaker.abort_reason()!r}: 24 + ответ площадки ({answer}) + 24 — "
|
||||||
|
"серии в 25 подряд не было"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sidecar_storm_stops_run_as_failed_not_done() -> None:
|
||||||
|
"""Шторм сайдкара: 1 успех, дальше только отказы — обрыв на 25-м, 'failed'."""
|
||||||
|
fetches: list[Any] = [MagicMock()]
|
||||||
|
fetches += [AvitoSidecarUnavailableError("browser unavailable 503") for _ in range(1599)]
|
||||||
|
|
||||||
|
counters, runs = await _run_avito(fetches, snapshot_size=1600)
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=1 + _INFRA_LIMIT)
|
||||||
|
assert runs.mark_backfill_finished.call_args is None, (
|
||||||
|
"прогон ушёл в backfill-honest-status: там 1 карточка превращает шторм в 'done'"
|
||||||
|
)
|
||||||
|
failed = runs.mark_failed.call_args
|
||||||
|
assert failed is not None, "прогон не финализирован mark_failed"
|
||||||
|
written = failed.args[3]
|
||||||
|
assert (written["abort_reason"], written["ban_kinds"]) == (
|
||||||
|
"infra_streak",
|
||||||
|
{BAN_KIND_INFRA: _INFRA_LIMIT},
|
||||||
|
), f"counters={written}"
|
||||||
|
assert "тракт недоступен" in failed.args[2], f"причина={failed.args[2]!r}"
|
||||||
|
assert runs.mark_banned.call_args is None, "площадка не отказывала — не 'banned'"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interleaved_platform_answers_do_not_trip_infra_stop() -> None:
|
||||||
|
"""Контроль: 24 отказа сайдкара + ответ площадки, трижды — снапшот пройден целиком."""
|
||||||
|
fetches: list[Any] = []
|
||||||
|
for answer in (AvitoBlockedError("firewall"), MagicMock(), MagicMock()):
|
||||||
|
fetches += [AvitoSidecarUnavailableError("503") for _ in range(_INFRA_LIMIT - 1)]
|
||||||
|
fetches.append(answer)
|
||||||
|
|
||||||
|
counters, runs = await _run_avito(fetches, snapshot_size=len(fetches))
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=len(fetches))
|
||||||
|
finished = runs.mark_backfill_finished.call_args
|
||||||
|
assert finished is not None and finished.args[2].get("abort_reason") is None, (
|
||||||
|
f"прогон оборван: counters={finished and finished.args[2]}, "
|
||||||
|
f"mark_failed={runs.mark_failed.call_args}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quarter_platform_blocks_run_is_not_aborted() -> None:
|
||||||
|
"""Контроль: здоровый прогон с 25% настоящих блоков не рвётся ни одним критерием."""
|
||||||
|
fetches: list[Any] = []
|
||||||
|
for _ in range(10):
|
||||||
|
fetches += [MagicMock(), MagicMock(), MagicMock(), AvitoBlockedError("firewall")]
|
||||||
|
|
||||||
|
counters, runs = await _run_avito(fetches, snapshot_size=40)
|
||||||
|
|
||||||
|
_assert_identity(counters, expected_attempted=40)
|
||||||
|
finished = runs.mark_backfill_finished.call_args
|
||||||
|
assert finished is not None and finished.args[2].get("abort_reason") is None, (
|
||||||
|
f"counters={finished and finished.args[2]}: 10 блоков из 40 — рвать не по чему"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""Миграция 310 (#3385): yandex-посев на порядок от следующей точки удаляется, остальное нет.
|
||||||
|
|
||||||
|
Прогон НАСТОЯЩЕГО файла миграции на живом Postgres. Реальную таблицу не трогаем:
|
||||||
|
временная offer_price_history той же формы затеняет её в этой сессии (pg_temp первым
|
||||||
|
в search_path), а миграция обращается к таблице без схемы. Без БД — skip (в CI Trade-In
|
||||||
|
есть postgres-сервис).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_MIGRATION = (
|
||||||
|
Path(__file__).resolve().parents[1] / "data" / "sql" / "310_yandex_seed_decimal_slips.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
_T0 = datetime(2026, 7, 4, 16, 48, 35, 71486, tzinfo=UTC)
|
||||||
|
_DAY = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_conn() -> Any | None:
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
return psycopg.connect(
|
||||||
|
dsn.replace("postgresql+psycopg://", "postgresql://"),
|
||||||
|
autocommit=True,
|
||||||
|
connect_timeout=3,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn() -> Any:
|
||||||
|
c = _live_conn()
|
||||||
|
if c is None:
|
||||||
|
pytest.skip("no reachable Postgres test DB")
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TEMP TABLE offer_price_history (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
listing_id bigint NOT NULL,
|
||||||
|
change_time timestamptz NOT NULL,
|
||||||
|
price_rub numeric NOT NULL,
|
||||||
|
diff_percent numeric(8, 2),
|
||||||
|
source text,
|
||||||
|
recorded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield c
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _add(
|
||||||
|
c: Any,
|
||||||
|
listing_id: int,
|
||||||
|
change_time: datetime,
|
||||||
|
price: int,
|
||||||
|
*,
|
||||||
|
recorded_at: datetime,
|
||||||
|
source: str = "yandex",
|
||||||
|
) -> None:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO offer_price_history (listing_id, change_time, price_rub, source, recorded_at) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(listing_id, change_time, price, source, recorded_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_pair(
|
||||||
|
c: Any, listing_id: int, previous: int, current: int, *, source: str = "yandex"
|
||||||
|
) -> None:
|
||||||
|
"""Ровно так пишет посев yandex_price_history: now−24ч и now, recorded_at один на батч."""
|
||||||
|
recorded = _T0 + _DAY + timedelta(microseconds=1404)
|
||||||
|
_add(c, listing_id, _T0, previous, recorded_at=recorded, source=source)
|
||||||
|
_add(c, listing_id, _T0 + _DAY, current, recorded_at=recorded, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(c: Any) -> list[tuple[int, int]]:
|
||||||
|
return [
|
||||||
|
(int(lid), int(price))
|
||||||
|
for lid, price in c.execute(
|
||||||
|
"SELECT listing_id, price_rub FROM offer_price_history "
|
||||||
|
"ORDER BY listing_id, change_time, id"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(c: Any) -> None:
|
||||||
|
c.execute(_MIGRATION.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_deletes_only_seed_off_by_order_and_is_idempotent(conn: Any) -> None:
|
||||||
|
_seed_pair(conn, 1, 1_370_000, 13_700_000) # прод 3380975: ×10
|
||||||
|
later = _T0 + 20 * _DAY
|
||||||
|
_add(conn, 1, later, 13_000_000, recorded_at=later) # триггерная строка того же объявления
|
||||||
|
_seed_pair(conn, 2, 10_900, 10_900_000) # прод 1879742: ×1000
|
||||||
|
_seed_pair(conn, 3, 13_700_000, 1_370_000) # ÷10
|
||||||
|
_seed_pair(conn, 4, 8_225_550, 17_500_000) # прод 10359546: ×2.13, честный посев
|
||||||
|
_seed_pair(conn, 5, 1_450_000, 13_700_000) # ×9.45 — ниже порога
|
||||||
|
_seed_pair(conn, 6, 1_370_000, 13_700_000, source="cian") # не yandex
|
||||||
|
# ×10, но триггерные строки (change_time = recorded_at) — это наблюдения, не посев.
|
||||||
|
_add(conn, 7, _T0, 1_000_000, recorded_at=_T0)
|
||||||
|
_add(conn, 7, _T0 + _DAY, 10_000_000, recorded_at=_T0 + _DAY)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
(1, 13_700_000),
|
||||||
|
(1, 13_000_000),
|
||||||
|
(2, 10_900_000),
|
||||||
|
(3, 1_370_000),
|
||||||
|
(4, 8_225_550),
|
||||||
|
(4, 17_500_000),
|
||||||
|
(5, 1_450_000),
|
||||||
|
(5, 13_700_000),
|
||||||
|
(6, 1_370_000),
|
||||||
|
(6, 13_700_000),
|
||||||
|
(7, 1_000_000),
|
||||||
|
(7, 10_000_000),
|
||||||
|
]
|
||||||
|
_migrate(conn)
|
||||||
|
assert _rows(conn) == expected
|
||||||
|
_migrate(conn)
|
||||||
|
assert _rows(conn) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_stops_and_rolls_back_when_too_many(conn: Any) -> None:
|
||||||
|
for listing_id in range(1, 32):
|
||||||
|
_seed_pair(conn, listing_id, 1_000_000, 10_000_000)
|
||||||
|
before = _rows(conn)
|
||||||
|
assert len(before) == 62
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="миграция остановлена"):
|
||||||
|
_migrate(conn)
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
assert _rows(conn) == before
|
||||||
|
|
@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv(
|
||||||
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
||||||
# оставило бы тест зелёным против фантома.
|
# оставило бы тест зелёным против фантома.
|
||||||
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
|
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
|
||||||
patch("curl_cffi.requests.AsyncSession", _no_http),
|
patch("scraper_kit.providers._base.AsyncSession", _no_http),
|
||||||
):
|
):
|
||||||
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
|
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ def _run_with_pool(
|
||||||
with (
|
with (
|
||||||
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
||||||
patch.object(estimator, "save_imv_evaluation", return_value=1),
|
patch.object(estimator, "save_imv_evaluation", return_value=1),
|
||||||
patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session),
|
patch("scraper_kit.providers._base.AsyncSession", lambda *a, **kw: session),
|
||||||
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
|
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
|
||||||
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
"""Бакет уходит в чекпоинт только если ВСЕ его фазы что-то дали (#3415).
|
||||||
|
|
||||||
|
Прод-факт. `cian_city_sweep` 6179 (06.09) — статус `failed` с причиной
|
||||||
|
«phase-honest-status: фаза 'houses' отказала полностью — 30 из 30 попыток
|
||||||
|
неудачны, обогащено 0», а `counters.done_buckets` при этом несёт ВСЕ пять якорей
|
||||||
|
(«Академический», «Пионерский», «Уралмаш», «Центр», «ЮЗ»). Следующий прогон 6276
|
||||||
|
(07.09) резюмировался от него (`resume_from: 6179`, `resume_buckets: 5`),
|
||||||
|
пропустил все якоря и отчитался `done` с `errors_count=0`, `houses_attempted=0`,
|
||||||
|
`lots_fetched=0` — зелёный по построению: фаза не исполнялась. Из-за этого фикс
|
||||||
|
#3396 (houses через пул прокси) простоял на проде с 06.09 и ни разу не отработал.
|
||||||
|
|
||||||
|
ИНВАРИАНТ. Отказ ЦЕЛОЙ фазы внутри якоря — это «якорь пройден не был», ровно как
|
||||||
|
исключение или таймаут (#3074/#3319). Разница только в том, что отказ фазы виден
|
||||||
|
не потоком управления, а бухгалтерией счётчиков: `houses_attempted` попыток,
|
||||||
|
`houses_failed` отказов, обогащено ноль. Порог здесь — одна попытка, а не три,
|
||||||
|
как у run-level `_phase_totally_failed` (#2700): тот СТАВИТ ДИАГНОЗ прогону и
|
||||||
|
обязан отсеивать шум, а этот решает «собирать ли якорь заново», и цена ошибок
|
||||||
|
несимметрична — лишний повтор якоря стоит нескольких запросов, а ложная отметка
|
||||||
|
«пройден» теряет часть города навсегда и молча.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Settings собирается автофикстурой conftest'а и требует database_url.
|
||||||
|
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
|
||||||
|
|
||||||
|
ANCHOR_A = (56.83, 60.60, "ekb-center")
|
||||||
|
ANCHOR_B = (56.79, 60.63, "ekb-south")
|
||||||
|
|
||||||
|
# Два дома на якорь — НИЖЕ порога run-level правила (_PHASE_MIN_ATTEMPTS = 3):
|
||||||
|
# гейт бакета обязан сработать на своей, более строгой границе.
|
||||||
|
HOUSE_ROWS = [
|
||||||
|
{"id": 101, "cian_zhk_url": "https://cian.ru/zhk/101"},
|
||||||
|
{"id": 102, "cian_zhk_url": "https://cian.ru/zhk/102"},
|
||||||
|
]
|
||||||
|
DETAIL_ROWS = [
|
||||||
|
{"id": 201, "source_url": "/ekb/kvartiry/201"},
|
||||||
|
{"id": 202, "source_url": "/ekb/kvartiry/202"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
prev_counters: dict[str, Any] | None = None,
|
||||||
|
house_rows: list[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.prev_counters = prev_counters or {}
|
||||||
|
# [] — запрос домов падает (ветка «houses DB query failed»).
|
||||||
|
self.house_rows = HOUSE_ROWS if house_rows is None else house_rows
|
||||||
|
self.heartbeats: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
if params and "counters" in params:
|
||||||
|
self.heartbeats.append(json.loads(params["counters"]))
|
||||||
|
return MagicMock()
|
||||||
|
if params and "rid" in params:
|
||||||
|
return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters))
|
||||||
|
if params and "ids" in params:
|
||||||
|
# houses-фаза: дома, у которых есть cian_zhk_url.
|
||||||
|
if not self.house_rows:
|
||||||
|
raise RuntimeError("houses DB query failed")
|
||||||
|
return MagicMock(mappings=lambda: MagicMock(all=lambda: self.house_rows))
|
||||||
|
if params and "limit" in params:
|
||||||
|
# detail-фаза avito: карточки-кандидаты на обогащение.
|
||||||
|
return MagicMock(mappings=lambda: MagicMock(all=lambda: DETAIL_ROWS))
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _lot() -> types.SimpleNamespace:
|
||||||
|
"""Новостроечный лот с привязкой к ЖК — только такой доходит до houses-фазы."""
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
listing_segment="novostroyki",
|
||||||
|
house_source="cian_newbuilding",
|
||||||
|
house_ext_id="nb-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScraper:
|
||||||
|
"""Двойник CianScraper: помнит, за какими якорями реально ходили."""
|
||||||
|
|
||||||
|
visited: list[str] = [] # noqa: RUF012 — тестовый сборник
|
||||||
|
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None:
|
||||||
|
self.state_extraction_attempts = 1
|
||||||
|
self.state_extraction_failures = 0
|
||||||
|
self.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeScraper:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_e: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_around_multi_room(
|
||||||
|
self, lat: float, lon: float, *_a: Any, **_kw: Any
|
||||||
|
) -> list[types.SimpleNamespace]:
|
||||||
|
_FakeScraper.visited.append(f"{lat},{lon}")
|
||||||
|
return [_lot()]
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> types.SimpleNamespace:
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
scraper_proxy_url=None,
|
||||||
|
scraper_fetch_mode="cffi",
|
||||||
|
use_proxy_pool_browser=False,
|
||||||
|
browser_http_endpoint=None,
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
prev: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
houses_ok: bool,
|
||||||
|
run_id: int = 8401,
|
||||||
|
house_rows: list[dict[str, Any]] | None = None,
|
||||||
|
failing_urls: frozenset[str] | None = None,
|
||||||
|
) -> tuple[_FakeDb, Any]:
|
||||||
|
from scraper_kit.orchestration import pipeline as pl
|
||||||
|
|
||||||
|
_FakeScraper.visited = []
|
||||||
|
db = _FakeDb(prev, house_rows)
|
||||||
|
|
||||||
|
async def _fake_newbuilding(zhk_url: str, *_a: Any, **_kw: Any) -> Any:
|
||||||
|
# None — ровно тот отказ, что был на проде 06.09: исключения нет,
|
||||||
|
# счётчик houses_failed растёт, фаза возвращается штатно.
|
||||||
|
if failing_urls is not None:
|
||||||
|
return None if zhk_url in failing_urls else object()
|
||||||
|
return object() if houses_ok else None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(pl, "CianScraper", _FakeScraper),
|
||||||
|
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
|
||||||
|
patch.object(pl, "fetch_newbuilding", _fake_newbuilding),
|
||||||
|
patch.object(pl, "save_newbuilding_enrichment", lambda *_a, **_kw: None),
|
||||||
|
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
|
||||||
|
):
|
||||||
|
counters = await pl.run_cian_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
run_id=run_id,
|
||||||
|
config=_config(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
anchors=[ANCHOR_A, ANCHOR_B],
|
||||||
|
enrich_houses=True,
|
||||||
|
detail_top_n=0,
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
resume_run_id=(run_id - 1) if prev is not None else None,
|
||||||
|
)
|
||||||
|
return db, counters
|
||||||
|
|
||||||
|
|
||||||
|
def _last_checkpoint(db: _FakeDb) -> list[str]:
|
||||||
|
with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb]
|
||||||
|
assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится"
|
||||||
|
return with_ckpt[-1]["done_buckets"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anchor_with_totally_failed_houses_is_not_checkpointed() -> None:
|
||||||
|
"""(а) Якорь, где houses отказала на ВСЕХ домах, не попадает в чекпоинт."""
|
||||||
|
db, counters = await _run(None, houses_ok=False)
|
||||||
|
assert counters.houses_attempted > 0, "houses-фаза вообще не исполнялась — тест не о том"
|
||||||
|
assert counters.houses_enriched == 0, "houses-фаза что-то обогатила — отказ не полный"
|
||||||
|
assert _last_checkpoint(db) == [], (
|
||||||
|
"якорь с полностью отказавшей houses-фазой помечен пройденным — "
|
||||||
|
f"чекпоинт {_last_checkpoint(db)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_next_run_re_enters_houses() -> None:
|
||||||
|
"""(б) Следующий прогон с этим чекпоинтом снова заходит в houses."""
|
||||||
|
db1, _ = await _run(None, houses_ok=False)
|
||||||
|
_db2, counters2 = await _run(
|
||||||
|
{"done_buckets": _last_checkpoint(db1)}, houses_ok=True, run_id=8402
|
||||||
|
)
|
||||||
|
assert counters2.houses_attempted > 0, (
|
||||||
|
"прогон-наследник пропустил houses-фазу: чекпоинт предшественника "
|
||||||
|
"объявил якоря пройденными, хотя фаза в них не дала ничего"
|
||||||
|
)
|
||||||
|
assert counters2.houses_enriched > 0, "houses-фаза исполнилась, но ничего не обогатила"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_healthy_anchor_is_still_checkpointed() -> None:
|
||||||
|
"""(в) Контроль: якорь, где все фазы отработали, помечается как раньше."""
|
||||||
|
db, counters = await _run(None, houses_ok=True)
|
||||||
|
assert counters.houses_enriched > 0
|
||||||
|
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
|
||||||
|
"перестали помечать пройденные якоря вовсе — резюм (#3074) сломан"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anchor_with_partially_failed_houses_is_still_checkpointed() -> None:
|
||||||
|
"""Контроль жадности: один отказавший дом из двух — якорь пройден.
|
||||||
|
|
||||||
|
Гейт про ПОЛНЫЙ отказ фазы. Жадный гейт (любой отказ) собирал бы такой якорь
|
||||||
|
заново каждым прогоном — на проде houses почти всегда теряет пару домов.
|
||||||
|
"""
|
||||||
|
db, counters = await _run(
|
||||||
|
None, houses_ok=True, failing_urls=frozenset({HOUSE_ROWS[0]["cian_zhk_url"]})
|
||||||
|
)
|
||||||
|
assert (counters.houses_attempted, counters.houses_failed) == (4, 2)
|
||||||
|
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
|
||||||
|
f"якорь с частичным отказом houses не помечен пройденным: {_last_checkpoint(db)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_failed_attempt_blocks_checkpoint() -> None:
|
||||||
|
"""Порог — одна попытка: единственный дом якоря отказал — якорь не пройден."""
|
||||||
|
db, counters = await _run(None, houses_ok=False, house_rows=HOUSE_ROWS[:1])
|
||||||
|
assert (counters.houses_attempted, counters.houses_enriched) == (2, 0)
|
||||||
|
assert _last_checkpoint(db) == [], (
|
||||||
|
f"якорь с 1 из 1 отказавшим домом помечен пройденным: {_last_checkpoint(db)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_houses_db_query_failure_blocks_checkpoint() -> None:
|
||||||
|
"""Упал запрос домов: попытки считаются вместе с отказами, якорь не пройден."""
|
||||||
|
db, counters = await _run(None, houses_ok=True, house_rows=[])
|
||||||
|
assert _last_checkpoint(db) == [], (
|
||||||
|
f"якорь с упавшим запросом домов помечен пройденным: {_last_checkpoint(db)}"
|
||||||
|
)
|
||||||
|
assert (counters.houses_attempted, counters.houses_failed) == (2, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAvitoScraper:
|
||||||
|
"""Двойник AvitoScraper: SERP отдаёт лоты, дальше работает detail-фаза."""
|
||||||
|
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None:
|
||||||
|
self._browser = None
|
||||||
|
self._cffi = None
|
||||||
|
|
||||||
|
async def fetch_around(self, *_a: Any, **_kw: Any) -> list[types.SimpleNamespace]:
|
||||||
|
return [types.SimpleNamespace(house_url=None)]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncSession:
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeAsyncSession:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_e: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_avito_anchor_with_totally_failed_detail_is_not_checkpointed() -> None:
|
||||||
|
"""Сосед: у avito-свипа тот же гейт — фаза без единой удачи не даёт отметки.
|
||||||
|
|
||||||
|
Кода общего у свипов нет (две отдельные функции), общий только гейт. Прод-следа
|
||||||
|
у avito за 45 суток нет (0 прогонов против 4 у циана) — тест сторожит вторую
|
||||||
|
копию проводки, а не найденный в данных дефект.
|
||||||
|
"""
|
||||||
|
from scraper_kit.orchestration import pipeline as pl
|
||||||
|
|
||||||
|
db = _FakeDb()
|
||||||
|
|
||||||
|
async def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise RuntimeError("detail отдал 403")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(pl, "AvitoScraper", _FakeAvitoScraper),
|
||||||
|
patch.object(pl, "AsyncSession", _FakeAsyncSession),
|
||||||
|
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
|
||||||
|
patch.object(pl, "fetch_detail", _boom),
|
||||||
|
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
|
||||||
|
):
|
||||||
|
counters = await pl.run_avito_city_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
run_id=8404,
|
||||||
|
config=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,
|
||||||
|
),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
enrichment=MagicMock(),
|
||||||
|
anchors=[ANCHOR_A],
|
||||||
|
enrich_houses=False,
|
||||||
|
enrich_imv=False,
|
||||||
|
detail_top_n=len(DETAIL_ROWS),
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert counters.detail_attempted == len(DETAIL_ROWS)
|
||||||
|
assert counters.detail_enriched == 0
|
||||||
|
assert _last_checkpoint(db) == [], (
|
||||||
|
f"якорь с полностью отказавшей detail-фазой помечен пройденным: {_last_checkpoint(db)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_old_format_checkpoint_still_resumes() -> None:
|
||||||
|
"""(г) Чекпоинт СТАРОГО формата (плоский список имён якорей) читается как раньше."""
|
||||||
|
db, _ = await _run({"done_buckets": ["ekb-center"]}, houses_ok=True, run_id=8403)
|
||||||
|
assert f"{ANCHOR_A[0]},{ANCHOR_A[1]}" not in _FakeScraper.visited, (
|
||||||
|
"якорь из унаследованного чекпоинта всё-таки опрашивали"
|
||||||
|
)
|
||||||
|
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
|
||||||
|
"унаследованный якорь потерян — формат чекпоинта разъехался со старыми прогонами"
|
||||||
|
)
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют.
|
"""#3452: зона n = 1..9 — коридор ДКП виден, но цену по нему не поправляют.
|
||||||
|
|
||||||
Два порога на одну выборку. Показ коридора открывается с трёх сделок
|
Два порога на одну выборку. Показ коридора открывается с одной сделки (ниже
|
||||||
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
`DKP_CORRIDOR_CITY_WIDE_MIN_N` вне Екатеринбурга пробуется city-wide widen, но
|
||||||
|
уличная выборка из 1-2 сделок отдаётся и без него, #3466), а обе ценовые страховки
|
||||||
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||||
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор
|
||||||
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||||
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
||||||
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
||||||
|
|
@ -34,8 +35,8 @@ import pytest
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import CORRIDOR_CLAMP_MIN_N
|
||||||
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
|
from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||||
|
|
||||||
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
||||||
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
||||||
|
|
@ -46,7 +47,7 @@ _MARKER = "corridor_advisory_zone"
|
||||||
|
|
||||||
def _cap_ppm2() -> float:
|
def _cap_ppm2() -> float:
|
||||||
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
||||||
return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack)
|
return _CORRIDOR_HIGH_PPM2 * (1.0 + CORRIDOR_CLAMP_SLACK)
|
||||||
|
|
||||||
|
|
||||||
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||||||
|
|
@ -157,7 +158,7 @@ def test_zone_exists_at_all() -> None:
|
||||||
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
|
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
|
||||||
а не оставит два теста ниже молча проверять пустое множество.
|
а не оставит два теста ниже молча проверять пустое множество.
|
||||||
"""
|
"""
|
||||||
assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
assert CORRIDOR_CLAMP_MIN_N > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
||||||
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
||||||
"advisory-only решение #3452 больше не описывает реальность"
|
"advisory-only решение #3452 больше не описывает реальность"
|
||||||
)
|
)
|
||||||
|
|
@ -167,15 +168,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
||||||
n = settings.estimate_corridor_clamp_min_n - 1
|
n = CORRIDOR_CLAMP_MIN_N - 1
|
||||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
est = _run_estimate(_corridor(n))
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
||||||
assert est.dkp_corridor.count == n
|
assert est.dkp_corridor.count == n
|
||||||
assert est.dkp_corridor.advisory_only is True, (
|
assert est.dkp_corridor.advisory_only is True, (
|
||||||
f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, "
|
f"n={n} < порога {CORRIDOR_CLAMP_MIN_N} — коридор справочный, ответ обязан это называть"
|
||||||
"ответ обязан это называть"
|
|
||||||
)
|
)
|
||||||
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
||||||
assert est.median_price_per_m2 > _cap_ppm2(), (
|
assert est.median_price_per_m2 > _cap_ppm2(), (
|
||||||
|
|
@ -185,7 +185,7 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
||||||
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
||||||
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
||||||
msg = hits[0].getMessage()
|
msg = hits[0].getMessage()
|
||||||
assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg
|
assert f"n={n}" in msg and f"min_n={CORRIDOR_CLAMP_MIN_N}" in msg, msg
|
||||||
assert "scope=street" in msg, msg
|
assert "scope=street" in msg, msg
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -193,13 +193,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
||||||
n = settings.estimate_corridor_clamp_min_n + 2
|
n = CORRIDOR_CLAMP_MIN_N + 2
|
||||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
est = _run_estimate(_corridor(n))
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
assert est.dkp_corridor is not None
|
assert est.dkp_corridor is not None
|
||||||
assert est.dkp_corridor.advisory_only is False, (
|
assert est.dkp_corridor.advisory_only is False, (
|
||||||
f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, "
|
f"n={n} >= порога {CORRIDOR_CLAMP_MIN_N} — коридор в цене участвует, "
|
||||||
"справочным его называть нельзя"
|
"справочным его называть нельзя"
|
||||||
)
|
)
|
||||||
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
||||||
|
|
|
||||||
176
tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
Normal file
176
tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""#3466: при якоре Tier A коридор ДКП в цену не входит — ответ обязан это нести.
|
||||||
|
|
||||||
|
`DkpCorridor.advisory_only` (#3452) считается из размера выборки, а кламп headline
|
||||||
|
выключается ещё и якорем того же дома (`_apply_corridor_clamp`: Tier A exempt;
|
||||||
|
radius-floor требует `anchor_tier is None`). Ревьюер PR #3462 воспроизвёл: n=20,
|
||||||
|
advisory_only=False, headline 202 100 ₽/м² против потолка 140 000 — поле молчит, а
|
||||||
|
коридор цену не тронул.
|
||||||
|
|
||||||
|
Признак — `analog_tier == "same_building"`, уже структурный в POST-ответе. На
|
||||||
|
GET-rehydrate колонки под него нет, тир восстанавливается из подписи якорного
|
||||||
|
блока (`analog_tier_from_explanation`). Тесты ПО ЗНАЧЕНИЮ:
|
||||||
|
|
||||||
|
* цена Tier A не зависит от коридора вовсе — снятие exempt в клампе роняет тест;
|
||||||
|
* тот же коридор на радиусном пути цену прижимает — иначе первый тест пуст;
|
||||||
|
* подпись, которую пишет якорный блок, читается обратно в тот же тир, а соседняя
|
||||||
|
подпись радиусного тира S («аналоги из того же дома») — нет;
|
||||||
|
* полоса счёта маркера `corridor_advisory_zone` — n = 1..9, а не 3..9: уличный
|
||||||
|
коридор отдаётся с одной сделки (widen только вне Екатеринбурга).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import estimator
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
# Коридор выше порога доверия, потолок заведомо ниже цены якоря: кламп, если ему
|
||||||
|
# позволить, ОБЯЗАН прижать headline к cap = 100 000 × (1 + slack).
|
||||||
|
_CORRIDOR: dict[str, Any] = {
|
||||||
|
"count": 20,
|
||||||
|
"low_ppm2": 80_000,
|
||||||
|
"median_ppm2": 90_000,
|
||||||
|
"high_ppm2": 100_000,
|
||||||
|
"period_months": 12,
|
||||||
|
"latest_deal_date": None,
|
||||||
|
"scope": "street",
|
||||||
|
}
|
||||||
|
_ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _cap() -> float:
|
||||||
|
return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(
|
||||||
|
*,
|
||||||
|
listings_ppm2: tuple[float, ...],
|
||||||
|
anchor_tier_fetched: str | None,
|
||||||
|
dkp_raw: dict[str, Any] | None,
|
||||||
|
analog_tier: str = "W",
|
||||||
|
) -> estimator.PricingResult:
|
||||||
|
return estimator._price_from_inputs(
|
||||||
|
listings=[
|
||||||
|
{"price_per_m2": p, "address": f"ул. Тестовая, {i}", "source": "avito"}
|
||||||
|
for i, p in enumerate(listings_ppm2)
|
||||||
|
],
|
||||||
|
area_m2=50.0,
|
||||||
|
rooms=2,
|
||||||
|
repair_state=None,
|
||||||
|
floor=5,
|
||||||
|
total_floors=10,
|
||||||
|
target_year=None,
|
||||||
|
analog_tier=analog_tier,
|
||||||
|
fallback_used=False,
|
||||||
|
area_widened=False,
|
||||||
|
anchor_comps=(
|
||||||
|
[{"price_per_m2": p, "area_m2": 50.0, "rooms": 2} for p in _ANCHOR_PPM2]
|
||||||
|
if anchor_tier_fetched
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
anchor_tier_fetched=anchor_tier_fetched,
|
||||||
|
dkp_raw=dkp_raw,
|
||||||
|
imv_anchor=None,
|
||||||
|
imv_eval=None,
|
||||||
|
yandex_val_present=False,
|
||||||
|
cian_val_present=False,
|
||||||
|
ratio_resolver=lambda _appm2: (None, None),
|
||||||
|
quarter_index_lookup=lambda _q: None,
|
||||||
|
quarter_indexes_lookup=lambda _qs: {},
|
||||||
|
target_house_cadnum=None,
|
||||||
|
dadata_coarse=False,
|
||||||
|
geo=GeocodeResult(
|
||||||
|
lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim"
|
||||||
|
),
|
||||||
|
dadata_qc_geo=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _price(pr: estimator.PricingResult) -> tuple[float, int, int, int]:
|
||||||
|
return (pr.median_ppm2, pr.median_price, pr.range_low, pr.range_high)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_price_does_not_depend_on_corridor() -> None:
|
||||||
|
"""Ядро признака: при same_building коридор не двигает НИ ОДНО ценовое число."""
|
||||||
|
listings = (100_000.0,) * 6
|
||||||
|
with_corridor = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=_CORRIDOR)
|
||||||
|
without = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=None)
|
||||||
|
|
||||||
|
assert with_corridor.anchor_tier == "A", "якорь не сработал — тест проверял бы радиусный путь"
|
||||||
|
# Предпосылка — на цене БЕЗ коридора: она выше потолка, т.е. клампу было что резать.
|
||||||
|
assert without.median_ppm2 > _cap(), (
|
||||||
|
f"цена якоря {without.median_ppm2:.0f} не выше cap {_cap():.0f} — "
|
||||||
|
"кламп не понадобился бы и без exempt, случай #3466 не воспроизведён"
|
||||||
|
)
|
||||||
|
assert _price(with_corridor) == _price(without), (
|
||||||
|
"коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак "
|
||||||
|
f"same_building на витрине врёт: {_price(with_corridor)} против {_price(without)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_corridor_clamps_radius_path() -> None:
|
||||||
|
"""Контроль: тот же коридор без якоря прижимает цену — первый тест не пустой."""
|
||||||
|
pr = _call(
|
||||||
|
listings_ppm2=(195_000.0, 200_000.0, 200_000.0, 205_000.0, 210_000.0, 190_000.0),
|
||||||
|
anchor_tier_fetched=None,
|
||||||
|
dkp_raw=_CORRIDOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
assert round(pr.median_ppm2) == round(_cap()), (
|
||||||
|
f"без якоря коридор n=20 обязан прижать headline к cap={_cap():.0f}, "
|
||||||
|
f"получено {pr.median_ppm2:.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_sentence_reads_back_to_tier() -> None:
|
||||||
|
"""Подпись якорного блока → тот же enum, что POST кладёт в analog_tier."""
|
||||||
|
tier_a = _call(listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched="A", dkp_raw=None)
|
||||||
|
tier_c = _call(listings_ppm2=(200_000.0,) * 6, anchor_tier_fetched="C", dkp_raw=None)
|
||||||
|
assert (tier_a.anchor_tier, tier_c.anchor_tier) == ("A", "C")
|
||||||
|
|
||||||
|
assert estimator.analog_tier_from_explanation(tier_a.explanation) == "same_building"
|
||||||
|
assert estimator.analog_tier_from_explanation(tier_c.explanation) == "micro_radius"
|
||||||
|
# #conf-cap ставит свою фразу ПЕРЕД подписью — тир обязан читаться и тогда.
|
||||||
|
capped = "Оценка построена с расширенными параметрами подбора — точность снижена. "
|
||||||
|
assert estimator.analog_tier_from_explanation(capped + tier_a.explanation) == "same_building"
|
||||||
|
|
||||||
|
|
||||||
|
def test_radius_same_house_note_is_not_anchor() -> None:
|
||||||
|
"""Радиусный тир S пишет «(аналоги из того же дома)», но коридор там клампит."""
|
||||||
|
pr = _call(
|
||||||
|
listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched=None, dkp_raw=None, analog_tier="S"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
assert "из того же дома" in pr.explanation, pr.explanation
|
||||||
|
assert estimator.analog_tier_from_explanation(pr.explanation) is None
|
||||||
|
assert estimator.analog_tier_from_explanation(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_street_corridor_is_returned_below_three_deals() -> None:
|
||||||
|
"""Полоса маркера #3452 — n = 1..9: улица Екатеринбурга не расширяется до города."""
|
||||||
|
rows = [
|
||||||
|
{"price_per_m2": 150_000, "deal_date": None},
|
||||||
|
{"price_per_m2": 160_000, "deal_date": None},
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||||||
|
with patch.object(estimator, "_load_sber_index_series", return_value={}):
|
||||||
|
raw = estimator._fetch_dkp_corridor(
|
||||||
|
db,
|
||||||
|
address="Екатеринбург, ул. Учителей, 18",
|
||||||
|
rooms=2,
|
||||||
|
area=50.0,
|
||||||
|
city="екатеринбург",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw is not None and raw["count"] == 2, raw
|
||||||
|
assert raw["scope"] == "street"
|
||||||
|
assert raw["count"] < estimator.DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||||
|
|
@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -211,6 +212,87 @@ def test_showcase_counters_do_not_fake_freshness() -> None:
|
||||||
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3а. Пустой прогон витрины не гасит часы свежести (#3511) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordedRuns:
|
||||||
|
"""`ctx.runs`, который кладёт финал прогона строкой `_STALE_SOURCES_SQL`.
|
||||||
|
|
||||||
|
Тогда тест идёт ТЕМ ЖЕ путём, что и сводка на проде: обработчик ставит статус,
|
||||||
|
`freshness_rows` судит, принёс ли прогон данные, `stale_sources` — просрочку.
|
||||||
|
Проверка «вызван mark_failed» такой путь не проходит: она зелёная и тогда,
|
||||||
|
когда статус верный, а мера свежести его не видит.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rows: list[Any] = []
|
||||||
|
self.finished_at = NOW
|
||||||
|
|
||||||
|
def _row(self, status: str, counters: dict[str, Any]) -> None:
|
||||||
|
self.rows.append(
|
||||||
|
SimpleNamespace(
|
||||||
|
source=SOURCE,
|
||||||
|
interval_days=str(_seeded_interval_days()),
|
||||||
|
created_at=NOW - timedelta(days=30),
|
||||||
|
finished_at=self.finished_at,
|
||||||
|
status=status,
|
||||||
|
counters=counters,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||||
|
self._row("done", counters)
|
||||||
|
|
||||||
|
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||||
|
self._row("failed", counters)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_handler(runs: _RecordedRuns, counters: dict[str, int], age_days: float) -> None:
|
||||||
|
runs.finished_at = NOW - timedelta(days=age_days)
|
||||||
|
with patch(
|
||||||
|
"app.tasks.landing_showcase_deals.refresh_landing_showcase_deals",
|
||||||
|
return_value=counters,
|
||||||
|
):
|
||||||
|
await _job_landing_showcase_deals(MagicMock(), 1, {}, SimpleNamespace(runs=runs))
|
||||||
|
|
||||||
|
|
||||||
|
_FULL = {"considered": 200, "priced": 180, "eligible": 160, "written": 20}
|
||||||
|
_EMPTY = {"considered": 200, "priced": 180, "eligible": 160, "written": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_showcase_run_does_not_reset_freshness() -> None:
|
||||||
|
"""Последний непустой прогон старше 3× такта, вчерашний пустой → витрина в тревоге.
|
||||||
|
|
||||||
|
До #3511 пустой прогон завершался `done`, и `run_brought_data` (судит по
|
||||||
|
статусу: результатного ключа кита у витрины нет) засчитывал его свежестью —
|
||||||
|
опустевший блок лэндинга молчал бы сколько угодно.
|
||||||
|
"""
|
||||||
|
runs = _RecordedRuns()
|
||||||
|
await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1)
|
||||||
|
await _run_handler(runs, _EMPTY, age_days=1)
|
||||||
|
|
||||||
|
stale = _stale_now(runs.rows)
|
||||||
|
assert [s.source for s in stale] == [SOURCE], (
|
||||||
|
f"пустой прогон витрины засчитан свежестью: статусы {[r.status for r in runs.rows]}"
|
||||||
|
)
|
||||||
|
assert stale[0].age_days > _ACCEPTANCE_CYCLES * _seeded_interval_days()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nonempty_showcase_run_keeps_freshness() -> None:
|
||||||
|
"""Контроль: тот же сценарий с непустым последним прогоном — тревоги нет.
|
||||||
|
|
||||||
|
Зелёный с обеих сторон правки; без него правка могла бы валить любой прогон.
|
||||||
|
"""
|
||||||
|
runs = _RecordedRuns()
|
||||||
|
await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1)
|
||||||
|
await _run_handler(runs, _FULL, age_days=1)
|
||||||
|
|
||||||
|
assert _stale_now(runs.rows) == []
|
||||||
|
assert [r.status for r in runs.rows] == ["done", "done"]
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Проверка отмены не оставляет транзакцию открытой на сетевую фазу свипа (#3480).
|
||||||
|
|
||||||
|
Прод 17.09 07:48 UTC, pg_stat_activity БД tradein: pid 1933154 из tradein-scraper,
|
||||||
|
'idle in transaction' 1 ч 35 мин, последний запрос `SELECT status FROM scrape_runs
|
||||||
|
WHERE id = $1` (runs.is_cancelled), xact_start через 30 мс после старта
|
||||||
|
domclick_city_sweep_moskva 7344. За 14 суток 10 из 12 окон «транзакция > 1 ч»
|
||||||
|
совпали со свипами ДомКлика.
|
||||||
|
|
||||||
|
Сессия настоящая (SQLAlchemy + SQLite): значение `in_transaction()` — то же, что видит
|
||||||
|
Postgres как 'idle in transaction'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.orchestration import pipeline as pl
|
||||||
|
from scraper_kit.orchestration import runs
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> Session:
|
||||||
|
db = Session(create_engine("sqlite://"))
|
||||||
|
db.execute(text("CREATE TABLE scrape_runs (id INTEGER, status TEXT, counters TEXT)"))
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO scrape_runs VALUES "
|
||||||
|
"(7333, 'done', NULL), (7344, 'running', NULL), (7345, 'cancelled', NULL)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_cancelled_closes_its_transaction() -> None:
|
||||||
|
db = _session()
|
||||||
|
|
||||||
|
assert runs.is_cancelled(db, 7344) is False
|
||||||
|
assert db.in_transaction() is False
|
||||||
|
assert runs.is_cancelled(db, 7345) is True
|
||||||
|
assert db.in_transaction() is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_domclick_sweep_http_phase_runs_outside_transaction() -> None:
|
||||||
|
db = _session()
|
||||||
|
seen: list[bool] = []
|
||||||
|
|
||||||
|
class _Scraper:
|
||||||
|
blocked = False
|
||||||
|
geo_filtered = fetch_errors = buckets_completed = buckets_total = 0
|
||||||
|
bucket_start_index = 0
|
||||||
|
completed_buckets: list[str] = [] # noqa: RUF012
|
||||||
|
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Scraper:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_e: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_city(self, **_kw: Any) -> list[Any]:
|
||||||
|
seen.append(db.in_transaction()) # момент сетевого обхода
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(pl, "DomClickScraper", _Scraper),
|
||||||
|
patch.object(runs, "update_heartbeat", MagicMock()),
|
||||||
|
patch.object(runs, "mark_done", MagicMock()),
|
||||||
|
patch.object(runs, "mark_failed", MagicMock()),
|
||||||
|
patch.object(runs, "mark_banned", MagicMock()),
|
||||||
|
):
|
||||||
|
await pl.run_domclick_city_sweep(
|
||||||
|
db,
|
||||||
|
run_id=7344,
|
||||||
|
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
city_id=4,
|
||||||
|
pages=1,
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
resume_run_id=7333, # резюм-SELECT тоже открывает транзакцию
|
||||||
|
)
|
||||||
|
|
||||||
|
assert seen == [False]
|
||||||
|
|
@ -297,3 +297,17 @@ def test_load_fixture_plain_and_gzip_roundtrip() -> None:
|
||||||
m_plain = bt.replay_fixture(loaded_plain)
|
m_plain = bt.replay_fixture(loaded_plain)
|
||||||
m_gz = bt.replay_fixture(loaded_gz)
|
m_gz = bt.replay_fixture(loaded_gz)
|
||||||
assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)
|
assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_fixture_records_engine_constants(tmp_path: Path) -> None:
|
||||||
|
"""#1970: пороги движка — константы estimator.py, снимок фикстуры обязан их нести."""
|
||||||
|
est = bt._import_estimator_full()
|
||||||
|
out = tmp_path / "fixture.json"
|
||||||
|
bt._write_fixture(str(out), capture=[], since="2026-09-01", est=est)
|
||||||
|
snap = json.loads(out.read_text(encoding="utf-8"))["settings_at_capture"]
|
||||||
|
assert {
|
||||||
|
k: snap.get(k)
|
||||||
|
for k in ("CORRIDOR_CLAMP_MIN_N", "CORRIDOR_CLAMP_SLACK", "QUARTER_INDEX_MIN_N_DEALS")
|
||||||
|
} == {"CORRIDOR_CLAMP_MIN_N": 10, "CORRIDOR_CLAMP_SLACK": 0.4, "QUARTER_INDEX_MIN_N_DEALS": 10}
|
||||||
|
assert snap.get("SB_ROOMS_MATCH_BOOST") == 1.6
|
||||||
|
assert "estimate_pi_low_mult" in snap # поля Settings по-прежнему в снимке
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,8 @@ import math
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.services import estimator
|
|
||||||
from scripts.backtest_estimator import load_fixture, replay_fixture
|
from scripts.backtest_estimator import load_fixture, replay_fixture
|
||||||
|
|
||||||
_FIXTURES = Path(__file__).parent / "fixtures"
|
_FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None:
|
||||||
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
|
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
|
||||||
|
|
||||||
|
|
||||||
def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_backtest_regression_gate() -> None:
|
||||||
# The frozen fixture records the injected-callback control flow captured with
|
|
||||||
# cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time).
|
|
||||||
# Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a
|
|
||||||
# frozen OFF capture — with dedup active _dedup_cross_source would trim listings
|
|
||||||
# before quarter_indexes_lookup and the recorded call sequence would diverge. Pin
|
|
||||||
# the flag OFF so the replay follows the captured control flow. (Accuracy-neutral:
|
|
||||||
# backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs,
|
|
||||||
# so the OFF baseline stays the valid spine regression reference.)
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
|
||||||
fixture = load_fixture(_FIXTURE_PATH)
|
fixture = load_fixture(_FIXTURE_PATH)
|
||||||
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
|
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
|
||||||
# Round-trip the replay output through JSON before comparing: the committed
|
# Round-trip the replay output through JSON before comparing: the committed
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import pytest # noqa: E402
|
||||||
from fastapi import FastAPI # noqa: E402
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.services.estimator import analog_tier_from_explanation # noqa: E402
|
||||||
|
|
||||||
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
|
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -170,6 +172,9 @@ def _stub_precision_and_pdf():
|
||||||
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
||||||
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
||||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||||
|
# #3466: якорный тир из подписи в confidence_explanation. У фикстуры там
|
||||||
|
# «ok» → None и есть настоящее поведение; положительный случай — ниже.
|
||||||
|
analog_tier_from_explanation=lambda _e: None,
|
||||||
)
|
)
|
||||||
real_estimator = sys.modules.get("app.services.estimator")
|
real_estimator = sys.modules.get("app.services.estimator")
|
||||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||||
|
|
@ -273,6 +278,26 @@ def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None
|
||||||
assert body["last_scraped_at"] is not None
|
assert body["last_scraped_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_restores_anchor_tier(trade_in_app: FastAPI) -> None:
|
||||||
|
"""#3466: ссылка ?id= на оценку Tier A отдаёт analog_tier=same_building.
|
||||||
|
|
||||||
|
Без него витрина не отличает коридор, который цену не трогал (якорь того же
|
||||||
|
дома), от работающего. Разбор — настоящий, из estimator, по подписи, которую
|
||||||
|
пишет якорный блок.
|
||||||
|
"""
|
||||||
|
est = sys.modules["app.services.estimator"]
|
||||||
|
est.analog_tier_from_explanation = analog_tier_from_explanation # type: ignore[attr-defined]
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
row.confidence_explanation = "Оценка построена по 20 аналогам из того же дома."
|
||||||
|
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["analog_tier"] == "same_building"
|
||||||
|
|
||||||
|
|
||||||
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
||||||
"""No X-Authenticated-User header → 401."""
|
"""No X-Authenticated-User header → 401."""
|
||||||
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,8 @@ def _estimator_stub():
|
||||||
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
||||||
# (см. tests/test_estimator_search_radius_2632.py).
|
# (см. tests/test_estimator_search_radius_2632.py).
|
||||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||||
|
# #3466: подписи якоря у строк фикстуры нет → None (см. test_estimate_idor.py).
|
||||||
|
analog_tier_from_explanation=lambda _e: None,
|
||||||
estimate_quality=_default_estimate_quality,
|
estimate_quality=_default_estimate_quality,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
||||||
|
|
||||||
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||||
# иначе тест проверял бы не то, что заявлено в docstring.
|
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
|
||||||
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||||
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
|
||||||
|
|
||||||
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
|
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
|
||||||
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
|
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
|
||||||
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||||
_OTHER_COMPS = [
|
_OTHER_COMPS = [
|
||||||
_comp(
|
_comp(
|
||||||
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
|
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
|
||||||
|
|
@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke
|
||||||
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
|
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
|
||||||
anchor → est.analogs содержит физлот ровно ОДИН раз (n=1).
|
anchor → est.analogs содержит физлот ровно ОДИН раз (n=1).
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
from app.services import estimator
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||||
|
|
||||||
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
|
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
|
||||||
|
|
@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat
|
||||||
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
|
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
|
||||||
при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но
|
при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но
|
||||||
нечестно. После фикса n_analogs=1, синхронно с analogs."""
|
нечестно. После фикса n_analogs=1, синхронно с analogs."""
|
||||||
from app.core.config import settings
|
from app.services import estimator
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
with monkeypatch.context() as m:
|
||||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||||
|
|
||||||
assert est.n_analogs == len(est.analogs) == 1
|
assert est.n_analogs == len(est.analogs) == 1
|
||||||
|
|
|
||||||
|
|
@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
|
||||||
)
|
)
|
||||||
if anchor is None:
|
if anchor is None:
|
||||||
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
|
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
|
||||||
from app.core.config import settings
|
from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
|
||||||
|
|
||||||
# Проверяем условие gate_thin напрямую
|
# Проверяем условие gate_thin напрямую
|
||||||
gate_thin = (
|
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
|
||||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
|
||||||
)
|
|
||||||
# n=2 < 3 — должен сработать если FSD тоже высокий
|
# n=2 < 3 — должен сработать если FSD тоже высокий
|
||||||
assert anchor["n"] == 2
|
assert anchor["n"] == 2
|
||||||
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
|
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
|
||||||
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов
|
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов
|
||||||
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
|
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
|
||||||
if anchor["fsd"] > settings.estimate_sb_gate_max_fsd:
|
if anchor["fsd"] > SB_GATE_MAX_FSD:
|
||||||
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
|
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
with patch("app.services.estimator.settings") as mock_settings:
|
||||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
|
||||||
mock_settings.estimate_sb_small_n_threshold = 10
|
|
||||||
mock_settings.avito_imv_thin_market_threshold = 10
|
mock_settings.avito_imv_thin_market_threshold = 10
|
||||||
mock_settings.sber_index_max_age_days = 35
|
mock_settings.sber_index_max_age_days = 35
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = {
|
||||||
|
|
||||||
|
|
||||||
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
|
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
|
||||||
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for
|
"""N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for
|
||||||
Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`).
|
Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`).
|
||||||
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
|
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
|
||||||
street+floor+area+price physical key) treats them as distinct units, not
|
street+floor+area+price physical key) treats them as distinct units, not
|
||||||
|
|
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
|
||||||
"period_months": 12,
|
"period_months": 12,
|
||||||
}
|
}
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active
|
# >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active
|
||||||
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
|
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
|
||||||
db.execute.side_effect = _fake_anchor_sql_execute(
|
db.execute.side_effect = _fake_anchor_sql_execute(
|
||||||
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
|
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Проверяет:
|
Проверяет:
|
||||||
- композиты физического ключа (_lot_dedup_components) — что образуют / не образуют;
|
- композиты физического ключа (_lot_dedup_components) — что образуют / не образуют;
|
||||||
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
|
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
|
||||||
(свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично;
|
(свежайший scraped_at), не-дубли не трогаются;
|
||||||
- выбор представителя детерминирован (freshest scraped_at).
|
- выбор представителя детерминирован (freshest scraped_at).
|
||||||
|
|
||||||
No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
|
No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
|
||||||
|
|
@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None:
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Флаг: default ON (#2173) vs явный OFF (byte-identical)
|
# Дедуп без выключателя (#2378)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_default_is_on() -> None:
|
def test_dedup_default_is_on() -> None:
|
||||||
# #2173: default флага теперь ON (в проде дедуп активен без ENV-override).
|
# #2378: выключателя больше нет — дедуп работает всегда и схлопывает
|
||||||
# Без monkeypatch settings отражает config-default → дедуп схлопывает
|
# кросс-пост-тройку одного физлота.
|
||||||
# кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
|
|
||||||
# поведение по умолчанию.
|
|
||||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito"),
|
_lot(source="avito"),
|
||||||
_lot(source="cian"),
|
_lot(source="cian"),
|
||||||
_lot(source="domklik"),
|
_lot(source="domklik"),
|
||||||
]
|
]
|
||||||
out = estimator._dedup_cross_source(lots)
|
out = estimator._dedup_cross_source(lots)
|
||||||
assert len(out) == 1 # дедуп активен по умолчанию
|
assert len(out) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
# OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON):
|
|
||||||
# при флаге OFF проход байт-идентичен — возвращается тот же объект.
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
|
||||||
lots = [
|
|
||||||
_lot(source="avito"),
|
|
||||||
_lot(source="cian"),
|
|
||||||
_lot(source="domklik"),
|
|
||||||
]
|
|
||||||
out = estimator._dedup_cross_source(lots)
|
|
||||||
assert out is lots # тот же объект → нулевое влияние на выборку
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# _dedup_cross_source — флаг ON
|
# _dedup_cross_source
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_cross_source_triple() -> None:
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
|
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
_lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||||
|
|
@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
assert out[0]["source"] == "cian"
|
assert out[0]["source"] == "cian"
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_keeps_distinct_units() -> None:
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
|
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito", floor=4, area=67.0, price=21_995_000),
|
_lot(source="avito", floor=4, area=67.0, price=21_995_000),
|
||||||
|
|
@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
assert len(out) == 5 # ничего не схлопнулось
|
assert len(out) == 5 # ничего не схлопнулось
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_preserves_first_seen_order() -> None:
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
|
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
|
||||||
# позицию первого появления.
|
# позицию первого появления.
|
||||||
lots = [
|
lots = [
|
||||||
|
|
@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No
|
||||||
assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]
|
assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_lots_without_key_stay_unique() -> None:
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
# Нет площади → ключ None → не дедупим, даже если оба «пустые».
|
# Нет площади → ключ None → не дедупим, даже если оба «пустые».
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito", area=0),
|
_lot(source="avito", area=0),
|
||||||
|
|
@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None:
|
||||||
assert _street_token(None) == ""
|
assert _street_token(None) == ""
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_three_source_formats() -> None:
|
||||||
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
|
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
|
||||||
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
|
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="cian",
|
source="cian",
|
||||||
|
|
@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -
|
||||||
assert len(out) == 1 # один физлот, не три
|
assert len(out) == 1 # один физлот, не три
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_merges_cadnum_asymmetry_same_street() -> None:
|
||||||
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
|
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
|
||||||
# street-композиту (#2265: union по любому совпавшему ключу).
|
# street-композиту (#2265: union по любому совпавшему ключу).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="cian",
|
source="cian",
|
||||||
|
|
@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
|
def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
|
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
|
||||||
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
|
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="cian",
|
source="cian",
|
||||||
|
|
@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
|
||||||
assert len(out) == 2
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_same_street_different_metrics_stay_distinct(
|
def test_dedup_same_street_different_metrics_stay_distinct() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
|
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
|
||||||
# разными (защита от over-merge при отсутствии cadnum).
|
# разными (защита от over-merge при отсутствии cadnum).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||||
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
|
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
|
||||||
|
|
@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None:
|
||||||
assert _street_token("ул. Белинского 86") == "белинского"
|
assert _street_token("ул. Белинского 86") == "белинского"
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_numbered_street_three_formats() -> None:
|
||||||
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
|
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="cian",
|
source="cian",
|
||||||
|
|
@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_house_no_guard_blocks_street_union() -> None:
|
||||||
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
|
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
|
||||||
# дома → house-guard не даёт слить (both-present-different).
|
# дома → house-guard не даёт слить (both-present-different).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||||
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
|
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
|
||||||
|
|
@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc
|
||||||
assert len(out) == 2
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_house_no_guard_order_stable() -> None:
|
||||||
# Устойчивость к порядку: домклик без номера сливается с одним из номеров,
|
# Устойчивость к порядку: домклик без номера сливается с одним из номеров,
|
||||||
# два разных номера остаются раздельными независимо от позиции «пустого».
|
# два разных номера остаются раздельными независимо от позиции «пустого».
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
|
base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="domklik", address="улица Сыромолотова", **base), # house ""
|
_lot(source="domklik", address="улица Сыромолотова", **base), # house ""
|
||||||
|
|
@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N
|
||||||
assert len(out) == 2
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_empty_cadnum_and_street_stay_separate() -> None:
|
||||||
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
|
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
|
||||||
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
|
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||||
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||||
|
|
@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None:
|
||||||
assert domklik == avito == cian == "рассветная"
|
assert domklik == avito == cian == "рассветная"
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_domklik_cian_name_order() -> None:
|
||||||
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
|
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
|
||||||
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
|
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
|
||||||
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
|
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
|
||||||
|
|
@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
|
||||||
# адреса используют одну и ту же глухую («к1») family корпуса, и после
|
# адреса используют одну и ту же глухую («к1») family корпуса, и после
|
||||||
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
|
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
|
||||||
# независимо от пробела перед «к» (см. _normalize_house_no).
|
# независимо от пробела перед «к» (см. _normalize_house_no).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="domklik",
|
source="domklik",
|
||||||
|
|
@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
|
||||||
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
|
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_domklik_cian_area_straddle() -> None:
|
||||||
# #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
|
# #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
|
||||||
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
|
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
|
||||||
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
|
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
|
||||||
|
|
@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
|
||||||
# должен склеить их в один физлот. Оба адреса — глухая («к1») family
|
# должен склеить их в один физлот. Оба адреса — глухая («к1») family
|
||||||
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
|
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
|
||||||
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
|
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="domklik",
|
source="domklik",
|
||||||
|
|
@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
|
||||||
assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0
|
assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_avito_glued_suffix() -> None:
|
||||||
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
|
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
|
||||||
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
|
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
|
||||||
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
|
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
|
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
|
||||||
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
|
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
|
||||||
|
|
@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_mkr_fallback_does_not_cross_with_street() -> None:
|
||||||
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
|
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
|
||||||
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
|
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
|
||||||
# сливаются даже при совпадении метрик.
|
# сливаются даже при совпадении метрик.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
|
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
|
||||||
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
|
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
|
||||||
|
|
@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str)
|
||||||
assert estimator._parse_street_house(addr) == ("", "")
|
assert estimator._parse_street_house(addr) == ("", "")
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None:
|
||||||
# Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
|
# Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
|
||||||
# ключевался по району и никогда не сливался с avito/cian, где «готвальда»
|
# ключевался по району и никогда не сливался с avito/cian, где «готвальда»
|
||||||
# распознаётся явной веткой (тип-слово «улица»).
|
# распознаётся явной веткой (тип-слово «улица»).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="domclick",
|
source="domclick",
|
||||||
|
|
@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_dedup_collapses_yo_ye_variant() -> None:
|
||||||
# Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
|
# Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
|
||||||
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
|
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
|
_lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
|
||||||
_lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
|
_lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
|
||||||
|
|
@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
|
||||||
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
|
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_avito_glued_metro_suffix_house_match(
|
def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
|
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
|
||||||
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
|
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
|
||||||
# больше не блокирует street-union (оба извлекают «2») → один физлот.
|
# больше не блокирует street-union (оба извлекают «2») → один физлот.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="avito",
|
source="avito",
|
||||||
|
|
@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match(
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_glued_metro_noise_does_not_cause_over_merge(
|
def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
|
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
|
||||||
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
|
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
|
||||||
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
|
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
|
||||||
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
|
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="avito",
|
source="avito",
|
||||||
|
|
@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
|
||||||
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
|
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
|
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
|
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
|
||||||
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
|
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
|
||||||
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
|
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
|
||||||
# схлопнуться в один. union() зависит от порядка (кто станет root) —
|
# схлопнуться в один. union() зависит от порядка (кто станет root) —
|
||||||
# проверяем ОБА порядка следования лотов.
|
# проверяем ОБА порядка следования лотов.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
|
|
||||||
def _pair(first_source: str, second_source: str) -> list[dict]:
|
def _pair(first_source: str, second_source: str) -> list[dict]:
|
||||||
by_source = {
|
by_source = {
|
||||||
|
|
@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
|
||||||
assert len(out_domklik_first) == 1
|
assert len(out_domklik_first) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
|
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
|
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
|
||||||
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
|
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
|
||||||
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
|
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
|
||||||
# должно.
|
# должно.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="avito",
|
source="avito",
|
||||||
|
|
@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
|
||||||
assert len(out) == 2
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dedup_house_no_guard_blocks_different_corpus_numbers(
|
def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
|
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
|
||||||
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
|
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
|
||||||
# по-прежнему их разделять после нормализации.
|
# по-прежнему их разделять после нормализации.
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
|
||||||
lots = [
|
lots = [
|
||||||
_lot(
|
_lot(
|
||||||
source="domklik",
|
source="domklik",
|
||||||
|
|
@ -977,3 +924,128 @@ def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
|
||||||
# сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
|
# сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
|
||||||
# схлопывается ни во что и не отбрасывается.
|
# схлопывается ни во что и не отбрасывается.
|
||||||
assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")
|
assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #2291 класс 4 — нумерованные улицы с дефисом («40-летия Комсомола»)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("addr", "expected"),
|
||||||
|
[
|
||||||
|
# Живые форматы источников с прода (tradein-postgres, 2026-09-17).
|
||||||
|
("ул. 40-летия Комсомола,32Г", ("40-летия комсомола", "32г")), # avito
|
||||||
|
(
|
||||||
|
"Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г",
|
||||||
|
("40-летия комсомола", "32г"),
|
||||||
|
), # yandex
|
||||||
|
("р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
|
||||||
|
("Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
|
||||||
|
("Москва, 60-летия Октября проспект, 18 к3", ("60-летия октября", "18/3")),
|
||||||
|
("улица 22-го Партсъезда, 10", ("22-го партсъезда", "10")),
|
||||||
|
# Регрессии: пробельная нумерованная, мкр-шум при уличном типе, bare-street, mkr.
|
||||||
|
("улица 8 Марта, 12", ("8 марта", "12")),
|
||||||
|
("мкр. ЖБИ, улица Сыромолотова, 5", ("сыромолотова", "5")),
|
||||||
|
("Заречный м-н, Готвальда, 24", ("готвальда", "24")),
|
||||||
|
("мкр Пионерский, 5", ("mkr пионерский", "5")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_street_house_hyphen_numbered_street(addr: str, expected: tuple[str, str]) -> None:
|
||||||
|
assert estimator._parse_street_house(addr) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_collapses_hyphen_numbered_street_four_sources() -> None:
|
||||||
|
# Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian —
|
||||||
|
# ('mkr жби', '40'), и слиться не мог никто. Флаг дедупа не трогаем: на main он
|
||||||
|
# ON по умолчанию (test_dedup_default_is_on), а #3556 удаляет его из Settings —
|
||||||
|
# setattr по несуществующему полю уронил бы тест при любом порядке мержа.
|
||||||
|
addrs = [
|
||||||
|
("avito", "ул. 40-летия Комсомола,32Г"),
|
||||||
|
("yandex", "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г"),
|
||||||
|
("cian", "р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г"),
|
||||||
|
("domklik", "Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г"),
|
||||||
|
]
|
||||||
|
lots = [
|
||||||
|
_lot(source=s, address=a, area=44.0, price=5_900_000, floor=7, cad=None) for s, a in addrs
|
||||||
|
]
|
||||||
|
assert len(estimator._dedup_cross_source(lots)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> None:
|
||||||
|
# Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с
|
||||||
|
# одинаковыми этажом/площадью/ценой сливались в один лот.
|
||||||
|
lots = [
|
||||||
|
_lot(
|
||||||
|
source="cian",
|
||||||
|
address=f"р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, {h}",
|
||||||
|
area=44.0,
|
||||||
|
price=5_900_000,
|
||||||
|
floor=7,
|
||||||
|
cad=None,
|
||||||
|
)
|
||||||
|
for h in ("8А", "35")
|
||||||
|
]
|
||||||
|
assert len(estimator._dedup_cross_source(lots)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #2291 класс 4 — однобуквенные порядковые («2-я Новосибирская», «3-й Конный»)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("addr", "expected"),
|
||||||
|
[
|
||||||
|
# Три формата одной улицы (живые адреса с прода, tradein-postgres, 17.09):
|
||||||
|
# cian «тип номер имя» раньше давал ('', ''), avito/yandex «номер тип имя» и
|
||||||
|
# domklik «номер имя тип» теряли номер ('синичкина').
|
||||||
|
("2-я ул. Синичкина,22", ("2-я синичкина", "22")), # avito
|
||||||
|
("Москва, 2-я Синичкина улица, 22", ("2-я синичкина", "22")), # domklik
|
||||||
|
("Россия, Москва, 2-я улица Синичкина, 22", ("2-я синичкина", "22")), # yandex
|
||||||
|
("улица 2-я Новосибирская, 5", ("2-я новосибирская", "5")),
|
||||||
|
("пер. 3-й Конный, 7", ("3-й конный", "7")),
|
||||||
|
("ул. 1-й Пятилетки, 3", ("1-й пятилетки", "3")),
|
||||||
|
("12-й пр. Марьиной Рощи,8с2", ("12-й марьиной рощи", "8")),
|
||||||
|
# Раньше мкр-ветка: ('mkr перловка', '1') — номер улицы вместо дома.
|
||||||
|
("мкр. Перловка, улица 1-я Крестьянская, 33к2", ("1-я крестьянская", "33/2")),
|
||||||
|
# Порядковый перед нумерованным именем: без него было бы ('', '').
|
||||||
|
("2-й пер. 1 Мая,13", ("2-й 1 мая", "13")),
|
||||||
|
(
|
||||||
|
"Россия, Московская область, Павловский Посад, 2-й переулок 1 Мая, 11",
|
||||||
|
("2-й 1 мая", "11"),
|
||||||
|
),
|
||||||
|
# Регрессии: номер дома с дефисом остаётся номером дома.
|
||||||
|
("ул. Ленина, 12-а", ("ленина", "12")),
|
||||||
|
("ул. Щорса, 7-б", ("щорса", "7")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_street_house_single_letter_ordinal(addr: str, expected: tuple[str, str]) -> None:
|
||||||
|
assert estimator._parse_street_house(addr) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_collapses_single_letter_ordinal_three_formats() -> None:
|
||||||
|
addrs = [
|
||||||
|
("avito", "2-я ул. Синичкина,22"),
|
||||||
|
("domklik", "Москва, 2-я Синичкина улица, 22"),
|
||||||
|
("yandex", "Россия, Москва, 2-я улица Синичкина, 22"),
|
||||||
|
("cian", "ЮВАО, р-н Лефортово, улица 2-я Синичкина, 22"),
|
||||||
|
]
|
||||||
|
lots = [
|
||||||
|
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None) for s, a in addrs
|
||||||
|
]
|
||||||
|
assert len(estimator._dedup_cross_source(lots)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_different_ordinal_streets_stay_distinct() -> None:
|
||||||
|
# Раньше domklik терял номер: «9-я Парковая» и «12-я Парковая» обе давали
|
||||||
|
# ('парковая', '5'), и квартиры с одинаковыми этажом/площадью/ценой в двух
|
||||||
|
# разных домах Измайлова сливались в одну.
|
||||||
|
lots = [
|
||||||
|
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None)
|
||||||
|
for s, a in (
|
||||||
|
("domklik", "Москва, 9-я Парковая улица, 5"),
|
||||||
|
("domklik", "Москва, 12-я Парковая улица, 5"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert len(estimator._dedup_cross_source(lots)) == 2
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3):
|
||||||
|
|
||||||
def _run_estimate(
|
def _run_estimate(
|
||||||
ratio_tuple: tuple[float | None, str | None],
|
ratio_tuple: tuple[float | None, str | None],
|
||||||
*,
|
|
||||||
clamp_enabled: bool = True,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
|
@ -90,7 +88,6 @@ def _run_estimate(
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
with (
|
with (
|
||||||
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
|
|
||||||
# #2002: these tests assert the clamp/ratio math exactly. Hold the
|
# #2002: these tests assert the clamp/ratio math exactly. Hold the
|
||||||
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
|
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
|
||||||
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
||||||
|
|
@ -131,7 +128,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
||||||
) -> None:
|
) -> None:
|
||||||
ratio = 1.15
|
ratio = 1.15
|
||||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
est = _run_estimate((ratio, "per_rooms"))
|
||||||
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
|
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
|
||||||
assert est.expected_sold_price_rub is not None
|
assert est.expected_sold_price_rub is not None
|
||||||
assert est.expected_sold_price_rub <= est.median_price_rub, (
|
assert est.expected_sold_price_rub <= est.median_price_rub, (
|
||||||
|
|
@ -167,7 +164,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
||||||
|
|
||||||
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||||||
ratio = 0.8
|
ratio = 0.8
|
||||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
est = _run_estimate((ratio, "per_rooms"))
|
||||||
assert est.median_price_rub > 0
|
assert est.median_price_rub > 0
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||||
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
|
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||||
|
|
@ -182,22 +179,10 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_expected_sold_none_when_ratio_none() -> None:
|
def test_expected_sold_none_when_ratio_none() -> None:
|
||||||
est = _run_estimate((None, None), clamp_enabled=True)
|
est = _run_estimate((None, None))
|
||||||
assert est.expected_sold_price_rub is None
|
assert est.expected_sold_price_rub is None
|
||||||
assert est.expected_sold_per_m2 is None
|
assert est.expected_sold_per_m2 is None
|
||||||
assert est.expected_sold_range_low_rub is None
|
assert est.expected_sold_range_low_rub is None
|
||||||
assert est.expected_sold_range_high_rub is None
|
assert est.expected_sold_range_high_rub is None
|
||||||
assert est.asking_to_sold_ratio is None
|
assert est.asking_to_sold_ratio is None
|
||||||
assert est.median_price_rub > 0
|
assert est.median_price_rub > 0
|
||||||
|
|
||||||
|
|
||||||
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
|
|
||||||
ratio = 1.15
|
|
||||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
|
|
||||||
assert est.median_price_rub > 0
|
|
||||||
assert est.expected_sold_price_rub is not None
|
|
||||||
assert est.expected_sold_price_rub > est.median_price_rub, (
|
|
||||||
f"Ожидалось expected_sold > asking при clamp=False, "
|
|
||||||
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
|
|
||||||
)
|
|
||||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies:
|
||||||
* both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new);
|
* both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new);
|
||||||
* the neutral year term when ``target_year`` is None (≡ year 2000);
|
* the neutral year term when ``target_year`` is None (≡ year 2000);
|
||||||
* the le_asking invariant — the corrected expected_sold never exceeds the asking
|
* the le_asking invariant — the corrected expected_sold never exceeds the asking
|
||||||
headline (median) when ``estimate_expected_sold_le_asking`` is on.
|
headline (median) — the expected_sold ≤ asking clamp has no switch (#2380).
|
||||||
|
|
||||||
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||||||
requires DATABASE_URL. Set it BEFORE importing app modules.
|
requires DATABASE_URL. Set it BEFORE importing app modules.
|
||||||
|
|
@ -151,8 +151,9 @@ def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Small area + new year → raw factor > factor_max → clamped to the ceiling.
|
"""Small area + new year → raw factor > factor_max → clamped to the ceiling.
|
||||||
|
|
||||||
le_asking is held OFF so the raw ceiling factor is observable on the point
|
ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on
|
||||||
(otherwise the re-clamp would cap it at the asking headline).
|
expected_sold ≤ asking re-clamp does not bind and the raw ceiling factor is
|
||||||
|
observable on the point (#2380: the clamp no longer has an OFF switch).
|
||||||
|
|
||||||
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
|
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
|
||||||
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
|
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
|
||||||
|
|
@ -161,11 +162,10 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
||||||
off = _price(area_m2=15.0, target_year=2025, ratio=0.85)
|
off = _price(area_m2=15.0, target_year=2025, ratio=0.70)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||||
on = _price(area_m2=15.0, target_year=2025, ratio=0.85)
|
on = _price(area_m2=15.0, target_year=2025, ratio=0.70)
|
||||||
|
|
||||||
factor = _expected_factor(15.0, 2025)
|
factor = _expected_factor(15.0, 2025)
|
||||||
assert factor == estimator.settings.estimate_hedonic_factor_max
|
assert factor == estimator.settings.estimate_hedonic_factor_max
|
||||||
|
|
@ -187,9 +187,8 @@ def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""With le_asking on, the hedonic-corrected expected_sold never exceeds asking."""
|
"""The hedonic-corrected expected_sold never exceeds asking."""
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True)
|
|
||||||
# small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 →
|
# small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 →
|
||||||
# uncorrected the point would exceed the asking headline; the re-clamp must bind.
|
# uncorrected the point would exceed the asking headline; the re-clamp must bind.
|
||||||
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
|
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
|
||||||
|
|
@ -202,17 +201,6 @@ def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch
|
||||||
assert res.expected_sold_price == res.median_price
|
assert res.expected_sold_price == res.median_price
|
||||||
|
|
||||||
|
|
||||||
def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp)."""
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
|
|
||||||
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
|
|
||||||
|
|
||||||
assert res.expected_sold_price is not None
|
|
||||||
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
|
|
||||||
assert res.expected_sold_price > res.median_price
|
|
||||||
|
|
||||||
|
|
||||||
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
|
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
|
@ -33,20 +32,6 @@ ELITE_REASON = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _settings(
|
|
||||||
*,
|
|
||||||
high_value: int = 20_000_000,
|
|
||||||
wide_ratio: float = 1.9,
|
|
||||||
elite_ppm2: int = 250_000,
|
|
||||||
) -> SimpleNamespace:
|
|
||||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
|
||||||
return SimpleNamespace(
|
|
||||||
estimate_manual_review_high_value_rub=high_value,
|
|
||||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
|
||||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
|
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,7 +42,6 @@ def test_premium_building_alone() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=7_500_000,
|
range_low=7_500_000,
|
||||||
range_high=8_500_000,
|
range_high=8_500_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [PREMIUM_REASON]
|
assert reasons == [PREMIUM_REASON]
|
||||||
|
|
@ -70,7 +54,6 @@ def test_high_value_alone() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=24_000_000,
|
range_low=24_000_000,
|
||||||
range_high=26_000_000,
|
range_high=26_000_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [HIGH_VALUE_REASON]
|
assert reasons == [HIGH_VALUE_REASON]
|
||||||
|
|
@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None:
|
||||||
confidence="low",
|
confidence="low",
|
||||||
range_low=4_500_000,
|
range_low=4_500_000,
|
||||||
range_high=5_500_000,
|
range_high=5_500_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [LOW_CONF_REASON]
|
assert reasons == [LOW_CONF_REASON]
|
||||||
|
|
@ -96,7 +78,6 @@ def test_wide_range_alone() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=5_000_000,
|
range_low=5_000_000,
|
||||||
range_high=10_000_000, # ratio 2.0 ≥ 1.9
|
range_high=10_000_000, # ratio 2.0 ≥ 1.9
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [WIDE_RANGE_REASON]
|
assert reasons == [WIDE_RANGE_REASON]
|
||||||
|
|
@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=11_000_000,
|
range_low=11_000_000,
|
||||||
range_high=13_000_000, # ratio ~1.18 < 1.9
|
range_high=13_000_000, # ratio ~1.18 < 1.9
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=300_000, # ≥ 250_000
|
asking_ppm2=300_000, # ≥ 250_000
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
|
|
@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=11_000_000,
|
range_low=11_000_000,
|
||||||
range_high=13_000_000,
|
range_high=13_000_000,
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=250_000,
|
asking_ppm2=250_000,
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
|
|
@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=11_000_000,
|
range_low=11_000_000,
|
||||||
range_high=13_000_000,
|
range_high=13_000_000,
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=249_999,
|
asking_ppm2=249_999,
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
|
|
@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=11_000_000,
|
range_low=11_000_000,
|
||||||
range_high=13_000_000,
|
range_high=13_000_000,
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=None,
|
asking_ppm2=None,
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
|
|
@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=11_000_000,
|
range_low=11_000_000,
|
||||||
range_high=13_000_000,
|
range_high=13_000_000,
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=0,
|
asking_ppm2=0,
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
|
|
@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None:
|
||||||
confidence="low",
|
confidence="low",
|
||||||
range_low=10_000_000,
|
range_low=10_000_000,
|
||||||
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
||||||
settings=_settings(),
|
|
||||||
asking_ppm2=400_000,
|
asking_ppm2=400_000,
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
|
|
@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None:
|
||||||
confidence="low",
|
confidence="low",
|
||||||
range_low=10_000_000,
|
range_low=10_000_000,
|
||||||
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
# порядок детерминирован: premium → high_value → low_conf → wide_range
|
# порядок детерминирован: premium → high_value → low_conf → wide_range
|
||||||
|
|
@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None:
|
||||||
confidence="medium",
|
confidence="medium",
|
||||||
range_low=9_000_000,
|
range_low=9_000_000,
|
||||||
range_high=11_000_000, # ratio ~1.22 < 1.9
|
range_high=11_000_000, # ratio ~1.22 < 1.9
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
assert reasons == []
|
assert reasons == []
|
||||||
|
|
@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None:
|
||||||
confidence="medium",
|
confidence="medium",
|
||||||
range_low=19_000_000,
|
range_low=19_000_000,
|
||||||
range_high=21_000_000,
|
range_high=21_000_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [HIGH_VALUE_REASON]
|
assert reasons == [HIGH_VALUE_REASON]
|
||||||
|
|
@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None:
|
||||||
confidence="medium",
|
confidence="medium",
|
||||||
range_low=19_000_000,
|
range_low=19_000_000,
|
||||||
range_high=21_000_000,
|
range_high=21_000_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
assert reasons == []
|
assert reasons == []
|
||||||
|
|
@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=10_000_000,
|
range_low=10_000_000,
|
||||||
range_high=19_000_000, # ratio == 1.9
|
range_high=19_000_000, # ratio == 1.9
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is True
|
assert rec is True
|
||||||
assert reasons == [WIDE_RANGE_REASON]
|
assert reasons == [WIDE_RANGE_REASON]
|
||||||
|
|
@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None:
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=10_000_000,
|
range_low=10_000_000,
|
||||||
range_high=18_900_000, # ratio 1.89 < 1.9
|
range_high=18_900_000, # ratio 1.89 < 1.9
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
assert reasons == []
|
assert reasons == []
|
||||||
|
|
@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
||||||
confidence="high",
|
confidence="high",
|
||||||
range_low=range_low,
|
range_low=range_low,
|
||||||
range_high=range_high,
|
range_high=range_high,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is False
|
assert rec is False
|
||||||
assert WIDE_RANGE_REASON not in reasons
|
assert WIDE_RANGE_REASON not in reasons
|
||||||
|
|
@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None:
|
||||||
confidence=conf,
|
confidence=conf,
|
||||||
range_low=9_000_000,
|
range_low=9_000_000,
|
||||||
range_high=11_000_000,
|
range_high=11_000_000,
|
||||||
settings=_settings(),
|
|
||||||
)
|
)
|
||||||
assert rec is bool(reasons)
|
assert rec is bool(reasons)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,9 @@ from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||||
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 app.services import estimator
|
|
||||||
|
|
||||||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||||||
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
|
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
|
||||||
|
|
@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec)
|
||||||
_spec.loader.exec_module(_h)
|
_spec.loader.exec_module(_h)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
# Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов»,
|
|
||||||
# ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173).
|
|
||||||
# Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы
|
|
||||||
# близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы
|
|
||||||
# мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON).
|
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
|
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
|
||||||
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
|
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
|
||||||
|
|
||||||
|
|
@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None:
|
||||||
_listing(price_per_m2=210_000.0),
|
_listing(price_per_m2=210_000.0),
|
||||||
_listing(price_per_m2=215_000.0),
|
_listing(price_per_m2=215_000.0),
|
||||||
_listing(price_per_m2=225_000.0),
|
_listing(price_per_m2=225_000.0),
|
||||||
_listing(price_per_m2=None),
|
# Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON
|
||||||
_listing(price_per_m2=None),
|
# с #2378) не схлопывает два безценовых листинга в один.
|
||||||
|
_listing(price_per_m2=None, area_m2=61.0),
|
||||||
|
_listing(price_per_m2=None, area_m2=62.0),
|
||||||
]
|
]
|
||||||
est = _h._run_estimate(
|
est = _h._run_estimate(
|
||||||
anchor_comps=[],
|
anchor_comps=[],
|
||||||
|
|
@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None:
|
||||||
def test_n_analogs_all_priced_unchanged() -> None:
|
def test_n_analogs_all_priced_unchanged() -> None:
|
||||||
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
|
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
|
||||||
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
|
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
|
||||||
radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)]
|
# Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает.
|
||||||
|
radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)]
|
||||||
est = _h._run_estimate(
|
est = _h._run_estimate(
|
||||||
anchor_comps=[],
|
anchor_comps=[],
|
||||||
anchor_tier=None,
|
anchor_tier=None,
|
||||||
|
|
|
||||||
|
|
@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None:
|
||||||
|
|
||||||
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
|
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
|
||||||
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
|
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
|
||||||
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||||
дальше по коду он читается как «headline построил якорь».
|
дальше по коду он читается как «headline построил якорь».
|
||||||
"""
|
"""
|
||||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4
|
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4
|
||||||
radius_median_price = int(100_000 * 50.0)
|
radius_median_price = int(100_000 * 50.0)
|
||||||
pr = _call(
|
pr = _call(
|
||||||
listings=_lots(100_000, n=5),
|
listings=_lots(100_000, n=5),
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,7 @@ def _run_estimate_qi(
|
||||||
anchor_tier_override: str | None = None,
|
anchor_tier_override: str | None = None,
|
||||||
analog_indexes: dict[str, float] | None = None,
|
analog_indexes: dict[str, float] | None = None,
|
||||||
imv_anchor: dict[str, Any] | None = None,
|
imv_anchor: dict[str, Any] | None = None,
|
||||||
|
seen_min_n: list[int] | None = None,
|
||||||
):
|
):
|
||||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
||||||
|
|
||||||
|
|
@ -396,6 +397,9 @@ def _run_estimate_qi(
|
||||||
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
||||||
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
||||||
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
||||||
|
|
||||||
|
seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
|
||||||
|
фильтрует строки в SQL, и заглушка его иначе теряет.
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
|
@ -406,7 +410,14 @@ def _run_estimate_qi(
|
||||||
|
|
||||||
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
||||||
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
||||||
|
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
|
||||||
|
if seen_min_n is not None:
|
||||||
|
seen_min_n.append(min_n_deals)
|
||||||
|
return qi_lookup_result
|
||||||
|
|
||||||
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
||||||
|
if seen_min_n is not None:
|
||||||
|
seen_min_n.append(min_n_deals)
|
||||||
if analog_indexes is not None:
|
if analog_indexes is not None:
|
||||||
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
||||||
if qi_lookup_result is None:
|
if qi_lookup_result is None:
|
||||||
|
|
@ -451,7 +462,7 @@ def _run_estimate_qi(
|
||||||
# Stub singular target-quarter lookup
|
# Stub singular target-quarter lookup
|
||||||
patch(
|
patch(
|
||||||
"app.services.estimator._lookup_quarter_index",
|
"app.services.estimator._lookup_quarter_index",
|
||||||
return_value=qi_lookup_result,
|
side_effect=_fake_lookup_index,
|
||||||
),
|
),
|
||||||
# Stub batched analog-quarter lookup
|
# Stub batched analog-quarter lookup
|
||||||
patch(
|
patch(
|
||||||
|
|
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||||
|
# #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб
|
||||||
|
# отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде.
|
||||||
|
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||||
)
|
)
|
||||||
# Медиана НЕ должна изменяться
|
# Медиана НЕ должна изменяться
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard2_ratio_exactly_0_6_still_corrects() -> None:
|
||||||
|
"""#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip."""
|
||||||
|
analogs = [
|
||||||
|
_make_listing_qi(
|
||||||
|
price_per_m2=_BASE_PPM2,
|
||||||
|
building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100",
|
||||||
|
floor=1 + i,
|
||||||
|
)
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
est = _run_estimate_qi(
|
||||||
|
analogs=analogs,
|
||||||
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
|
qi_lookup_result=(1.5, 30),
|
||||||
|
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||||
|
)
|
||||||
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarter_lookups_filter_by_ten_deals() -> None:
|
||||||
|
"""#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок.
|
||||||
|
|
||||||
|
Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно:
|
||||||
|
подмена числа на месте вызова проходила весь сьют.
|
||||||
|
"""
|
||||||
|
seen: list[int] = []
|
||||||
|
est = _run_estimate_qi(
|
||||||
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
|
qi_lookup_result=(1.2, 30),
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
|
seen_min_n=seen,
|
||||||
|
)
|
||||||
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны
|
||||||
|
assert seen == [10, 10]
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Sparse fallback: нет строки → no-op
|
# Sparse fallback: нет строки → no-op
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None:
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_bimodal_guard_skips_high_index_small_n() -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
|
("target_qi", "expected_factor"),
|
||||||
analogs_no_cadnum = [
|
[
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
|
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
|
||||||
for i in range(5)
|
(2.01, 1.0),
|
||||||
]
|
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
|
||||||
base_median = round(_BASE_PPM2 * _AREA)
|
],
|
||||||
est = _run_estimate_qi(
|
|
||||||
analogs=analogs_no_cadnum,
|
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
|
||||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None:
|
||||||
|
"""Bimodal guard: price_index > 2.0 при n_deals=20 → no-op.
|
||||||
|
|
||||||
|
#2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра —
|
||||||
|
поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде.
|
||||||
|
"""
|
||||||
|
est = _run_estimate_qi(
|
||||||
|
analogs=_ANALOGS_OTHER_QUARTER,
|
||||||
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
|
qi_lookup_result=(target_qi, 20),
|
||||||
|
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||||
|
)
|
||||||
|
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor)
|
||||||
|
|
||||||
|
|
||||||
def test_bimodal_guard_allows_high_index_large_n() -> None:
|
def test_bimodal_guard_allows_high_index_large_n() -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||||
- dkp_raw is None → no-op (нет базы для floor)
|
- dkp_raw is None → no-op (нет базы для floor)
|
||||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||||
|
- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466)
|
||||||
|
- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -73,10 +75,7 @@ def _make_payload():
|
||||||
def _run_estimate(
|
def _run_estimate(
|
||||||
analogs: list[dict[str, Any]],
|
analogs: list[dict[str, Any]],
|
||||||
dkp_raw: dict[str, Any] | None,
|
dkp_raw: dict[str, Any] | None,
|
||||||
*,
|
|
||||||
radius_floor_factor: float = 0.8,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -107,24 +106,31 @@ def _run_estimate(
|
||||||
),
|
),
|
||||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
|
||||||
):
|
):
|
||||||
return await estimate_quality(payload, db)
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
return anyio.run(_run)
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
def _six(center: float) -> list[dict[str, Any]]:
|
||||||
|
"""Шесть лотов вокруг center — выше HEADLINE_LISTINGS_MIN_N (5).
|
||||||
|
|
||||||
|
Три лота (как было до #3466) уводили оценку в #oblast-E: headline уступался
|
||||||
|
медиане коридора (180k), и floor не исполнялся вовсе — тесты 1 и 2 зеленели на
|
||||||
|
deals-fallback, а снятие гейта min_n у floor ничего не роняло.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
_make_listing(price_per_m2=center + d)
|
||||||
|
for d in (-5_000.0, -2_500.0, 0.0, 0.0, 2_500.0, 5_000.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── тест 1: radius median ниже floor → поднята ───────────────────────────────
|
# ── тест 1: radius median ниже floor → поднята ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
|
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
|
||||||
# analogs с median ~80k
|
analogs = _six(80_000.0)
|
||||||
analogs = [
|
|
||||||
_make_listing(price_per_m2=75_000.0),
|
|
||||||
_make_listing(price_per_m2=80_000.0),
|
|
||||||
_make_listing(price_per_m2=85_000.0),
|
|
||||||
]
|
|
||||||
dkp_raw = {
|
dkp_raw = {
|
||||||
"count": 15,
|
"count": 15,
|
||||||
"low_ppm2": 150_000,
|
"low_ppm2": 150_000,
|
||||||
|
|
@ -132,11 +138,12 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
"high_ppm2": 220_000,
|
"high_ppm2": 220_000,
|
||||||
"period_months": 12,
|
"period_months": 12,
|
||||||
}
|
}
|
||||||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
est = _run_estimate(analogs, dkp_raw)
|
||||||
|
|
||||||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||||
assert est.median_price_per_m2 >= floor_ppm2, (
|
# Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже».
|
||||||
f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
|
assert est.median_price_per_m2 == floor_ppm2, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2} должна быть поднята ровно до floor={floor_ppm2}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,11 +152,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
|
|
||||||
def test_radius_median_above_dkp_floor_unchanged() -> None:
|
def test_radius_median_above_dkp_floor_unchanged() -> None:
|
||||||
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
|
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
|
||||||
analogs = [
|
analogs = _six(200_000.0)
|
||||||
_make_listing(price_per_m2=190_000.0),
|
|
||||||
_make_listing(price_per_m2=200_000.0),
|
|
||||||
_make_listing(price_per_m2=210_000.0),
|
|
||||||
]
|
|
||||||
dkp_raw = {
|
dkp_raw = {
|
||||||
"count": 15,
|
"count": 15,
|
||||||
"low_ppm2": 150_000,
|
"low_ppm2": 150_000,
|
||||||
|
|
@ -157,15 +160,15 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
|
||||||
"high_ppm2": 220_000,
|
"high_ppm2": 220_000,
|
||||||
"period_months": 12,
|
"period_months": 12,
|
||||||
}
|
}
|
||||||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
est = _run_estimate(analogs, dkp_raw)
|
||||||
|
|
||||||
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
|
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
|
||||||
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
||||||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||||
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
|
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
|
||||||
# Медиана соответствует аналогам (~200k), а не floor
|
# Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k).
|
||||||
assert 180_000 <= est.median_price_per_m2 <= 220_000, (
|
assert est.median_price_per_m2 == 200_000, (
|
||||||
f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
|
f"median_ppm2={est.median_price_per_m2} должна остаться медианой аналогов (no-op)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -179,9 +182,69 @@ def test_no_dkp_raw_no_floor() -> None:
|
||||||
_make_listing(price_per_m2=80_000.0),
|
_make_listing(price_per_m2=80_000.0),
|
||||||
_make_listing(price_per_m2=85_000.0),
|
_make_listing(price_per_m2=85_000.0),
|
||||||
]
|
]
|
||||||
est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8)
|
est = _run_estimate(analogs, dkp_raw=None)
|
||||||
|
|
||||||
# median ~80k, без dkp_raw floor не поднимает
|
# median ~80k, без dkp_raw floor не поднимает
|
||||||
assert est.median_price_per_m2 < 100_000, (
|
assert est.median_price_per_m2 < 100_000, (
|
||||||
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── тест 4: коридор ниже порога доверия → floor не поднимает (#3466) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_not_applied_below_clamp_min_n() -> None:
|
||||||
|
"""n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен.
|
||||||
|
|
||||||
|
`DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа
|
||||||
|
headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта —
|
||||||
|
здесь: без гейта по count медиана 80k поднялась бы до 120k.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
analogs = _six(80_000.0)
|
||||||
|
dkp_raw = {
|
||||||
|
"count": settings.estimate_corridor_clamp_min_n - 1,
|
||||||
|
"low_ppm2": 150_000,
|
||||||
|
"median_ppm2": 180_000,
|
||||||
|
"high_ppm2": 220_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||||
|
|
||||||
|
assert est.median_price_per_m2 < 100_000, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2}: коридор из "
|
||||||
|
f"{dkp_raw['count']} сделок ниже порога не должен поднимать цену до floor=120000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── тест 5: ровно на пороге floor уже работает (#3466) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_applied_at_exactly_clamp_min_n() -> None:
|
||||||
|
"""n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять.
|
||||||
|
|
||||||
|
Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между
|
||||||
|
ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись
|
||||||
|
«справочно», значит коридор обязан войти в цену.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.trade_in import DkpCorridor
|
||||||
|
|
||||||
|
analogs = _six(80_000.0)
|
||||||
|
dkp_raw = {
|
||||||
|
"count": settings.estimate_corridor_clamp_min_n,
|
||||||
|
"low_ppm2": 150_000,
|
||||||
|
"median_ppm2": 180_000,
|
||||||
|
"high_ppm2": 220_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
# Та же граница с другой стороны: на ней коридор уже не справочный.
|
||||||
|
assert DkpCorridor(**dkp_raw).advisory_only is False
|
||||||
|
|
||||||
|
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||||
|
|
||||||
|
assert est.median_price_per_m2 == 150_000 * 0.8, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок "
|
||||||
|
"(ровно порог) должен поднять цену ровно до floor=120000"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402
|
||||||
from app.services.estimator import ( # noqa: E402
|
from app.services.estimator import ( # noqa: E402
|
||||||
DEFAULT_RADIUS_M,
|
DEFAULT_RADIUS_M,
|
||||||
_compute_confidence,
|
_compute_confidence,
|
||||||
|
analog_tier_from_explanation,
|
||||||
radius_from_relaxations,
|
radius_from_relaxations,
|
||||||
radius_relaxation_label,
|
radius_relaxation_label,
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
|
|
@ -193,6 +194,8 @@ def _light_estimator_stub():
|
||||||
_source_counts=lambda *a, **k: {},
|
_source_counts=lambda *a, **k: {},
|
||||||
_canonical_sources=lambda *a, **k: ["cian"],
|
_canonical_sources=lambda *a, **k: ["cian"],
|
||||||
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
||||||
|
# #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий.
|
||||||
|
analog_tier_from_explanation=analog_tier_from_explanation,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
yield stub
|
yield stub
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence
|
||||||
from app.services.estimator import _downgrade_confidence
|
|
||||||
|
|
||||||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||||||
# estimate путь со всеми I/O застабленными).
|
# estimate путь со всеми I/O застабленными).
|
||||||
|
|
@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def]
|
||||||
|
|
||||||
|
|
||||||
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||||
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold):
|
||||||
return _h._run_estimate(
|
return _h._run_estimate(
|
||||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
|
|
@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции"
|
||||||
def test_threshold_default_1_2() -> None:
|
def test_threshold_default_1_2() -> None:
|
||||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
||||||
assert settings.estimate_wide_corridor_threshold == 1.2
|
assert WIDE_CORRIDOR_THRESHOLD == 1.2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import os
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -46,12 +47,13 @@ class _FakeTelegramClient:
|
||||||
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
|
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
|
||||||
|
|
||||||
`responses` — очередь: каждый вызов `send_message` берёт следующий элемент
|
`responses` — очередь: каждый вызов `send_message` берёт следующий элемент
|
||||||
(dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не
|
(dict = успех, Exception = отказ, float = зависнуть на столько секунд, потом
|
||||||
удалась, повторная фоном прошла».
|
успех), позволяя смоделировать «первая попытка не удалась, повторная фоном
|
||||||
|
прошла».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
calls: ClassVar[list[dict[str, Any]]] = []
|
calls: ClassVar[list[dict[str, Any]]] = []
|
||||||
responses: ClassVar[list[dict[str, Any] | Exception]] = []
|
responses: ClassVar[list[dict[str, Any] | Exception | float]] = []
|
||||||
|
|
||||||
def __init__(self, _token: str = "fake-token") -> None:
|
def __init__(self, _token: str = "fake-token") -> None:
|
||||||
pass
|
pass
|
||||||
|
|
@ -61,6 +63,9 @@ class _FakeTelegramClient:
|
||||||
outcome = _FakeTelegramClient.responses.pop(0)
|
outcome = _FakeTelegramClient.responses.pop(0)
|
||||||
if isinstance(outcome, Exception):
|
if isinstance(outcome, Exception):
|
||||||
raise outcome
|
raise outcome
|
||||||
|
if isinstance(outcome, float):
|
||||||
|
await asyncio.sleep(outcome)
|
||||||
|
return {"message_id": 1}
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,6 +114,28 @@ def test_sync_failure_queues_background_retry_and_still_returns_502(
|
||||||
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
|
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_telegram_answers_502_before_sender_gives_up(
|
||||||
|
client: TestClient, _fake_telegram_client: Any, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""#3157, прод 16.09.2026 16:42 UTC: отказ Telegram был медленным (relay 8 с +
|
||||||
|
напрямую 5 с + пауза 2 с), GlitchTip оборвал запрос на 10-й секунде, 502 и
|
||||||
|
фоновой доставки не было. Медленный отказ обязан идти тем же путём, что быстрый."""
|
||||||
|
# GlitchTip 6.1.6 apps/alerts/webhooks.py send_webhook: ClientTimeout(total=10).
|
||||||
|
assert glitchtip_module._INTERACTIVE_SEND_DEADLINE_S <= 8.0
|
||||||
|
monkeypatch.setattr(glitchtip_module, "_INTERACTIVE_SEND_DEADLINE_S", 0.2)
|
||||||
|
_fake_telegram_client.responses = [5.0, {"message_id": 2}]
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert elapsed < 2.0
|
||||||
|
# Фон отработал: зависшая синхронная попытка + доставка фоном тем же текстом.
|
||||||
|
assert len(_fake_telegram_client.calls) == 2
|
||||||
|
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
|
||||||
|
|
||||||
|
|
||||||
def test_sync_success_does_not_queue_background_retry(
|
def test_sync_success_does_not_queue_background_retry(
|
||||||
client: TestClient, _fake_telegram_client: Any
|
client: TestClient, _fake_telegram_client: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None:
|
||||||
assert provider.released == []
|
assert provider.released == []
|
||||||
assert bf._lease is not None
|
assert bf._lease is not None
|
||||||
assert bf._lease.id == 5
|
assert bf._lease.id == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3410: /login несёт узел аренды, как /fetch ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_pool", [True, False])
|
||||||
|
async def test_login_body_carries_lease_proxy(use_pool: bool) -> None:
|
||||||
|
"""Аренда есть → в теле /login тот же proxy/proxy_kind, что ушёл бы в /fetch; нет → без."""
|
||||||
|
provider = _FakeProxyProvider(ProxyLease(id=13, url="http://u:p@pool:8080", kind="http"))
|
||||||
|
client = _mock_client({"cookies": [{"name": "DMIR_AUTH", "value": "v"}]})
|
||||||
|
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=use_pool)
|
||||||
|
|
||||||
|
cookies = await bf.login(
|
||||||
|
url="https://www.cian.ru/authenticate/",
|
||||||
|
email="a@b.c",
|
||||||
|
password="pw",
|
||||||
|
email_selector="#e",
|
||||||
|
password_selector="#p",
|
||||||
|
submit_selector="#s",
|
||||||
|
success_cookie="DMIR_AUTH",
|
||||||
|
)
|
||||||
|
await bf.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
assert cookies == {"DMIR_AUTH": "v"}
|
||||||
|
url_called = client.post.call_args.args[0]
|
||||||
|
body = client.post.call_args.kwargs["json"]
|
||||||
|
assert url_called == "http://browser:3000/login"
|
||||||
|
if use_pool:
|
||||||
|
assert (body.get("proxy"), body.get("proxy_kind")) == ("http://u:p@pool:8080", "http")
|
||||||
|
assert provider.released == [13]
|
||||||
|
else:
|
||||||
|
assert "proxy" not in body and "proxy_kind" not in body
|
||||||
|
assert provider.acquired == []
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services import estimator
|
|
||||||
from app.services.estimator import (
|
from app.services.estimator import (
|
||||||
_compute_same_building_anchor,
|
_compute_same_building_anchor,
|
||||||
_geocode_is_coarse,
|
_geocode_is_coarse,
|
||||||
|
|
@ -395,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
|
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
|
||||||
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755).
|
# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
|
||||||
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
|
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
|
||||||
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
|
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
|
||||||
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
|
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
|
||||||
|
|
@ -503,6 +501,29 @@ def test_estimate_premium_lift_real_in_range() -> None:
|
||||||
assert est.range_high_rub >= int(real * 0.9)
|
assert est.range_high_rub >= int(real * 0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_anchor_gets_prod_weights() -> None:
|
||||||
|
"""#2381: веса якоря доходят до расчёта боевыми (прод 17.09.2026).
|
||||||
|
|
||||||
|
Тест значений констант держит только число в estimator.py. Подмена
|
||||||
|
rooms_boost/floor_sigma на месте вызова (например, 1.0) проходила весь сьют:
|
||||||
|
веса сдвигают headline внутри тех же допусков, что проверяют тесты выше.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"app.services.estimator._compute_same_building_anchor",
|
||||||
|
wraps=_compute_same_building_anchor,
|
||||||
|
) as spy:
|
||||||
|
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
|
||||||
|
assert est.median_price_per_m2 >= 450_000 # якорь сработал, а не радиус
|
||||||
|
kw = spy.call_args.kwargs
|
||||||
|
assert {k: kw[k] for k in ("sigma", "rooms_boost", "floor_sigma", "min_comps", "mad_k")} == {
|
||||||
|
"sigma": 0.18,
|
||||||
|
"rooms_boost": 1.6,
|
||||||
|
"floor_sigma": 0.25,
|
||||||
|
"min_comps": 4,
|
||||||
|
"mad_k": 3.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_anchor_fires_without_radius_analogs() -> None:
|
def test_estimate_anchor_fires_without_radius_analogs() -> None:
|
||||||
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
||||||
same-building комплы есть → якорь обязан сработать и дать median>0.
|
same-building комплы есть → якорь обязан сработать и дать median>0.
|
||||||
|
|
@ -529,7 +550,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
|
||||||
|
|
||||||
def test_estimate_economy_no_regression() -> None:
|
def test_estimate_economy_no_regression() -> None:
|
||||||
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
|
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
|
||||||
4 компла — satisfies estimate_sb_min_comps=4 (#755).
|
4 компла — satisfies SB_MIN_COMPS=4 (#755).
|
||||||
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
|
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
|
||||||
(все отклонения << 3.5×MAD при tight spread)."""
|
(все отклонения << 3.5×MAD при tight spread)."""
|
||||||
eco_comps = [
|
eco_comps = [
|
||||||
|
|
@ -598,7 +619,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
|
||||||
def test_estimate_range_covers_same_building_comp_spread() -> None:
|
def test_estimate_range_covers_same_building_comp_spread() -> None:
|
||||||
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
|
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
|
||||||
вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).
|
вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).
|
||||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||||
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
|
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
|
||||||
view_comps = [
|
view_comps = [
|
||||||
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
|
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
|
||||||
|
|
@ -665,7 +686,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
|
||||||
def test_estimate_analogs_pass_through_display_fields() -> None:
|
def test_estimate_analogs_pass_through_display_fields() -> None:
|
||||||
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
|
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
|
||||||
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
|
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
|
||||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||||
comps_with_display = [
|
comps_with_display = [
|
||||||
{
|
{
|
||||||
"price_per_m2": 399_478,
|
"price_per_m2": 399_478,
|
||||||
|
|
@ -1040,16 +1061,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
|
||||||
assert "по 4 аналогам" in (est.confidence_explanation or "")
|
assert "по 4 аналогам" in (est.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_radius_path_n_analogs_unchanged() -> None:
|
||||||
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
|
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
|
||||||
|
|
||||||
Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source
|
Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source
|
||||||
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно
|
дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных
|
||||||
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF,
|
физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1.
|
||||||
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
|
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)]
|
||||||
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
|
|
||||||
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
|
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
|
||||||
assert est.n_analogs == 5
|
assert est.n_analogs == 5
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ through `build_curl_cffi_session` — so a missing `config=` does NOT raise like
|
||||||
(non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in
|
(non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in
|
||||||
prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a
|
prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a
|
||||||
caller that forgets `config=` here gets no signal at all.
|
caller that forgets `config=` here gets no signal at all.
|
||||||
|
|
||||||
|
#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы
|
||||||
|
`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом
|
||||||
|
«без config — TypeError, фетчер и сессия не строятся».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import (
|
||||||
|
|
||||||
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
|
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
|
||||||
_TEST_PROXY = "http://mobile-proxy.example:8080"
|
_TEST_PROXY = "http://mobile-proxy.example:8080"
|
||||||
|
_CONFIG = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||||||
|
|
||||||
|
|
||||||
def _spy_browser_fetcher(html: str) -> MagicMock:
|
def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
@ -77,21 +82,6 @@ async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом — сигнатура
|
|
||||||
больше не роняет TypeError на отсутствующем обязательном параметре конструктора."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "cian"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ─────────────────────
|
# ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,21 +105,6 @@ async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_yandex_fetch_jk_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
scraper = YandexNewbuildingScraper()
|
|
||||||
await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "yandex"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── yandex.newbuilding.resolve_yandex_jk_slug ────────────────────────────────
|
# ── yandex.newbuilding.resolve_yandex_jk_slug ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,20 +126,6 @@ async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
await resolve_yandex_jk_slug("1592987")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "yandex"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
|
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
|
||||||
#
|
#
|
||||||
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
|
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
|
||||||
|
|
@ -195,9 +156,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
|
||||||
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
||||||
"""
|
"""
|
||||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
await resolve_yandex_jk_slug("286394")
|
await resolve_yandex_jk_slug("286394", config=_CONFIG)
|
||||||
|
|
||||||
url = spy.return_value.fetch.call_args[0][0]
|
url = spy.return_value.fetch.call_args[0][0]
|
||||||
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
||||||
|
|
@ -208,9 +169,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
|
||||||
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
|
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
|
||||||
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
|
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
|
||||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") == "uspenskij"
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) == "uspenskij"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
|
||||||
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
||||||
"""
|
"""
|
||||||
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") is None
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
|
||||||
spy = _spy_browser_fetcher(
|
spy = _spy_browser_fetcher(
|
||||||
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
|
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") is None
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
|
||||||
|
|
||||||
|
|
||||||
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
||||||
|
|
@ -276,17 +237,33 @@ async def test_resolve_cian_zhk_url_via_search_passes_proxy_from_config(monkeypa
|
||||||
assert kwargs.get("proxy_url") == _TEST_PROXY
|
assert kwargs.get("proxy_url") == _TEST_PROXY
|
||||||
|
|
||||||
|
|
||||||
|
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch):
|
@pytest.mark.parametrize(
|
||||||
"""Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher
|
"call",
|
||||||
assertion выше в этом файле), но proxy_url молча становится None — резолвер уходит
|
[
|
||||||
на прямое (непроксированное) соединение вместо настроенного мобильного прокси."""
|
lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"),
|
||||||
spy = _spy_curl_session(_SERP_HTML)
|
lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"),
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy)
|
lambda: resolve_yandex_jk_slug("1592987"),
|
||||||
|
lambda: resolve_cian_zhk_url_via_search(48853),
|
||||||
|
],
|
||||||
|
ids=["cian_fetch_newbuilding", "yandex_fetch_jk", "resolve_yandex_slug", "resolve_cian_url"],
|
||||||
|
)
|
||||||
|
async def test_newbuilding_entrypoints_refuse_to_run_without_config(monkeypatch, call):
|
||||||
|
"""Вызов без config= падает TypeError ДО сети: браузерный фетчер и curl-сессия не
|
||||||
|
строятся. Раньше тот же вызов молча уходил на env-узел сайдкара мимо пула (#2767,
|
||||||
|
#3197) — отказ по сигнатуре единственное, что ловит забывчивого вызывающего."""
|
||||||
|
browser_spy = _spy_browser_fetcher("<html></html>")
|
||||||
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", browser_spy)
|
||||||
|
session_spy = _spy_curl_session(_SERP_HTML)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", session_spy
|
||||||
|
)
|
||||||
|
|
||||||
url = await resolve_cian_zhk_url_via_search(48853)
|
with pytest.raises(TypeError, match="config"):
|
||||||
|
await call()
|
||||||
|
|
||||||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" # "works" — silently unproxied
|
assert browser_spy.call_count == 0
|
||||||
spy.assert_called_once()
|
assert session_spy.call_count == 0
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("proxy_url") is None
|
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _mock_settings(
|
def _mock_settings(scraper_proxy_url: str | None) -> SimpleNamespace:
|
||||||
scraper_proxy_url: str | None,
|
|
||||||
yandex_cookies_file: str | None = None,
|
|
||||||
) -> SimpleNamespace:
|
|
||||||
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
|
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
scraper_proxy_url=scraper_proxy_url,
|
scraper_proxy_url=scraper_proxy_url,
|
||||||
|
|
@ -52,7 +49,6 @@ def _mock_settings(
|
||||||
# removed — dead mobileproxy accounts). Mirrored here for test compat.
|
# removed — dead mobileproxy accounts). Mirrored here for test compat.
|
||||||
cian_proxy_url=scraper_proxy_url,
|
cian_proxy_url=scraper_proxy_url,
|
||||||
yandex_proxy_url=scraper_proxy_url,
|
yandex_proxy_url=scraper_proxy_url,
|
||||||
yandex_cookies_file=yandex_cookies_file,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -199,6 +195,10 @@ async def test_yandex_valuation_session_no_proxies_when_none():
|
||||||
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
||||||
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
||||||
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
||||||
|
# config обязателен (#2385): фетчер строит фабрика _base.build_browser_fetcher.
|
||||||
|
_NB_CONFIG = SimpleNamespace(
|
||||||
|
browser_http_endpoint="http://tradein-browser:3000", use_proxy_pool_browser=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -222,8 +222,8 @@ async def test_cian_newbuilding_own_session_receives_proxies():
|
||||||
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
||||||
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
|
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
|
||||||
await cian_newbuilding.fetch_newbuilding(zhk_url)
|
await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG)
|
||||||
|
|
||||||
# BrowserFetcher.fetch вызван с ЖК-url
|
# BrowserFetcher.fetch вызван с ЖК-url
|
||||||
assert fetch_calls == [zhk_url]
|
assert fetch_calls == [zhk_url]
|
||||||
|
|
@ -253,8 +253,10 @@ async def test_cian_newbuilding_shared_session_not_recreated():
|
||||||
|
|
||||||
sentinel_session = MagicMock(name="external_session")
|
sentinel_session = MagicMock(name="external_session")
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
|
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
|
||||||
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session)
|
await cian_newbuilding.fetch_newbuilding(
|
||||||
|
zhk_url, config=_NB_CONFIG, session=sentinel_session
|
||||||
|
)
|
||||||
|
|
||||||
# BrowserFetcher.fetch вызван — именно через него тянется страница
|
# BrowserFetcher.fetch вызван — именно через него тянется страница
|
||||||
assert fetch_calls == [zhk_url]
|
assert fetch_calls == [zhk_url]
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
||||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||||
|
|
@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
||||||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||||
assert tier is None
|
assert tier is None
|
||||||
|
|
@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
||||||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||||
assert tier is None
|
assert tier is None
|
||||||
|
|
@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||||
|
|
@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||||
|
|
@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
||||||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -285,3 +285,88 @@ def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) ->
|
||||||
)
|
)
|
||||||
assert r.status_code == 422, r.text
|
assert r.status_code == 422, r.text
|
||||||
assert not db.execute.called
|
assert not db.execute.called
|
||||||
|
|
||||||
|
|
||||||
|
# ── #1971: уведомление ответственному в support-топик ─────────────────────────
|
||||||
|
|
||||||
|
_NOTIFY_LEAD_ID = "00000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def telegram(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
|
||||||
|
"""Бот настроен, клиент и сессия фоновой задачи подменены."""
|
||||||
|
from app.api.v1 import lead as lead_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(lead_module.settings, "telegram_bot_token", "fake-token")
|
||||||
|
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", -100123456789)
|
||||||
|
monkeypatch.setattr(lead_module.settings, "telegram_support_topic_id", 2)
|
||||||
|
fake_client = SimpleNamespace(send_message=AsyncMock(return_value={"message_id": 7}))
|
||||||
|
monkeypatch.setattr(lead_module, "get_telegram_client", lambda: fake_client)
|
||||||
|
notify_db = MagicMock()
|
||||||
|
monkeypatch.setattr(lead_module, "SessionLocal", lambda: notify_db)
|
||||||
|
return SimpleNamespace(client=fake_client, db=notify_db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_notifies_topic_without_phone_and_sets_notified_at(
|
||||||
|
client: TestClient, db: MagicMock, telegram: SimpleNamespace
|
||||||
|
) -> None:
|
||||||
|
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+7 (912) 345-67-89", "consent": True},
|
||||||
|
headers={"X-Authenticated-User": "alice"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
telegram.client.send_message.assert_awaited_once()
|
||||||
|
kwargs = telegram.client.send_message.await_args.kwargs
|
||||||
|
assert kwargs["chat_id"] == -100123456789
|
||||||
|
assert kwargs["message_thread_id"] == 2
|
||||||
|
assert _NOTIFY_LEAD_ID in kwargs["text"]
|
||||||
|
assert "alice" in kwargs["text"]
|
||||||
|
# Телефон в Telegram не уходит ни в каком написании.
|
||||||
|
assert "9123456789" not in "".join(ch for ch in kwargs["text"] if ch.isdigit())
|
||||||
|
|
||||||
|
sql, params = telegram.db.execute.call_args.args
|
||||||
|
assert "SET notified_at = now()" in str(sql)
|
||||||
|
assert params == {"id": _NOTIFY_LEAD_ID}
|
||||||
|
assert telegram.db.commit.called
|
||||||
|
assert telegram.db.close.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_telegram_failure_keeps_200_and_notified_at_empty(
|
||||||
|
client: TestClient, db: MagicMock, telegram: SimpleNamespace
|
||||||
|
) -> None:
|
||||||
|
from app.services.tgbot.client import TelegramError
|
||||||
|
|
||||||
|
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
|
||||||
|
telegram.client.send_message.side_effect = TelegramError("boom")
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True},
|
||||||
|
headers={"X-Authenticated-User": "alice"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["id"] == _NOTIFY_LEAD_ID
|
||||||
|
assert db.commit.called # лид сохранён до попытки уведомления
|
||||||
|
telegram.client.send_message.assert_awaited_once()
|
||||||
|
assert not telegram.db.execute.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_bot_not_configured_skips_notification(
|
||||||
|
client: TestClient,
|
||||||
|
db: MagicMock,
|
||||||
|
telegram: SimpleNamespace,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from app.api.v1 import lead as lead_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", 0)
|
||||||
|
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
|
||||||
|
|
||||||
|
r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
telegram.client.send_message.assert_not_awaited()
|
||||||
|
assert not telegram.db.execute.called
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,46 @@ def test_first_capture_previous_equals_current_inserts_one_point(db: Session) ->
|
||||||
assert len(_history(db, lid)) == 1
|
assert len(_history(db, lid)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("previous", "current"),
|
||||||
|
[
|
||||||
|
(1_370_000, 13_700_000), # прод 3380975: ×10, 15.6 тыс/м² у нижней
|
||||||
|
(972_130, 9_472_130), # прод 10365196: ×9.74 — сдвиг вместе с мелкой сменой
|
||||||
|
(10_900, 10_900_000), # прод 1879742: ×1000, цена в тысячах
|
||||||
|
(13_700_000, 1_370_000), # ÷10 — синтетическую точку на порядок не сеем и вниз
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_first_capture_previous_off_by_order_is_not_seeded(
|
||||||
|
db: Session, previous: int, current: int
|
||||||
|
) -> None:
|
||||||
|
"""#3385: price.previous на порядок от текущей → в истории одна текущая точка."""
|
||||||
|
lid = _seed_listing(db, source="yandex", source_id="3385")
|
||||||
|
n = yph.record_yandex_price_history(
|
||||||
|
db, [_lot(source_id="3385", price_rub=current, price_previous_rub=previous)]
|
||||||
|
)
|
||||||
|
assert n == 1
|
||||||
|
assert [price for price, _ in _history(db, lid)] == [float(current)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("previous", "current"),
|
||||||
|
[
|
||||||
|
(12_900_000, 13_700_000),
|
||||||
|
(8_225_550, 17_500_000), # прод 10359546: ×2.13 — самый большой честный посев
|
||||||
|
(1_450_000, 13_700_000), # ×9.45 — ниже порога, посев остаётся
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_first_capture_previous_within_order_is_seeded(
|
||||||
|
db: Session, previous: int, current: int
|
||||||
|
) -> None:
|
||||||
|
lid = _seed_listing(db, source="yandex", source_id="3385b")
|
||||||
|
n = yph.record_yandex_price_history(
|
||||||
|
db, [_lot(source_id="3385b", price_rub=current, price_previous_rub=previous)]
|
||||||
|
)
|
||||||
|
assert n == 2
|
||||||
|
assert [price for price, _ in _history(db, lid)] == [float(previous), float(current)]
|
||||||
|
|
||||||
|
|
||||||
def test_recapture_same_price_is_noop(db: Session) -> None:
|
def test_recapture_same_price_is_noop(db: Session) -> None:
|
||||||
"""Повторный захват с той же ценой → 0 строк (idempotent)."""
|
"""Повторный захват с той же ценой → 0 строк (idempotent)."""
|
||||||
_seed_listing(db, source="yandex", source_id="333")
|
_seed_listing(db, source="yandex", source_id="333")
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ def test_yandex_detail_picks_up_delay():
|
||||||
|
|
||||||
def test_yandex_newbuilding_picks_up_delay():
|
def test_yandex_newbuilding_picks_up_delay():
|
||||||
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name."""
|
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name."""
|
||||||
s = YandexNewbuildingScraper(delay_provider=lambda _name: 9.5)
|
s = YandexNewbuildingScraper(config=_KIT_CONFIG, delay_provider=lambda _name: 9.5)
|
||||||
assert s.request_delay_sec == 9.5
|
assert s.request_delay_sec == 9.5
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,12 @@ Per-provider модель (#1793):
|
||||||
поиск yandex.ru, а не голым goto (#3251). Ручная сессия
|
поиск yandex.ru, а не голым goto (#3251). Ручная сессия
|
||||||
29.08.2026 показала эталонный человеческий путь:
|
29.08.2026 показала эталонный человеческий путь:
|
||||||
yandex.ru → клик по результату → выдача с Referer
|
yandex.ru → клик по результату → выдача с Referer
|
||||||
yandex.ru. Дефолт "domclick" — авито/циан/яндекс
|
yandex.ru. Дефолт — пусто, выключено везде (#3263):
|
||||||
проверяются отдельно в #3251, не включать здесь.
|
выдача Яндекса капчит конкретные выходные узлы пула,
|
||||||
Пустая строка = выключено везде (полный откат к
|
и путь срабатывал ~2 раза из 9. Якорь поднимается
|
||||||
прежнему поведению). Провайдер без соответствующего
|
прямым goto на выдачу площадки, Referer карточке даёт
|
||||||
|
переход с неё. Включать только явным env и только
|
||||||
|
после замера на узлах пула. Провайдер без соответствующего
|
||||||
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
|
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
|
||||||
делает — см. ниже.
|
делает — см. ниже.
|
||||||
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} — поисковый запрос на yandex.ru для
|
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} — поисковый запрос на yandex.ru для
|
||||||
|
|
@ -143,6 +145,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
@ -329,12 +332,15 @@ BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
|
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
|
||||||
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
|
# поиск yandex.ru, а не голым goto. Дефолт — пусто (#3263): капча на выдаче Яндекса —
|
||||||
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
|
# свойство выходного узла (замер владельца 29.08: узлы 1/10/11 — капча, 9 и домашний
|
||||||
# выключено везде — полный откат на поведение до #3251.
|
# IP — выдача), в проде путь срабатывал ~2 из 9, а в логах 15-16.09 все 7 капч
|
||||||
|
# закончились «якорная вкладка не поднялась (Error)» через 0,2 с после отката — якоря
|
||||||
|
# не было вовсе. Без поиска якорь сразу открывает выдачу площадки, карточка идёт с
|
||||||
|
# referer=origin — переход внутри площадки. Включать только явным env после замера.
|
||||||
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
|
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
|
||||||
p.strip().lower()
|
p.strip().lower()
|
||||||
for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",")
|
for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "").split(",")
|
||||||
if p.strip()
|
if p.strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -832,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн
|
||||||
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
|
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
|
||||||
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
|
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
|
||||||
_last_response_status: dict[str, int | None] = {}
|
_last_response_status: dict[str, int | None] = {}
|
||||||
|
# #3419: provider → длительность ЦЕЛЕВОЙ page.goto последнего /fetch в мс (None — до неё не
|
||||||
|
# дошли: упал прогрев origin, fetch_mode не navigate). Пишется в _fetch_once и на успехе,
|
||||||
|
# и на исключении goto; читается fetch_handler'ом под тем же локом для строки «fetch error»,
|
||||||
|
# а «fetch OK» печатает её сам. Нужна, чтобы порог BROWSER_NAV_TIMEOUT_MS сравнивать с
|
||||||
|
# распределением времени навигации, а не подбирать вслепую.
|
||||||
|
_last_nav_ms: dict[str, int | None] = {}
|
||||||
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
|
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
|
||||||
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
|
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
|
||||||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||||||
|
|
@ -895,10 +907,16 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
|
||||||
"block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK),
|
"block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK),
|
||||||
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
|
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
|
||||||
"block_webrtc": True,
|
"block_webrtc": True,
|
||||||
# Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект
|
# Таймзону НЕ задаём (#3187): её ставит geoip=True по exit-IP на запуске —
|
||||||
# ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) →
|
# из той же GeoIP-записи, что и координаты geolocation. Жёсткий
|
||||||
# геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного.
|
# Europe/Moscow в config (было) camoufox 0.5.5 применял поверх geoip
|
||||||
"config": {"timezone": "Europe/Moscow"},
|
# (timezone — setdefault), а координаты брал из IP: для узлов вне UTC+3
|
||||||
|
# браузер заявлял МСК при новосибирских координатах и адресе (узлы 13/15 —
|
||||||
|
# T2 NSK, UTC+7; 14 — МегаФон Самара, UTC+4 по RIPE, 17.09.2026) и писал
|
||||||
|
# LeakWarning «Please use the geoip parameter» на каждом запуске. Ротация
|
||||||
|
# узла пула меняет IP внутри той же подсети оператора (health-логи 48 ч:
|
||||||
|
# 176.59.137.x/176.59.144.x у 15, 178.176.78-79.x у 14), а смена узла
|
||||||
|
# релончит браузер (_launched_proxy) — зона, взятая на запуске, не протухает.
|
||||||
}
|
}
|
||||||
if proxy is not None:
|
if proxy is not None:
|
||||||
kwargs["proxy"] = proxy
|
kwargs["proxy"] = proxy
|
||||||
|
|
@ -1454,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
status = _last_response_status.get(provider)
|
status = _last_response_status.get(provider)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"tradein-browser[%s]: fetch error url=%r: %s: %s",
|
"tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s",
|
||||||
provider,
|
provider,
|
||||||
|
_last_nav_ms.get(provider),
|
||||||
url,
|
url,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
exc,
|
||||||
|
|
@ -2349,6 +2368,7 @@ async def _fetch_once(
|
||||||
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
|
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
|
||||||
# уехать статус предыдущей страницы этого же провайдера (#3196).
|
# уехать статус предыдущей страницы этого же провайдера (#3196).
|
||||||
_last_response_status[provider] = None
|
_last_response_status[provider] = None
|
||||||
|
_last_nav_ms[provider] = None
|
||||||
|
|
||||||
if reset_context:
|
if reset_context:
|
||||||
await _close_reusable_context(provider)
|
await _close_reusable_context(provider)
|
||||||
|
|
@ -2423,7 +2443,11 @@ async def _fetch_once(
|
||||||
}
|
}
|
||||||
if referer:
|
if referer:
|
||||||
goto_kwargs["referer"] = referer
|
goto_kwargs["referer"] = referer
|
||||||
|
nav_started = time.monotonic()
|
||||||
|
try:
|
||||||
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
||||||
|
finally:
|
||||||
|
_last_nav_ms[provider] = int((time.monotonic() - nav_started) * 1000)
|
||||||
_last_response_status[provider] = _status_of(response)
|
_last_response_status[provider] = _status_of(response)
|
||||||
if BROWSER_WAIT_MS > 0:
|
if BROWSER_WAIT_MS > 0:
|
||||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||||
|
|
@ -2510,9 +2534,12 @@ async def _fetch_once(
|
||||||
await page.close() # type: ignore[attr-defined]
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
||||||
logger.debug(
|
# INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не
|
||||||
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d",
|
# снять, а сравнивать его с таймаутами и есть цель.
|
||||||
|
logger.info(
|
||||||
|
"tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d",
|
||||||
provider,
|
provider,
|
||||||
|
_last_nav_ms.get(provider),
|
||||||
url,
|
url,
|
||||||
_page_counters[provider],
|
_page_counters[provider],
|
||||||
)
|
)
|
||||||
|
|
@ -2811,12 +2838,16 @@ async def login_handler(request: web.Request) -> web.Response:
|
||||||
return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
|
return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
|
||||||
|
|
||||||
provider = _resolve_provider(body, body["url"])
|
provider = _resolve_provider(body, body["url"])
|
||||||
|
# #3410: прокси из пула в теле — как у /fetch. Без него логин шёл только с env-узла
|
||||||
|
# сайдкара (на проде — выключенный узел, InvalidIP), и ручка восстановления сессии
|
||||||
|
# не работала при живом пуле. relaunch при смене прокси — внутри _ensure_browser.
|
||||||
|
proxy_override = _resolve_proxy_override(body, provider)
|
||||||
|
|
||||||
# #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) →
|
# #2616 шаг 1: см. fetch_handler — прод + нет НИ override, НИ env-прокси →
|
||||||
# отказ, не direct-IP.
|
# отказ, не direct-IP.
|
||||||
if IS_PROD and _no_live_proxy(provider, None):
|
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — "
|
"tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — "
|
||||||
"не подключаемся напрямую с IP сервера (#2616)",
|
"не подключаемся напрямую с IP сервера (#2616)",
|
||||||
provider,
|
provider,
|
||||||
)
|
)
|
||||||
|
|
@ -2828,7 +2859,7 @@ async def login_handler(request: web.Request) -> web.Response:
|
||||||
async with lock:
|
async with lock:
|
||||||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||||
# Lazy-попытка, иначе 503 — без прокси не логинимся.
|
# Lazy-попытка, иначе 503 — без прокси не логинимся.
|
||||||
if not await _ensure_browser(provider):
|
if not await _ensure_browser(provider, proxy_override=proxy_override):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
|
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
|
||||||
provider,
|
provider,
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,12 @@ _HOSTNAME = "ekaterinburg.domclick.ru"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_state() -> None:
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
server._contexts = {}
|
server._contexts = {}
|
||||||
server._anchor_pages = {}
|
server._anchor_pages = {}
|
||||||
|
# Путь через поиск выключен по умолчанию (#3263) — тесты ниже проверяют его
|
||||||
|
# поведение при ЯВНОМ включении env'ом BROWSER_ANCHOR_VIA_SEARCH=domclick.
|
||||||
|
monkeypatch.setattr(server, "_ANCHOR_VIA_SEARCH_PROVIDERS", frozenset({"domclick"}))
|
||||||
|
|
||||||
|
|
||||||
class _FakeLink:
|
class _FakeLink:
|
||||||
|
|
@ -158,7 +161,7 @@ def _run_ensure_anchor(provider: str, origin: str, page: _FakeAnchorPage) -> boo
|
||||||
|
|
||||||
|
|
||||||
def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
|
def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
|
||||||
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост."""
|
"""domclick включён env'ом: заход на yandex.ru → клик по ссылке на хост."""
|
||||||
page = _FakeAnchorPage(link_host=_HOSTNAME)
|
page = _FakeAnchorPage(link_host=_HOSTNAME)
|
||||||
|
|
||||||
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
|
||||||
|
|
@ -202,7 +205,7 @@ def test_no_link_in_serp_falls_back_without_referer() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_provider_not_in_allowlist_skips_search_entirely() -> None:
|
def test_provider_not_in_allowlist_skips_search_entirely() -> None:
|
||||||
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее."""
|
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (включён только domclick) — поведение прежнее."""
|
||||||
origin = "https://www.avito.ru/ekaterinburg/kvartiry"
|
origin = "https://www.avito.ru/ekaterinburg/kvartiry"
|
||||||
page = _FakeAnchorPage(link_host="www.avito.ru")
|
page = _FakeAnchorPage(link_host="www.avito.ru")
|
||||||
|
|
||||||
|
|
@ -255,3 +258,28 @@ def test_new_tab_wrong_host_falls_back_without_referer() -> None:
|
||||||
assert origin_calls[0]["referer"] is None
|
assert origin_calls[0]["referer"] is None
|
||||||
assert page.opened_popup is not None
|
assert page.opened_popup is not None
|
||||||
assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт"
|
assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_env_domclick_anchor_goes_straight_to_origin(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""#3263: без env якорь Домклика открывает свою выдачу прямым goto, yandex.ru не трогает.
|
||||||
|
|
||||||
|
Модуль грузится заново с удалённым env — проверяется именно код-дефолт, а не
|
||||||
|
значение, подставленное фикстурой. Капча на выдаче Яндекса включена в подделке:
|
||||||
|
если путь через поиск снова станет дефолтом, goto на yandex.ru попадёт в
|
||||||
|
goto_calls и тест покраснеет.
|
||||||
|
"""
|
||||||
|
monkeypatch.delenv("BROWSER_ANCHOR_VIA_SEARCH", raising=False)
|
||||||
|
spec = importlib.util.spec_from_file_location("tradein_browser_server_anchor_default", _SERVER_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
fresh = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(fresh)
|
||||||
|
|
||||||
|
page = _FakeAnchorPage(link_host=_HOSTNAME, captcha=True)
|
||||||
|
fresh._contexts["domclick"] = _FakeAnchorContext(page)
|
||||||
|
anchored = asyncio.run(fresh._ensure_anchor_page("domclick", _ORIGIN))
|
||||||
|
|
||||||
|
assert anchored is True
|
||||||
|
assert fresh._anchor_pages["domclick"] is page
|
||||||
|
assert page.goto_calls == [{"url": _ORIGIN, "referer": None}]
|
||||||
|
|
|
||||||
156
tradein-mvp/browser/test_server_nav_timing.py
Normal file
156
tradein-mvp/browser/test_server_nav_timing.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""test_server_nav_timing.py — длительность целевой навигации в логах /fetch (#3419).
|
||||||
|
|
||||||
|
Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался на DEBUG
|
||||||
|
и без длительности, «fetch error» — тоже без неё. Теперь обе строки несут nav_ms —
|
||||||
|
время ЦЕЛЕВОЙ page.goto (без прогрева origin, пейсинга и BROWSER_WAIT_MS).
|
||||||
|
|
||||||
|
Часы подменяются только в модуле сервера (server.time), event loop их не видит.
|
||||||
|
camoufox НЕ запускается: browser/page поддельные.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_nav_timing.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("tradein_browser_server_nav_timing", _SERVER_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
server = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(server)
|
||||||
|
|
||||||
|
_ORIGIN = "https://www.cian.ru/"
|
||||||
|
_CARD = "https://ekb.cian.ru/sale/flat/1/"
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class _Page:
|
||||||
|
"""goto двигает поддельные часы на заданное время; на origin или по флагу — падает."""
|
||||||
|
|
||||||
|
def __init__(self, clock: _Clock, nav_s: float, *, fail_target: bool, fail_origin: bool):
|
||||||
|
self._clock = clock
|
||||||
|
self._nav_s = nav_s
|
||||||
|
self._fail_target = fail_target
|
||||||
|
self._fail_origin = fail_origin
|
||||||
|
|
||||||
|
async def route(self, pattern: str, handler: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
|
if url == _ORIGIN:
|
||||||
|
if self._fail_origin:
|
||||||
|
raise TimeoutError("origin goto timeout")
|
||||||
|
return None
|
||||||
|
self._clock.now += self._nav_s
|
||||||
|
if self._fail_target:
|
||||||
|
raise TimeoutError("Page.goto: Timeout 60000ms exceeded.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def content(self) -> str:
|
||||||
|
return "<html><body>карточка</body></html>"
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Browser:
|
||||||
|
def __init__(self, page: _Page) -> None:
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
async def new_page(self) -> _Page:
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> _Clock:
|
||||||
|
for name in ("_browsers", "_page_counters", "_locks", "_last_goto_at",
|
||||||
|
"_last_response_status", "_last_nav_ms", "_launched_proxy"):
|
||||||
|
monkeypatch.setattr(server, name, {})
|
||||||
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
|
monkeypatch.setattr(server, "IS_PROD", False)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
|
||||||
|
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
|
||||||
|
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
|
||||||
|
monkeypatch.setattr(server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000))
|
||||||
|
|
||||||
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
clock = _Clock()
|
||||||
|
monkeypatch.setattr(server, "time", SimpleNamespace(monotonic=clock.monotonic))
|
||||||
|
return clock
|
||||||
|
|
||||||
|
|
||||||
|
async def _coro(value: Any) -> Any:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(page: _Page, body: dict[str, Any]) -> int:
|
||||||
|
server._browsers["cian"] = _Browser(page)
|
||||||
|
request = make_mocked_request("POST", "/fetch")
|
||||||
|
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||||
|
return asyncio.run(server.fetch_handler(request)).status
|
||||||
|
|
||||||
|
|
||||||
|
def _nav_ms(caplog: pytest.LogCaptureFixture, marker: str) -> list[int | None]:
|
||||||
|
"""nav_ms из строк лога с маркером, по порядку: число или None."""
|
||||||
|
values: list[int | None] = []
|
||||||
|
for record in caplog.records:
|
||||||
|
message = record.getMessage()
|
||||||
|
if marker in message:
|
||||||
|
match = re.search(r"nav_ms=(\d+|None)\b", message)
|
||||||
|
assert match is not None, f"в строке нет nav_ms: {message}"
|
||||||
|
values.append(None if match.group(1) == "None" else int(match.group(1)))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_logs_target_navigation_ms_at_info(
|
||||||
|
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
page = _Page(_reset_state, 12.345, fail_target=False, fail_origin=False)
|
||||||
|
with caplog.at_level(logging.INFO, logger=server.logger.name):
|
||||||
|
status = _fetch(page, {"url": _CARD, "origin": _ORIGIN})
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert _nav_ms(caplog, "[cian]: fetch OK") == [12345]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_logs_navigation_ms_then_prenav_failure_logs_none(
|
||||||
|
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Таймаут цели несёт своё время; следующий отказ ДО цели не наследует прошлое число."""
|
||||||
|
with caplog.at_level(logging.INFO, logger=server.logger.name):
|
||||||
|
first = _fetch(
|
||||||
|
_Page(_reset_state, 60.0007, fail_target=True, fail_origin=False),
|
||||||
|
{"url": _CARD, "origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
second = _fetch(
|
||||||
|
_Page(_reset_state, 5.0, fail_target=False, fail_origin=True),
|
||||||
|
{"url": _CARD, "origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (first, second) == (500, 500)
|
||||||
|
assert _nav_ms(caplog, "[cian]: fetch error") == [60000, None]
|
||||||
|
|
@ -267,6 +267,52 @@ def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.Mo
|
||||||
assert calls == [("avito", "http://pool:8080")]
|
assert calls == [("avito", "http://pool:8080")]
|
||||||
|
|
||||||
|
|
||||||
|
_LOGIN_BODY: dict[str, Any] = {
|
||||||
|
"url": "https://www.cian.ru/login",
|
||||||
|
"email": "a@b.ru",
|
||||||
|
"password": "x",
|
||||||
|
"email_selector": "#e",
|
||||||
|
"password_selector": "#p",
|
||||||
|
"submit_selector": "#s",
|
||||||
|
"success_cookie": "sid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("extra", "expected_override"),
|
||||||
|
[({"proxy": "http://pool:8080", "proxy_kind": "http"}, "http://pool:8080"), ({}, None)],
|
||||||
|
)
|
||||||
|
def test_login_handler_prod_uses_body_proxy(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, extra: dict[str, Any], expected_override: str | None
|
||||||
|
) -> None:
|
||||||
|
"""#3410: /login берёт прокси из тела, как /fetch; без него — прежний env-путь.
|
||||||
|
|
||||||
|
С прокси в теле и БЕЗ env-прокси прод-guard не отказывает (узел пула есть), а
|
||||||
|
браузер поднимается ровно с этим узлом. Без прокси в теле, но с env — override None.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(server, "IS_PROD", True)
|
||||||
|
if not extra:
|
||||||
|
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||||||
|
calls: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
calls.append((provider, proxy_override))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _fake_do_login(provider: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return [{"name": "sid", "value": "1"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
monkeypatch.setattr(server, "_do_login", _fake_do_login)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
server.login_handler(_make_request("/login", {**_LOGIN_BODY, **extra}))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert calls == [("cian", expected_override)]
|
||||||
|
|
||||||
|
|
||||||
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────
|
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,3 +149,46 @@ def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
||||||
asyncio.run(_run())
|
asyncio.run(_run())
|
||||||
assert calls == [("avito", None), ("avito", "http://pool:8080")]
|
assert calls == [("avito", None), ("avito", "http://pool:8080")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── #3187: гео-параметры запуска camoufox ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_leaves_timezone_to_geoip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Зона и координаты — только от geoip по exit-IP, вручную в config не задаются.
|
||||||
|
|
||||||
|
camoufox 0.5.5 (utils.launch_options): geoip кладёт в config координаты
|
||||||
|
безусловно, а timezone — через setdefault, то есть ручной config["timezone"]
|
||||||
|
перебивает зону из IP, и координаты с зоной расходятся (узел в Новосибирске
|
||||||
|
с заявленной МСК). Ключи 'timezone' и 'geolocation:*' в config — ровно те,
|
||||||
|
на которые библиотека пишет LeakWarning (warn_manual_config).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeCamoufox:
|
||||||
|
def __init__(self, **kwargs: object) -> None:
|
||||||
|
captured.update(kwargs)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return object()
|
||||||
|
|
||||||
|
fake_mod = types.ModuleType("camoufox.async_api")
|
||||||
|
fake_mod.AsyncCamoufox = _FakeCamoufox # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
|
||||||
|
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
|
||||||
|
|
||||||
|
asyncio.run(server._launch_browser("avito", proxy_override="http://u:p@176.59.137.239:8080"))
|
||||||
|
|
||||||
|
assert captured["geoip"] is True
|
||||||
|
assert captured["proxy"] == {
|
||||||
|
"server": "http://176.59.137.239:8080",
|
||||||
|
"username": "u",
|
||||||
|
"password": "p",
|
||||||
|
}
|
||||||
|
config = captured.get("config", {})
|
||||||
|
assert isinstance(config, dict)
|
||||||
|
manual_geo = sorted(k for k in config if k == "timezone" or k.startswith("geolocation:"))
|
||||||
|
assert manual_geo == []
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,11 @@ services:
|
||||||
# деградация. У scraper те же значения с #2126/#2160.
|
# деградация. У scraper те же значения с #2126/#2160.
|
||||||
USE_PROXY_POOL_CURL: "true"
|
USE_PROXY_POOL_CURL: "true"
|
||||||
USE_PROXY_POOL_BROWSER: "true"
|
USE_PROXY_POOL_BROWSER: "true"
|
||||||
|
# #3182: ручка админки scrape_avito_detail_backfill исполняет добор В ЭТОМ
|
||||||
|
# процессе. Хостовый backend/.env.runtime задаёт true (curl не проходит QRATOR
|
||||||
|
# PoW, см. scraper ниже) — environment перекрывает env_file. Значение одно на
|
||||||
|
# все сервисы образа backend, чтобы режим не зависел от того, кто взял задачу.
|
||||||
|
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
browser:
|
browser:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
@ -453,6 +458,8 @@ services:
|
||||||
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
|
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
|
||||||
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
|
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
|
||||||
SCHEDULER_ENABLE: "false"
|
SCHEDULER_ENABLE: "false"
|
||||||
|
# #3182: то же значение, что у backend/scraper (см. комментарий у backend).
|
||||||
|
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -216,12 +216,12 @@ describe("карточка игры выбирает карту по налич
|
||||||
});
|
});
|
||||||
|
|
||||||
it("схема есть — показана улица", () => {
|
it("схема есть — показана улица", () => {
|
||||||
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} />);
|
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(SCHEME), 0)]} bandSelected />);
|
||||||
expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
|
expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("схемы нет — остаётся район, а не пустое место", () => {
|
it("схемы нет — остаётся район, а не пустое место", () => {
|
||||||
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} />);
|
const { container } = render(<GuessGameV3 rounds={[toGameRound(deal(null), 0)]} bandSelected />);
|
||||||
expect(container.textContent).toContain("ЛЕНИНСКИЙ");
|
expect(container.textContent).toContain("ЛЕНИНСКИЙ");
|
||||||
expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
|
expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
* ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
|
* ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
|
||||||
* `/stats` в проде выглядит именно так: пустой объект.
|
* `/stats` в проде выглядит именно так: пустой объект.
|
||||||
*/
|
*/
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
||||||
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
|
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
|
||||||
|
import { GuessGameV3 } from "../_components/v3/GuessGameV3";
|
||||||
|
import { toGameRound } from "../_components/v3/deal-view";
|
||||||
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
||||||
import { HeroV3 } from "../_components/v3/HeroV3";
|
import { HeroV3 } from "../_components/v3/HeroV3";
|
||||||
import { BACKTEST } from "../landing-facts";
|
import { BACKTEST } from "../landing-facts";
|
||||||
|
|
@ -315,3 +317,57 @@ describe("лента сделок: контекст разброса", () => {
|
||||||
expect(note.textContent).toContain("75,7 %");
|
expect(note.textContent).toContain("75,7 %");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ИТОГ ИГРЫ НАЗЫВАЕТ ОТБОР (#2903).
|
||||||
|
*
|
||||||
|
* Раунды — строки витрины, а витрина с 12.09.2026 отобрана по ошибке МЕРЫ
|
||||||
|
* (полоса −5 %..+20 %). Плитка «ошибка МЕРЫ на этих же квартирах» без
|
||||||
|
* оговорки читается как точность расчёта, хотя это лучший отобранный кусок:
|
||||||
|
* на проде 17.09 медиана |ошибки| двадцати строк 6,5 %, по всей сверке
|
||||||
|
* кратно больше. Проверяется по тексту итога, до которого доходим кликами.
|
||||||
|
*/
|
||||||
|
describe("игра: итог называет отбор раундов", () => {
|
||||||
|
const rounds = [0.58, -3.2, 17.9].map((err_pct, i) =>
|
||||||
|
toGameRound(
|
||||||
|
{
|
||||||
|
...SHOWCASE.deals[0],
|
||||||
|
rooms: i,
|
||||||
|
err_pct,
|
||||||
|
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
|
||||||
|
},
|
||||||
|
i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
for (let i = 0; i < rounds.length; i += 1) {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Ответить" }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: i === rounds.length - 1 ? "Показать итог" : "Следующая квартира →",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return screen.getByText(/Медиана расхождения МЕРЫ по всей сверке/u);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("строки витрины в полосе → итог говорит, что квартиры из отобранной полосы, и печатает медиану сверки", () => {
|
||||||
|
render(<GuessGameV3 rounds={rounds} bandSelected />);
|
||||||
|
const note = finish();
|
||||||
|
expect(screen.getByText("ошибка МЕРЫ на этих же квартирах")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
note.textContent,
|
||||||
|
"итог сравнивает игрока с МЕРОЙ на отобранных по её ошибке квартирах и молчит об отборе",
|
||||||
|
).toMatch(/отобранной полосы.*от -5 % до \+20 %.*не вся сверка/u);
|
||||||
|
expect(note.textContent).toContain(BACKTEST.priceError.text);
|
||||||
|
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("строки витрины вне полосы → про полосу молчит, медиана сверки остаётся", () => {
|
||||||
|
render(<GuessGameV3 rounds={rounds} bandSelected={false} />);
|
||||||
|
const note = finish();
|
||||||
|
expect(note.textContent).not.toMatch(/полос/u);
|
||||||
|
expect(note.textContent).toContain(BACKTEST.priceError.text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue