Пул прокси МЕРА: оплаченный выделенный узел снова выдаётся, бан не выбивает последний узел, прогон знает свой прокси #3565

Merged
bot-backend merged 11 commits from fix/proxy-pool-guards into main 2026-09-17 10:49:04 +00:00
119 changed files with 5181 additions and 1393 deletions
Showing only changes of commit 90b93b90b5 - Show all commits

View file

@ -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` — то есть

View file

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

View file

@ -77,7 +77,7 @@ on:
# исполнял бы старую версию — молча и без единого сигнала. # исполнял бы старую версию — молча и без единого сигнала.
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное # backup.sh, restore-drill.sh, restore.sh — точечное
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это # перечисление пришлось бы дополнять при каждом новом скрипте, и про это
# снова забыли бы (см. как этот самый комментарий выше был точечным про # снова забыли бы (см. как этот самый комментарий выше был точечным про
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.

View file

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

View file

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

View file

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

View file

@ -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"] = {

View file

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

View file

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

View file

@ -9,7 +9,7 @@ Selector logic:
- DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем - DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем
- force=True игнорирует фильтр, загружает все объекты последнего snapshot - force=True игнорирует фильтр, загружает все объекты последнего snapshot
Beat schedule: вторник 04:00 UTC (в beat_schedule.py). Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
""" """
from __future__ import annotations from __future__ import annotations

View file

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

View file

@ -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})"
)

View file

@ -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:
"""Транзиентный таймаут не должен трогать запасной канал. """Транзиентный таймаут не должен трогать запасной канал.

View file

@ -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, "отказ перезагрузки не роняет деплой"

View 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
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"

View 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-ПП"

View 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, ДОК)]

View 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)"
)

View file

@ -1919,17 +1919,17 @@ export interface paths {
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, * @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). * ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
* *
* Beat 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;

View file

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

View file

@ -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) — именно их

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.0817.09 (61 857 точек): 140
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
#
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
# заводится в UI GlitchTip, а не кодом.
- alert: MeraBackendDown
expr: |
up{job="app", app="mera"} == 0
or absent(up{job="app", app="mera"})
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
# Гейт по RPS внутри знаменателя — тот же приём, что у # Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть, # PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит # иначе один упавший запрос при нулевой нагрузке даёт 100% и будит

View file

@ -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.0817.09 — 4 нулевые точки подряд
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
# поднимать тревогу про «Меру».
- interval: 30s
input_series:
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
values: '1x19 0 0 0 0 1x40'
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '0x64'
alert_rule_test:
- eval_time: 11m
alertname: MeraBackendDown
exp_alerts: []
- eval_time: 30m
alertname: MeraBackendDown
exp_alerts: []
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
- interval: 30s
input_series:
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
values: '1x40'
alert_rule_test:
- eval_time: 10m
alertname: MeraBackendDown
exp_alerts:
- exp_labels:
severity: critical
host: apps
job: app
app: mera
exp_annotations:
summary: "Бэкенд «Меры» не отвечает"
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ───── # ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -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. Зависший

View file

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. 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:

View file

@ -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} в окне "

View file

@ -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²)×(1tol)
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
SB_MAD_K_SMALL_N = 2.5
SB_SMALL_N_THRESHOLD = 10
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
# FSD<0.15) проходят без изменений.
SB_GATE_MIN_N = 3
SB_GATE_MAX_FSD = 0.20
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -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)

View file

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

View file

@ -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.137 тыс/м².
# Посев синтетический (now-1d, реального времени смены gate не несёт), без него
# теряется только фиктивная точка — текущая цена пишется всё равно.
_SEED_IMPLAUSIBLE_RATIO = 9.5
# Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id. # Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id.
_RESOLVE_SQL = text( _RESOLVE_SQL = text(
""" """
@ -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

View file

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

View file

@ -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.137 тыс/м², у верхних
-- 118375 тыс/м²; верхняя совпадает с listings.price_rub у 10 из 11, а 10387563 дальше
-- дешевеет от неё же (14M → 13M → 12M, триггерные строки).
--
-- Код починен в том же PR: посев на порядок и больше больше не пишется. Эта миграция —
-- задним числом по уже записанным строкам.
--
-- КРИТЕРИЙ — ПОДПИСЬ ПОСЕВА, А НЕ ТОЛЬКО ОТНОШЕНИЕ
-- Удаляется строка o, только если у того же объявления есть строка s, записанная тем же
-- батчем (s.recorded_at = o.recorded_at) ровно через 24 часа (s.change_time =
-- o.change_time + 24 hours), и цены разнятся в 9.5 раза и больше. Так посевы пишет
-- только этот писатель; триггерные строки (change_time = recorded_at) и строки других
-- источников условием не задеваются. Интервал '24 hours', а не '1 day': прибавление
-- суток к timestamptz зависит от таймзоны сессии, а Python считает ровно 24 часа.
-- diff_percent у второй строки не трогаем: писатель его не заполняет, у всех 11 он NULL,
-- и после удаления посева вторая строка становится первой — NULL там и есть верное.
--
-- СКОЛЬКО ЖДЁМ
-- 11 строк на 17.09.2026 (7 × 10 и 4 × 175…1000). До деплоя кода новые посевы ещё
-- могут появиться (06.09 → 17.09 прибавился один). Порог остановки 30: поймали больше —
-- критерий ловит не то, миграция падает, а не молча чистит историю.
--
-- ИДЕМПОТЕНТНОСТЬ
-- Удалённый посев второй раз не находится, у оставшейся строки пары больше нет →
-- повторный прогон удаляет 0. Новых объектов схемы нет.
BEGIN;
-- Конвенция проекта (#2752): DELETE берёт блокировки строк offer_price_history; без
-- lock_timeout деплой встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
deleted_rows bigint;
BEGIN
DELETE FROM offer_price_history o
USING offer_price_history s
WHERE o.source = 'yandex'
AND s.source = 'yandex'
AND s.listing_id = o.listing_id
AND s.recorded_at = o.recorded_at
AND s.change_time = o.change_time + interval '24 hours'
AND o.price_rub > 0
AND s.price_rub > 0
AND greatest(o.price_rub, s.price_rub) / least(o.price_rub, s.price_rub) >= 9.5;
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
RAISE NOTICE 'offer_price_history: удалено yandex-посевов на порядок = %', deleted_rows;
-- Исключение откатывает всю транзакцию вместе с DELETE выше.
IF deleted_rows > 30 THEN
RAISE EXCEPTION
'offer_price_history: к удалению % yandex-посевов при замере 17.09.2026 = 11 — '
'критерий ловит не то, миграция остановлена', deleted_rows;
END IF;
END $$;
COMMIT;

View file

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

View file

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

View file

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

View file

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

View file

@ -167,3 +167,9 @@ tests/test_3310_curl_ban_keeps_last_node.py::test_transport_failure_still_counts
tests/test_3404_egress_run_attribution.py::test_resolve_within_run_writes_node_to_scrape_runs tests/test_3404_egress_run_attribution.py::test_resolve_within_run_writes_node_to_scrape_runs
tests/test_3404_egress_run_attribution.py::test_resolve_outside_run_writes_nothing tests/test_3404_egress_run_attribution.py::test_resolve_outside_run_writes_nothing
tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_callers_transaction tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_callers_transaction
# Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent
tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many

View file

@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts """#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси) config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed -- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
at the call site. use_curl=True is the 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)."""

View 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)

View 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

View 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"

View 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

View file

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

View 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")])

View 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)

View file

@ -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 — рвать не по чему"
)

View file

@ -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: now24ч и 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

View file

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

View file

@ -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"], (
"унаследованный якорь потерян — формат чекпоинта разъехался со старыми прогонами"
)

View file

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

View 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

View file

@ -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. Живая БД: строка реально ложится в таблицу ────────────────────────────

View file

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

View file

@ -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 по-прежнему в снимке

View file

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

View file

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

View file

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

View file

@ -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 ошибочно поймал дрейф — тест не то мерит"

View file

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

View file

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

View file

@ -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]),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 == []

View file

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

View file

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

View file

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

View file

@ -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, ничего не схлопнуто.

View file

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

View file

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

View file

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

View file

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

View file

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

View 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]

View file

@ -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)" ────────

View file

@ -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 == []

View file

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

View file

@ -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("ДОМ НЕ ИЗВЕСТЕН");
}); });

View file

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