Выбор оператора мобильного прокси опирался на две ненадёжные опоры. Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id` была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось ни одним запросом. Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов, у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как «Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза». Теперь: - `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire` его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик. Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не вправе ронять выдачу прокси или ждать на блокировке строки прогона. - `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1: формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до штатного purge по `SOURCE_BAN_PURGE_DAYS`. Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress` (ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` — все гейтятся по `banned_until > now()`. Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by` исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя. Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`. Полный прогон бэкенда: 5600 passed, 37 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
1330 lines
115 KiB
Python
1330 lines
115 KiB
Python
"""Минимальный settings для standalone trade-in MVP."""
|
||
|
||
from typing import Literal
|
||
from urllib.parse import quote
|
||
|
||
from pydantic import Field, SecretStr, field_validator
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
|
||
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
|
||
# и как запасное значение, если переменная окружения задана пустой строкой
|
||
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
|
||
#
|
||
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
|
||
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
|
||
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
|
||
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
|
||
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
|
||
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
|
||
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
|
||
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
|
||
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
|
||
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
|
||
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
|
||
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
|
||
# отношения не имеет.
|
||
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
|
||
_AUTH_DB_DEFAULT_PORT = 5432
|
||
_AUTH_DB_DEFAULT_NAME = "auth"
|
||
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
|
||
_AUTH_DB_DEFAULT_USER = "auth_app"
|
||
|
||
# ── Окно свежести объявлений (#2656) ──────────────────────────────────────────
|
||
# ЕДИНСТВЕННОЕ место, где живёт это число. Читают: estimator (_COMMON_WHERE
|
||
# радиусного пути, inline-копия Tier W, оба SQL якоря дома) и ночной пересчёт
|
||
# asking_to_sold_ratios (знаменатель коэффициента выкупа). Лежит здесь, а не в
|
||
# estimator.py, потому что estimator сам импортирует area_bucket из
|
||
# app.tasks.asking_to_sold_ratio — обратный импорт дал бы цикл.
|
||
#
|
||
# ЗАЧЕМ фильтр вообще: `is_active` означает РАЗНОЕ для разных источников (TTL
|
||
# деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется
|
||
# никогда), а `scraped_at` — одно и то же. Ослабление этого окна или подмена
|
||
# `scraped_at` на `last_seen_at` впускает в ценовые выборки объявления, которых
|
||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||
|
||
# required — задаётся через env DATABASE_URL. Нет дефолта: fail-fast при старте
|
||
# если переменная не задана (C-3 security audit).
|
||
database_url: str
|
||
|
||
# In-app asyncio scheduler enable flag (#581).
|
||
# Set SCHEDULER_ENABLE=false when using the systemd timer trigger instead.
|
||
# Default true preserves existing behaviour.
|
||
scheduler_enable: bool = Field(default=True, validation_alias="SCHEDULER_ENABLE")
|
||
|
||
# ── strangler-миграция scheduler → scraper_kit (#2192, завершена #2397 Part C) ────
|
||
# scheduler_main.py теперь безусловно идёт kit-путём (scraper_kit.orchestration.scheduler
|
||
# + product_handlers) — legacy app.services.scheduler.scheduler_loop fallback удалён
|
||
# (был мёртвым грузом: прод давно на USE_KIT_SCHEDULER=true). Поле оставлено (extra=
|
||
# "ignore" в model_config защищает от startup-краха на leftover env var), но больше
|
||
# ни на что не влияет.
|
||
use_kit_scheduler: bool = Field(default=False, validation_alias="USE_KIT_SCHEDULER")
|
||
# Proxy-pool флаги — объявлены заранее (потребители P3/P4 отдельно), дефолт False.
|
||
use_proxy_pool_curl: bool = Field(default=False, validation_alias="USE_PROXY_POOL_CURL")
|
||
use_proxy_pool_browser: bool = Field(default=False, validation_alias="USE_PROXY_POOL_BROWSER")
|
||
|
||
cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
|
||
environment: str = "dev"
|
||
|
||
# ── #2213: defense-in-depth для trusted-header auth ───────────────────────
|
||
# Backend доверяет заголовку X-Authenticated-User (его проставляет Caddy после
|
||
# basic_auth). Но backend сидит на общей docker-сети gendesign_shared вместе с
|
||
# обоими стеками — любой контейнер мог бы отправить поддельный
|
||
# `X-Authenticated-User: admin` напрямую на tradein-backend:8000, минуя Caddy,
|
||
# и получить админ-доступ. Общий секрет закрывает эту дыру: Caddy добавляет
|
||
# X-Internal-Auth-Secret из env, backend требует точного совпадения на любом
|
||
# запросе с X-Authenticated-User (constant-time compare).
|
||
#
|
||
# Дизайн — fail-open до провижининга: пусто (дефолт) → защита НЕ активна
|
||
# (поведение как раньше + один WARNING на старте). Секрет задаётся руками в
|
||
# .env.runtime ОБОИХ стедов (Caddy главного стека + tradein-backend) и НИКОГДА
|
||
# не коммитится. ENV: TRADEIN_INTERNAL_AUTH_SECRET.
|
||
tradein_internal_auth_secret: str = Field(
|
||
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
||
)
|
||
|
||
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
|
||
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
|
||
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
|
||
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
|
||
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
|
||
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
|
||
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
|
||
session_cookie_name: str = Field(
|
||
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
|
||
)
|
||
# TTL сессии в часах. Дефолт 720ч (30 дней).
|
||
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
|
||
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
|
||
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
|
||
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
|
||
# Rate-limit на /login: не более login_rate_limit попыток за
|
||
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
|
||
#
|
||
# 27.08: было 5. Пилот «Практика» — ОФИС, все сотрудники выходят из-под
|
||
# одного NAT, то есть пять попыток делились на всю компанию сразу. Хватило
|
||
# одного человека, перепутавшего пароль, чтобы запереть остальных: «Слишком
|
||
# много попыток» получали те, кто вообще ещё не пробовал войти.
|
||
#
|
||
# Защита от перебора при этом не ослабевает: настоящий предохранитель —
|
||
# login_username_fail_threshold (счёт по ЛОГИНУ, 20 за час), и он не тронут.
|
||
# Лимит по адресу нужен против всплеска с одной машины, а не против офиса.
|
||
login_rate_limit: int = Field(default=20, validation_alias="LOGIN_RATE_LIMIT")
|
||
login_rate_limit_window_s: int = Field(
|
||
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
|
||
)
|
||
# Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571).
|
||
# Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого
|
||
# нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО
|
||
# имя, поэтому попытки со всех адресов складываются.
|
||
#
|
||
# Превышение порога НЕ блокирует учётку (это был бы вектор DoS против
|
||
# конкретного человека — злоумышленник выключал бы чужой вход по своему
|
||
# желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час
|
||
# выбран так, чтобы живой человек с опечатками до него не доходил.
|
||
login_username_fail_threshold: int = Field(
|
||
default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD"
|
||
)
|
||
login_username_fail_window_s: int = Field(
|
||
default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S"
|
||
)
|
||
# Потолок задержки одного ответа. Держим невысоким сознательно: задержка —
|
||
# это ещё и цена, которую платит легитимный владелец имени, пока его
|
||
# перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг».
|
||
login_username_throttle_max_delay_s: float = Field(
|
||
default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S"
|
||
)
|
||
# ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ────
|
||
# Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши —
|
||
# `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в
|
||
# `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были
|
||
# единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при
|
||
# стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см.
|
||
# `app.core.password.verify_password_bounded`.
|
||
#
|
||
# `workers` — это и есть потолок темпа: не больше workers/282мс проверок в
|
||
# секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы
|
||
# ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала
|
||
# блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл
|
||
# только вместе с осознанным ответом «во сколько раз мы согласны ускорить
|
||
# перебор ради параллельных входов».
|
||
# ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must
|
||
# be greater than 0») — контейнер уходит в crash-loop, и причина видна только
|
||
# в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля.
|
||
login_password_verify_workers: int = Field(
|
||
default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS"
|
||
)
|
||
# Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт
|
||
# очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут
|
||
# workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает
|
||
# соединение к БД (сессия реестра открыта после SELECT в
|
||
# `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная
|
||
# очередь выбрала бы пул и положила API ровно так же, как блокировка цикла,
|
||
# только другим способом. 4 из 15 соединений и худшее ожидание
|
||
# 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает.
|
||
# ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход
|
||
# получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего:
|
||
# потолок — это workers, а очередь без границы выбирает пул соединений к БД.
|
||
login_password_verify_max_inflight: int = Field(
|
||
default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT"
|
||
)
|
||
|
||
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
|
||
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и
|
||
# «Птицы» (Site Finder); схема — data/sql/auth/001-004.
|
||
#
|
||
# ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не
|
||
# заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые
|
||
# сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт)
|
||
# к этой БД не обращается ни одна строка кода: engine не создаётся,
|
||
# соединение не открывается, пустой DSN на старте ничего не роняет — см.
|
||
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
|
||
#
|
||
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
|
||
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
|
||
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
|
||
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
|
||
|
||
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
|
||
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
|
||
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
|
||
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
|
||
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
|
||
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
|
||
# (аутентификация к БД `auth` отваливается для всех сразу).
|
||
#
|
||
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
|
||
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
|
||
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
|
||
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
|
||
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
|
||
#
|
||
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
|
||
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
|
||
#
|
||
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
|
||
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
|
||
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
|
||
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
|
||
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
|
||
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
|
||
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
|
||
# месте — `.get_secret_value()` в резолвере ниже.
|
||
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
|
||
# это предсуществующее положение, а не «здесь безопасно, а там нет».
|
||
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
|
||
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
|
||
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
|
||
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
|
||
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
|
||
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
|
||
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
|
||
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
|
||
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
|
||
|
||
@field_validator("auth_db_port", mode="before")
|
||
@classmethod
|
||
def _blank_port_means_default(cls, value: object) -> object:
|
||
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
|
||
|
||
Симметрия с host/name/user, у которых пустое значение переменной падает
|
||
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
|
||
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
|
||
`settings = Settings()` выполняется на уровне модуля — то есть
|
||
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
|
||
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
|
||
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
|
||
одного обращения — ровно тот инвариант «дефолт не трогаем», который
|
||
держит остальной код.
|
||
|
||
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
|
||
заполняет только пароль — остальные строки остаются пустыми намеренно.
|
||
|
||
`mode="before"` — потому что вмешаться надо ДО приведения к int.
|
||
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
|
||
это опечатка со смыслом, а не «оставил пустым».
|
||
"""
|
||
if isinstance(value, str) and not value.strip():
|
||
return _AUTH_DB_DEFAULT_PORT
|
||
return value
|
||
|
||
@property
|
||
def resolved_auth_database_url(self) -> str:
|
||
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
|
||
|
||
Приоритет:
|
||
1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда. Обратная
|
||
совместимость (так настроено «до») плюс аварийный обход: если DSN
|
||
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
|
||
можно вписать целиком, не трогая код.
|
||
2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей.
|
||
3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
|
||
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
|
||
никто. Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db`
|
||
и только когда реестр реально понадобился.
|
||
|
||
⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
|
||
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
|
||
|
||
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
|
||
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
|
||
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
|
||
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
|
||
имя пользователя.
|
||
|
||
А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy
|
||
раскодирует обратно только userinfo (user/password), а path отдаёт как
|
||
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
|
||
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
|
||
%-кодирование тоже только мешает — оно поломало бы IPv6-скобки.
|
||
"""
|
||
explicit = self.auth_database_url.strip()
|
||
if explicit:
|
||
return explicit
|
||
|
||
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
|
||
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
|
||
# ведущий/хвостовой пробел может быть частью настоящего пароля.
|
||
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
|
||
password = self.auth_db_password.get_secret_value()
|
||
if not password.strip():
|
||
return ""
|
||
|
||
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
|
||
secret = quote(password, safe="")
|
||
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
|
||
port = self.auth_db_port
|
||
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
|
||
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
|
||
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
|
||
# зависимостях нет).
|
||
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
|
||
|
||
# Где живут identity (люди + сессии):
|
||
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
|
||
# (ровно сегодняшний прод, поведение не меняется);
|
||
# "auth" — БД auth, таблицы users/sessions (единый реестр).
|
||
# Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и
|
||
# перенесены данные. Дефолт = старое поведение: включить новый путь можно
|
||
# исключительно явной сменой этого флага. Единственный потребитель —
|
||
# app.services.identity_store. ENV: IDENTITY_STORE.
|
||
identity_store: Literal["tradein", "auth"] = Field(
|
||
default="tradein", validation_alias="IDENTITY_STORE"
|
||
)
|
||
|
||
# для User-Agent в Nominatim (Nominatim Usage Policy)
|
||
contact_email: str = "erginrajpopxbe@outlook.com"
|
||
|
||
# Public URL — для QR-кода в PDF, shareable links, etc.
|
||
public_url: str = "http://127.0.0.1:8080"
|
||
|
||
# GlitchTip DSN — мониторинг ошибок (Sentry-совместимый). #396.
|
||
# Пусто = мониторинг выключен (dev). В prod — env GLITCHTIP_DSN из .env.runtime.
|
||
glitchtip_dsn: str | None = None
|
||
|
||
# Ключ шифрования для pgp_sym_encrypt (Cian session cookies).
|
||
# Задаётся через env COOKIE_ENCRYPTION_KEY. Пусто = шифрование не работает.
|
||
cookie_encryption_key: str = ""
|
||
|
||
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
|
||
redis_url: str = "redis://localhost:6379/0"
|
||
|
||
# Rate-limit публичного /api/* (per-user / per-IP sliding window). ENV:
|
||
# RATE_LIMIT, RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за
|
||
# rate_limit_window_s секунд на КЛЮЧ (ключ = authenticated username, либо
|
||
# client IP для анонимов). См. ratelimit.py (#655, #2213).
|
||
rate_limit: int = 300
|
||
rate_limit_window_s: float = 60.0
|
||
# #2213: аутентифицированный трафик БОЛЬШЕ не освобождается целиком (это было
|
||
# плацебо — заголовок X-Authenticated-User клиент-контролируем). Вместо этого
|
||
# per-user лимит = rate_limit × этот множитель. Живой пилот (kopylov/admin) не
|
||
# должен упираться — множитель щедрый (×5 = 1500/60с при дефолте), но подделка
|
||
# заголовка уже не даёт безлимит. ENV: RATE_LIMIT_AUTHENTICATED_MULTIPLIER.
|
||
rate_limit_authenticated_multiplier: int = Field(
|
||
default=5, validation_alias="RATE_LIMIT_AUTHENTICATED_MULTIPLIER"
|
||
)
|
||
|
||
# ── B2C anti-abuse этап 2: отдельный жёсткий лимит частоты на POST /estimate ──
|
||
# Общий rate_limit (300/60с) рассчитан на дешёвые запросы; один вызов /estimate
|
||
# запускает цепочку внешних вызовов (geocode → Overpass → IMV → Yandex → Cian),
|
||
# каждый забюджетирован, но суммарно может занимать десятки секунд. Отдельный,
|
||
# куда более строгий бюджет burst'а поверх общего — не даёт одному ключу
|
||
# (user:/ip:, тот же принцип что и general-лимит) запустить много параллельных
|
||
# дорогих цепочек за короткое окно. НЕ заменяет account_quota (месячная квота,
|
||
# персистентная в Postgres) — это защита от burst, а не от abuse за месяц.
|
||
# Применяется К ЛЮБОМУ ключу (auth и анон одинаково) — цель защитить capacity
|
||
# сервера/upstream-скрейперов, а не различать роли. ENV: ESTIMATE_RATE_LIMIT,
|
||
# ESTIMATE_RATE_LIMIT_WINDOW_S.
|
||
estimate_rate_limit: int = Field(default=5, validation_alias="ESTIMATE_RATE_LIMIT")
|
||
estimate_rate_limit_window_s: float = Field(
|
||
default=300.0, validation_alias="ESTIMATE_RATE_LIMIT_WINDOW_S"
|
||
)
|
||
|
||
# Password for tradein_fdw_reader role — used by backend startup to create/refresh
|
||
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
|
||
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
|
||
gendesign_fdw_password: str | None = None
|
||
|
||
# DaData /clean/address — обогащение target-адреса канонической формой,
|
||
# kadastr_num, ФИАС, координатами, метро. Используется в estimator для
|
||
# on-demand enrichment (PR Q1). Demo tier: 100 req/день. Если хотя бы один
|
||
# не задан — service возвращает None gracefully, estimator продолжает.
|
||
# ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
|
||
dadata_api_token: str | None = None
|
||
dadata_api_secret: str | None = None
|
||
|
||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||
|
||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||
# и tighter FSD-диапазон.
|
||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
||
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
||
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
||
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
||
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
||
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
||
estimate_sb_floor_sigma: float = 0.25
|
||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||
# (проверено: 80.0% coverage на тех же 2366).
|
||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||
# Диагноз: estimator систематически промахивается по эре дома + размеру —
|
||
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
|
||
# прод-сделок, 2026-06-27) регрессии log(actual_sold/expected_sold) ~ year +
|
||
# ln(area) даёт мультипликативный фактор, применяемый к expected_sold.
|
||
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
|
||
# ВАЖНО: цифры ниже — метрики ТОГО ЖЕ 2366-сделочного held-out FIT, а НЕ
|
||
# 277-сделочного frozen backtest-фикстура, на котором гоняется regression-gate
|
||
# (там overall expected_sold MAPE 18.63→14.24):
|
||
# held-out median-abs-error 18.5%→16.1%; бизнес bias −22%→−14% (MAPE 22.3→15.5),
|
||
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
||
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
||
# OFF ⇒ точно старое поведение expected_sold.
|
||
estimate_hedonic_correction_enabled: bool = True
|
||
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
||
#
|
||
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
||
# 2026-06-27 (#2002), когда asking→sold ratio ключевался ПО КОМНАТАМ. 2026-08-05
|
||
# (#2620) ratio переключили на area-бакеты — то есть под хедонику подставили ДРУГУЮ
|
||
# базу, а её саму не пересчитали. Хедоника по определению чинит ОСТАТОК
|
||
# log(actual_sold / expected_sold), поэтому её коэффициенты верны только для той
|
||
# базы, на которой фитились. Итог: площадь штрафовалась дважды, крупные лоты
|
||
# занижались на 21% (bias 4+ комнат = -21.4%).
|
||
#
|
||
# larea = 0.0 ВЫСТАВЛЕН НАМЕРЕННО, это не «не задан». После #2620 площадь несёт
|
||
# area-бакетный ratio, и его форма — перевёрнутая U (факт sold/ask по бакетам:
|
||
# 0.786 / 0.829 / 0.899 / 0.954 / 0.864), которую монотонный ln(area) выразить не
|
||
# может в принципе: он тянет крупное жильё вниз ровно там, где рынок его не
|
||
# дисконтирует. Член стал избыточным и вредным — обнуляем, оставляя код-путь.
|
||
#
|
||
# Замер вариантов на той же фикстуре (bias по комнатам, median):
|
||
# текущие MAPE 13.90 | студия +7.6 1к -0.5 2к -2.8 3к -5.3 4+ -21.4
|
||
# OLS все 4 члена MAPE 14.11 | студия -0.9 1к -1.6 2к -0.7 3к -4.0 4+ -14.1
|
||
# БЕЗ larea (тут) MAPE 14.28 | студия -3.1 1к -2.3 2к -0.4 3к -1.9 4+ -10.4
|
||
# хедоника OFF MAPE 15.88 | студия -1.5 1к +4.4 2к +4.2 3к -2.3 4+ -5.0
|
||
# Берём «без larea»: худший перекос вдвое меньше, все прочие классы в пределах
|
||
# ±3.1%, цена — +0.38 п.п. общего MAPE (хедоника сжимает разброс за счёт year).
|
||
#
|
||
# ОСТАТОК -10.4% по 4+ конфигом НЕ закрывается: ratio бакета 4 = 0.8211 при
|
||
# фактических sold/ask = 0.8640, т.е. занижен на ~5% ДО всякой хедоники. Это
|
||
# пересчёт самой таблицы (app/tasks/asking_to_sold_ratio.py), см. follow-up.
|
||
estimate_hedonic_b0: float = -0.0140 # #3248 перефит поверх area-бакетного ratio
|
||
estimate_hedonic_year_coef: float = 0.0769 # per (year-2000)/20
|
||
estimate_hedonic_larea_coef: float = 0.0 # НАМЕРЕННО 0 — площадь несёт ratio (#2620)
|
||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||
estimate_hedonic_factor_min: float = 0.75
|
||
estimate_hedonic_factor_max: float = 1.30
|
||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
|
||
#
|
||
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
|
||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||
estimate_corridor_clamp_min_n: int = 10
|
||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
||
estimate_corridor_clamp_slack: float = 0.40
|
||
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
|
||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||
estimate_radius_floor_factor: float = 0.8
|
||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
||
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
||
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
||
# к point И range (expected_sold_low/high/price) консистентно.
|
||
# False -> старое поведение без clamp (backward-compat).
|
||
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
||
estimate_expected_sold_le_asking: bool = Field(
|
||
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
||
)
|
||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
||
estimate_sb_mad_k_small_n: float = 2.5
|
||
estimate_sb_small_n_threshold: int = 10
|
||
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
||
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
|
||
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
|
||
estimate_outlier_small_n_threshold: int = 15
|
||
estimate_outlier_tukey_k_small: float = 1.0
|
||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||
# ratio — не затрагиваются.
|
||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||
|
||
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
||
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
||
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||
estimate_sb_gate_min_n: int = 3
|
||
estimate_sb_gate_max_fsd: float = 0.20
|
||
|
||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||
estimate_price_trend_max_age_months: int = 6
|
||
|
||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||
|
||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
|
||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||
estimate_manual_review_elite_ppm2: int = 250000
|
||
|
||
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||
|
||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
|
||
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
|
||
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
|
||
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
|
||
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
|
||
# building (building_cadastral_number | нормализованный address)
|
||
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
|
||
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
|
||
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
|
||
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
|
||
# («лот считается один раз»).
|
||
#
|
||
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
|
||
# 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
|
||
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
|
||
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
|
||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||
estimate_dedup_analogs_enabled: bool = True
|
||
|
||
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||
# ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга
|
||
# аналогов. Три НЕЗАВИСИМЫХ флага (по одному на фичу, все default OFF —
|
||
# см. scripts/backtest_estimator.py --engine full для A/B измерения; каждый
|
||
# PR/issue #2012 обязан задокументировать MAPE/coverage/calibration до
|
||
# включения любого в default ON):
|
||
#
|
||
# kitchen_area_m2 / ceiling_height_m ("мягкие корректировки"): в отличие от
|
||
# house_type/year_built (сравниваются с target_house_type/target_year,
|
||
# известными из payload или OSM house_metadata fallback) — у kitchen/ceiling
|
||
# НЕТ target-значения: ни TradeInEstimateInput (форма пользователя), ни
|
||
# `deals` (backtest ground truth, rosreestr ДКП) их не несут. Поэтому
|
||
# релевантность штрафуется отклонением кандидата от МЕДИАНЫ САМОГО ПУЛА
|
||
# кандидатов текущего запроса (self-referential), а НЕ сравнением с внешней
|
||
# "типичной" константой — так сигнал не зависит от непроверенных допущений
|
||
# о типичном размере кухни/высоте потолка. NULL-safe и sparse-safe вдвойне:
|
||
# (1) кандидат без значения колонки не штрафуется и не участвует в подсчёте
|
||
# медианы пула; (2) если кандидатов пула с непустым значением меньше
|
||
# estimate_kitchen_ceiling_signal_min_n — сигнал пропускается ЦЕЛИКОМ для
|
||
# всего пула (слишком мало данных для честной "типичной" медианы — риск шума
|
||
# на sparse-колонках, который явно называет issue #2012: kitchen 4-99%,
|
||
# ceiling ~10% покрытия по источникам).
|
||
# ENV: ESTIMATE_KITCHEN_AREA_SIGNAL_ENABLED, ESTIMATE_CEILING_HEIGHT_SIGNAL_ENABLED.
|
||
estimate_kitchen_area_signal_enabled: bool = False
|
||
estimate_ceiling_height_signal_enabled: bool = False
|
||
# Масштаб (м² / м): во сколько "единиц отклонения" превращается 1.0 очко
|
||
# relevance_score — симметрично house_type-штрафу (1.5 очка за несовпадение)
|
||
# и year_built-штрафу (abs(delta)/12.0). Кухня ~3м² и потолок ~0.3м —
|
||
# консервативные масштабы, дающие умеренный штраф на типичном разбросе пула.
|
||
estimate_kitchen_area_scale: float = 3.0
|
||
estimate_ceiling_height_scale: float = 0.3
|
||
# Максимальный штраф за отклонение (та же единица очков, что house_type=1.5) —
|
||
# клампим, чтобы редкий выброс пула (напр. кухня 25м² в студийной подборке)
|
||
# не выбрасывал кандидата из top-50 целиком одним лишь этим сигналом.
|
||
estimate_kitchen_ceiling_signal_max_penalty: float = 1.0
|
||
# Минимум кандидатов пула с НЕ-NULL значением колонки, чтобы доверять её
|
||
# медиане как "типичной" для этого пула (иначе сигнал пропускается — см. риск
|
||
# sparse-coverage выше).
|
||
estimate_kitchen_ceiling_signal_min_n: int = 5
|
||
#
|
||
# ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ──────
|
||
# В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
|
||
# столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с
|
||
# известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
|
||
# данных выигрывает у знания, и это возвышает источник с худшей полнотой:
|
||
# замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 %
|
||
# доли в пуле, yandex (год 99 %) — 9 % при 37 %.
|
||
#
|
||
# Влияние на цену измерено дважды (#2936): систематического смещения НЕТ
|
||
# (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 %
|
||
# в зависимости от того, как оценено незнание — шум от полноты сбора.
|
||
# Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не
|
||
# правка формулы: включать — только после бэктеста на сделках (MAPE), как
|
||
# требует тот же контракт, что у kitchen/ceiling (#2012) выше.
|
||
#
|
||
# Механизм: НЕ наказание, но и не награда — кандидат с NULL получает
|
||
# МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он
|
||
# известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира
|
||
# после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение:
|
||
# per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал
|
||
# без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012.
|
||
# Sparse-safe: если известных значений меньше min_n — сигнал пропускается
|
||
# целиком (медиана по трём строкам — не «типичный штраф», а шум).
|
||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||
estimate_unknown_attr_penalty_enabled: bool = False
|
||
estimate_unknown_attr_penalty_min_n: int = 5
|
||
#
|
||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||
# #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной
|
||
# статус недвижимости (не жилое помещение, нет постоянной регистрации по
|
||
# месту жительства), заметно иная ценовая модель vs обычная квартира. Target
|
||
# trade-in объект почти всегда обычная квартира (TradeInEstimateInput не
|
||
# даёт признака "апартаменты"), поэтому при включении флага HARD-исключаем
|
||
# явно known is_apartments=true кандидатов из вторичка-пула. NULL-safe:
|
||
# неизвестный статус (подавляющее большинство строк, sparse coverage)
|
||
# участвует БЕЗ штрафа — фильтруются только явные True.
|
||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||
estimate_is_apartments_filter_enabled: bool = False
|
||
|
||
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
|
||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||
# disclosure в explanation. НЕ трогает point/median/range.
|
||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
|
||
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
|
||
estimate_wide_corridor_threshold: float = 1.2
|
||
|
||
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||
# low_price > high_price или отрицательные значения → также сброс.
|
||
# Дефолты: 500_000 (мин. рыночная квартира ЕКБ) и 500_000_000 (500 М — абс. потолок).
|
||
# ENV: CIAN_VALUATION_MIN_RUB, CIAN_VALUATION_MAX_RUB.
|
||
cian_valuation_min_rub: float = 500_000
|
||
cian_valuation_max_rub: float = 500_000_000
|
||
|
||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||
# #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
|
||
# (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
|
||
# guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
|
||
# место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
|
||
# берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
|
||
# так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
|
||
# extra="ignore" в model_config защищает от startup-краха на leftover env var.
|
||
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
|
||
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
|
||
avito_imv_thin_market_threshold: int = 10
|
||
# #915 Stage 3: route IMV backfill через /fetch-json sidecar (обходит
|
||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||
avito_imv_use_browser_fetcher: bool = False
|
||
|
||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
||
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
|
||
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
|
||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||
estimate_quarter_index_min_n_deals: int = 10
|
||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||
# аналоги уже несут локацию квартала → skip (location in median).
|
||
estimate_quarter_match_skip_ratio: float = 0.6
|
||
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
|
||
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
|
||
estimate_quarter_index_max_for_small_n: float = 2.0
|
||
estimate_quarter_index_small_n_threshold: int = 50
|
||
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
|
||
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
|
||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||
estimate_quarter_index_factor_min: float = 0.6
|
||
estimate_quarter_index_factor_max: float = 1.8
|
||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||
#
|
||
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
|
||
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
|
||
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
|
||
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
|
||
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
|
||
# денежный — поэтому включение отдельным решением, после замера.
|
||
#
|
||
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
|
||
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
|
||
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
|
||
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
|
||
# а не оставлять «на вырост».
|
||
estimate_quarter_from_coords_enabled: bool = False
|
||
|
||
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
|
||
#
|
||
# ⚠ НЕ ВКЛЮЧАТЬ БЕЗ ПЕРЕЗАМЕРА. Посылка, на которой откалиброваны множители
|
||
# ниже («движок занижает верхние сегменты»), 2026-08-29 признана АРТЕФАКТОМ
|
||
# измерения, а не свойством движка.
|
||
#
|
||
# Причина: `scripts/backtest_estimator.py::_segment_metrics` группирует ошибки
|
||
# ПО SOLD ₽/м² — по той самой величине, которую движок предсказывает. Группировка
|
||
# по исходу даёт регрессию к среднему: в дешёвых корзинах любой предиктор выглядит
|
||
# завышающим, в дорогих — занижающим, даже при нулевом перекосе.
|
||
#
|
||
# Контрольный опыт (13 074 сделки ЕКБ с 2025-06): предиктор БЕЗ сегментного
|
||
# перекоса по построению — медиана окружения 1 км ÷ общий уровень — даёт в тех же
|
||
# корзинах эконом +22.28%, бизнес −17.04%, элит −30.06%. Это БОЛЬШЕ наблюдавшегося
|
||
# перекоса движка (бизнес −9.74%, элит −18.45% на n=1191), т.е. движок «занижает»
|
||
# ровно настолько, насколько этого требует способ замера, и даже меньше.
|
||
#
|
||
# Перекрёстная проверка: те же сделки, но бэнд задан НЕЗАВИСИМЫМ прокси (медиана
|
||
# активных объявлений собственного дома, own_n>=3) — градиент исчезает, остаётся
|
||
# ровный уровень +20…+27% (это разрыв asking↔sold, его чинит asking_to_sold_ratio).
|
||
#
|
||
# Следствие: включение флага подняло бы бизнес/элит на 6-8% БЕЗ ОСНОВАНИЯ.
|
||
# Прежде чем трогать — перевести `_segment_metrics` на независимый бэнд и
|
||
# перемерить. Абзац «band-mismatch» ниже описывает симптом того же артефакта.
|
||
# Разбор: vault `segment-bias-is-regression-to-the-mean-artifact`.
|
||
#
|
||
# ИСТОРИЧЕСКАЯ ПОСЫЛКА (сохранена как есть, НЕ опирайся на неё):
|
||
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
|
||
# 2026-07-03): эконом +3.2%, комфорт −4.5%, бизнес −16.3% (n=76),
|
||
# элит −25.7% (n=16). Множитель применяется к median_price/median_ppm2 и
|
||
# пропорционально к range_low/range_high СРАЗУ ПЕРЕД min-width floor, после
|
||
# IMV-blend / corridor-clamp / quarter-index / hedonic / PI. Бэнд — по
|
||
# median_ppm2 границами PRICE_SEGMENTS_PPM2 (единый источник в estimator.py).
|
||
# Флаг default OFF → путь байт-в-байт идентичен (frozen gate не двигается).
|
||
estimate_segment_multiplier_enabled: bool = False
|
||
# Множители: калибровка live-бэктест OFF/ON sample=300 2026-07-03 (#2255).
|
||
# Поправка применяется ТОЛЬКО к бизнес/элит — где занижение крупное и
|
||
# однонаправленное. эконом/комфорт = 1.00 (no-op).
|
||
#
|
||
# Бэнд считается по PREDICTED ppm² (при оценке истинный сегмент неизвестен),
|
||
# а точность меряется по SOLD ppm² → band-mismatch: эконом-sold сделки,
|
||
# PREDICTED в бизнес (и так завышаемые +7.7%), получают ×множитель и
|
||
# завышаются дальше. Поэтому агрессивный v4 (бизнес 1.12 / элит 1.10)
|
||
# ОТВЕРГНУТ: overall MAPE +3.08pp, эконом MAPE 17.9→20.2.
|
||
#
|
||
# V2 (бизнес 1.08 / элит 1.06) — лучший трейд-офф: overall MAPE +1.87pp,
|
||
# бизнес bias −10.4→−3.2, элит −30→−24.6, эконом почти intact (MAPE 18.0).
|
||
# Дальнейшее сжатие bias без роста MAPE — только с confidence-гейтом
|
||
# (не применять множитель на low-confidence предсказаниях) → follow-up issue.
|
||
# NB (2026-08-29): «bias −10.4→−3.2» выше меряет схлопывание АРТЕФАКТА, а не
|
||
# исправление ошибки — см. предупреждение в начале блока. Рост общего MAPE
|
||
# +1.87pp при этом настоящий: множитель двигает реальные цены.
|
||
# Пересчёт биасов — см. scripts/backtest_estimator.py --calibrate-segments.
|
||
estimate_segment_multipliers: dict[str, float] = {
|
||
"эконом": 1.00,
|
||
"комфорт": 1.00,
|
||
"бизнес": 1.08,
|
||
"элит": 1.06,
|
||
}
|
||
|
||
# ── Estimate enrichment time-budgets (#654) ──────────────────────────────
|
||
# POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых
|
||
# вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex
|
||
# valuation (внутренний httpx timeout 30s) НЕ gated на наличие floor и
|
||
# выполняется на каждой оценке — главный подозреваемый на gateway-таймаут
|
||
# (Caddy 502/504). Эти budget'ы оборачивают самые медленные ungated-вызовы
|
||
# в asyncio.wait_for(): при превышении источник деградирует в None (тот же
|
||
# graceful-путь что и сетевая ошибка), а НЕ роняет весь /estimate в 5xx.
|
||
# Держать суммарный budget ниже Caddy read/write timeout (см.
|
||
# deploy/Caddyfile.tradein-fragment). ENV: ESTIMATE_YANDEX_VALUATION_TIMEOUT_S,
|
||
# ESTIMATE_CIAN_VALUATION_TIMEOUT_S, ESTIMATE_GEOCODE_BUDGET_S,
|
||
# ESTIMATE_HOUSE_META_TIMEOUT_S.
|
||
estimate_yandex_valuation_timeout_s: float = 8.0
|
||
estimate_cian_valuation_timeout_s: float = 8.0
|
||
|
||
# Внешние оценки (Yandex/Cian) не ждать в запросе, а догружать в фоне.
|
||
#
|
||
# Замер на проде 2026-08-22: расчёт по НОВОМУ адресу занимает 8-12 с, из них
|
||
# ~6 с ждёт Yandex и ~1.5 с Cian. Собственные запросы к базе и сам расчёт
|
||
# укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с.
|
||
#
|
||
# True: в запросе делается только чтение кэша; при промахе источник
|
||
# деградирует в None, а свежая загрузка уходит в фон и наполняет кэш к
|
||
# следующему обращению по тому же адресу. Ответ отдаётся за ~1 с.
|
||
#
|
||
# Деградация в None — НЕ новое состояние ответа: ровно так же ведёт себя
|
||
# таймаут `estimate_*_valuation_timeout_s`, этот путь работает в проде
|
||
# сегодня. Поэтому переключение не меняет контракт API.
|
||
#
|
||
# False (дефолт) — прежнее поведение: ждать источники в запросе.
|
||
# ENV: ESTIMATE_EXTERNAL_SOURCES_BACKGROUND.
|
||
estimate_external_sources_background: bool = Field(
|
||
default=False, validation_alias="ESTIMATE_EXTERNAL_SOURCES_BACKGROUND"
|
||
)
|
||
estimate_geocode_budget_s: float = 12.0
|
||
estimate_house_meta_timeout_s: float = 8.0
|
||
# #b2c-antiabuse-2: Avito IMV (evaluate_via_imv) была ЕДИНСТВЕННЫМ внешним
|
||
# вызовом в /estimate БЕЗ _with_budget — до 3 последовательных HTTP-запросов
|
||
# (warm-up + geocode + evaluate), каждый со своим таймаутом 25s
|
||
# (_HTTP_TIMEOUT_SEC в scraper_kit.providers.avito.imv), плюс возможен ОДИН
|
||
# internal retry с "очищенным" адресом на IMVAddressNotFoundError — необёрнутый
|
||
# worst-case доходил до ~150s. 20s щедрее соседних бюджетов (8s Yandex/Cian/
|
||
# house_meta) намеренно — IMV делает МНОГО последовательных round-trip'ов, а
|
||
# не один запрос, поэтому реалистичный "медленный, но живой" ответ длиннее.
|
||
# ENV: ESTIMATE_AVITO_IMV_TIMEOUT_S.
|
||
estimate_avito_imv_timeout_s: float = Field(
|
||
default=20.0, validation_alias="ESTIMATE_AVITO_IMV_TIMEOUT_S"
|
||
)
|
||
|
||
# Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658).
|
||
# Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15.
|
||
estimate_quota_limit: int = 15
|
||
|
||
# ── B2C anti-abuse этап 2 (#b2c-antiabuse-2) ──────────────────────────────
|
||
# Продукт открывается для анонимных пользователей — анонимный запрос БЕЗ
|
||
# X-Authenticated-User (Caddy basic_auth) раньше трактовался как unlimited
|
||
# безусловно (dev без Caddy). Недопустимо для публичного пути: любой
|
||
# анонимный клиент получал бы безлимитные дорогие оценки.
|
||
#
|
||
# quota_dev_fail_open: явный флаг ТОЛЬКО для локальной разработки без Caddy.
|
||
# По умолчанию ВЫКЛЮЧЕН — анонимный запрос без заголовка получает анонимную
|
||
# квоту (anon-session cookie + IP), а не безлимит. True включает старое
|
||
# fail-open поведение (username=None → unlimited) — задавай только в dev.
|
||
# ENV: QUOTA_DEV_FAIL_OPEN.
|
||
quota_dev_fail_open: bool = Field(default=False, validation_alias="QUOTA_DEV_FAIL_OPEN")
|
||
|
||
# anon_estimate_quota_limit: месячный лимит успешных оценок на связку
|
||
# (anon-session-cookie + client IP) для запросов БЕЗ X-Authenticated-User.
|
||
# Существенно строже пилот-лимита (estimate_quota_limit=15) — анонимный
|
||
# трафик не аутентифицирован и открыт всему интернету. Честно: смена IP или
|
||
# чистка cookie обходит этот лимит — цель поднять стоимость злоупотребления,
|
||
# а не сделать его невозможным (тот же принцип, что и account_estimate_usage
|
||
# для пилотов). ENV: ANON_ESTIMATE_QUOTA_LIMIT.
|
||
#
|
||
# Значение 5 выбрано владельцем 16.08.2026 при разборе PR #2546 (в первой
|
||
# редакции стояло 3). Компромисс продуктовый, а не технический: пять проб —
|
||
# это больше шансов, что человек дойдёт до ценности и купит платный отчёт за
|
||
# 150 ₽, ценой чуть более высокого потолка злоупотребления. Число легко
|
||
# пересматривается переменной окружения без выкатки кода.
|
||
anon_estimate_quota_limit: int = Field(default=5, validation_alias="ANON_ESTIMATE_QUOTA_LIMIT")
|
||
|
||
# anon_session_secret: ключ HMAC-подписи анонимного session-cookie (см.
|
||
# app/core/anon_session.py). Пусто (дефолт) → используется process-local
|
||
# эфемерный секрет, сгенерированный при старте (secrets.token_bytes) —
|
||
# криптографически стойкий (клиент его не знает и не может подделать
|
||
# session_id), но НЕ переживает рестарт/не общий между несколькими
|
||
# воркерами (каждый рестарт — новая генерация → старые cookie невалидны,
|
||
# клиенты просто получают новую anon-сессию, деградация допустима). Для
|
||
# стабильности между рестартами (текущий деплой — single-worker uvicorn)
|
||
# задай явный секрет в .env.runtime. ENV: ANON_SESSION_SECRET.
|
||
anon_session_secret: str = Field(default="", validation_alias="ANON_SESSION_SECRET")
|
||
|
||
# Фильтр junk-/премиум-порога для asking→sold derivation (#767).
|
||
# Нижняя граница 30 000 ₽/м² отсекает нежилые/технические сделки; менять не стоит.
|
||
# Верхняя граница — поднята с 600 000 до 1 200 000 ₽/м², чтобы покрыть ЕКБ-premium
|
||
# (>600k). Точное значение стоит сверить с `SELECT max(price_per_m2) FROM deals
|
||
# WHERE source='rosreestr'` на проде — см. QA-note в asking_to_sold_ratio.py.
|
||
# ENV: ASKING_RATIO_PPM2_MAX.
|
||
asking_ratio_ppm2_max: int = 1_200_000
|
||
|
||
# SSRF-защита для admin scrape endpoints (#756).
|
||
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
|
||
# Относительные пути (без netloc) проходят без проверки — хост подставляется
|
||
# фиксированным в самом scraper'е. Задаётся через env SCRAPE_ALLOWED_HOSTS
|
||
# (comma-separated). По умолчанию — все хосты используемые scrapers/*.py.
|
||
scrape_allowed_hosts: set[str] = {
|
||
"www.avito.ru",
|
||
"avito.ru",
|
||
"www.cian.ru",
|
||
"cian.ru",
|
||
"ekb.cian.ru",
|
||
"realty.yandex.ru",
|
||
"domclick.ru",
|
||
"www.domclick.ru",
|
||
}
|
||
|
||
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
|
||
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
|
||
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
|
||
# Пусто = прямое подключение (dev/staging без прокси).
|
||
#
|
||
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
|
||
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
|
||
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
|
||
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
|
||
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
||
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
||
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
|
||
|
||
@property
|
||
def scraper_proxy_url(self) -> str | None:
|
||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
|
||
|
||
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
|
||
fallback снят — мёртвая mobileproxy-переменная).
|
||
"""
|
||
return self.scraper_proxy_url_env
|
||
|
||
# ── Ban-recovery budget knobs (changeip снят #2616 шаг 2) ────────────────
|
||
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611);
|
||
# `_rotate_ip`/`_rotate_proxy_ip` остались no-op после удаления changeip-ссылки.
|
||
# Обе ручки читаются: `*_proxy_max_rotations` гейтит бюджет попыток в
|
||
# ban-rotation (scraper_kit.orchestration.pipeline._max_rotations),
|
||
# `avito_proxy_rotate_settle_s` — верхняя граница asyncio.wait_for в
|
||
# app.tasks.avito_detail_backfill.
|
||
# #3212: proxy_rotate_attempts / proxy_rotate_attempt_timeout_s удалены —
|
||
# ретраи changeip-GET, которых больше нет; ни одного читателя не осталось.
|
||
avito_proxy_max_rotations: int = 4
|
||
avito_proxy_rotate_settle_s: float = 9.0
|
||
|
||
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
|
||
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
|
||
# (app.services.proxy_rotation). Документированный публичный API (GET
|
||
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
|
||
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
|
||
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
|
||
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
|
||
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
|
||
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
|
||
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
|
||
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
|
||
|
||
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||
avito_serp_ok_not_banned: bool = True
|
||
|
||
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
|
||
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
|
||
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
|
||
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
|
||
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
|
||
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
|
||
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
||
cian_proxy_max_rotations: int = 4
|
||
|
||
# #1949: per-fetch hard-cancel для browser-fetch'ей внутри bucket-gather Cian full_load.
|
||
# asyncio.wait_for(fetch, timeout=X) отменяет зависший fetch → TimeoutError →
|
||
# _fetch_page_html ловит как Exception → None → _one_page → [] → gather завершается.
|
||
# 0 = отключить (старое поведение). Дефолт 90.0s щедрее nормального fetch ~12s.
|
||
# ENV: CIAN_FULL_LOAD_PER_FETCH_TIMEOUT_S.
|
||
cian_full_load_per_fetch_timeout_s: float = 90.0
|
||
|
||
# #1949: если run_cian_city_sweep уже сохранил лоты (ins+upd > 0) и abort случился
|
||
# из-за anchor-timeout/consecutive SERP failures ПОСЛЕ этого, ставим 'done' а не
|
||
# 'banned' — основной сбор состоялся, 'banned' лишний шум в мониторинге.
|
||
# Зеркалит avito_serp_ok_not_banned (#1950), но отдельный флаг — не смешиваем
|
||
# cian/avito семантику. False = старое поведение. ENV: CIAN_SWEEP_OK_NOT_BANNED.
|
||
cian_sweep_ok_not_banned: bool = True
|
||
|
||
@property
|
||
def cian_proxy_url(self) -> str | None:
|
||
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
|
||
override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig
|
||
совместимости)."""
|
||
return self.scraper_proxy_url
|
||
|
||
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
|
||
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
|
||
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
|
||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||
yandex_proxy_max_rotations: int = 4
|
||
|
||
# ── Порог полноты detail-страницы Яндекса (#3191) ────────────────────────
|
||
# Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным
|
||
# HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к
|
||
# структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух
|
||
# даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется).
|
||
# ENV: YANDEX_DETAIL_MIN_HTML_BYTES.
|
||
yandex_detail_min_html_bytes: int = 1_000_000
|
||
|
||
@property
|
||
def yandex_proxy_url(self) -> str | None:
|
||
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||
return self.scraper_proxy_url
|
||
|
||
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
||
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
||
# обновлять (старое поведение). ENV: SCRAPER_SKIP_SEEN_TODAY.
|
||
scraper_skip_seen_today: bool = True
|
||
|
||
# ── #759: deactivate stale avito listings ───────────────────────────────
|
||
# Avito-объявления, не виденные scraper'ом более avito_stale_ttl_days дней,
|
||
# помечаются is_active=false ночной задачей deactivate_stale_avito_listings.
|
||
# TTL=10 — баланс между "снятое объявление" (обычно 3-7 дней молчания) и
|
||
# допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
|
||
avito_stale_ttl_days: int = 10
|
||
|
||
# ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ────────────────────
|
||
# trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был
|
||
# хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT +
|
||
# _empty_estimate fallback) — вынесено в настройку, чтобы retention-период
|
||
# не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт
|
||
# "сессию" клиента, не архив); юридически обоснованный срок хранения адреса
|
||
# физлица для анонимного B2C — решение не инженера, см. итоговый комментарий
|
||
# к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS.
|
||
#
|
||
# 27.08 — поднято 24 → 720 (30 суток) по решению владельца. Прод-факт:
|
||
# у пилота «Практика» ВСЕ 75 оценок оказались недоступны, включая
|
||
# позавчерашние, и это выглядело как пропажа данных. Строки были целы —
|
||
# истёк `expires_at`, и карточка отдавала «Ссылка устарела».
|
||
#
|
||
# Обоснование «оценка живёт сессию клиента, не архив» писалось под
|
||
# анонимный B2C. «Практика» — пилот-юрлицо: менеджер возвращается к
|
||
# оценке через день-два, когда клиент перезванивает. Для такого сценария
|
||
# суточный срок означает, что работа исчезает раньше, чем её успевают
|
||
# использовать.
|
||
#
|
||
# NB: настройка ОДНА на оба контура. Юридический мотив 152-ФЗ (хранение
|
||
# адреса физлица) относится к анонимному B2C, и для него 30 суток —
|
||
# решение не инженерное. Разделение сроков по контурам — отдельная
|
||
# задача; здесь сознательно поднят общий срок, а не сделан вид, что
|
||
# контуры уже разведены.
|
||
trade_in_estimate_retention_hours: int = 720
|
||
|
||
# trade_in_leads.expires_at TTL (дни от момента создания, migration 231).
|
||
# У trade_in_leads раньше вообще не было срока хранения — лид (телефон +
|
||
# согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для
|
||
# НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон
|
||
# 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом);
|
||
# если лид конвертировался в реальную сделку/договор — для него должен
|
||
# действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет
|
||
# механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ,
|
||
# см. итоговый комментарий к задаче (конкретный юридически обоснованный
|
||
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
|
||
trade_in_lead_retention_days: int = 180
|
||
|
||
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
|
||
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
|
||
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
|
||
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
|
||
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
|
||
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
|
||
# для всех строк, поднять его до года = соврать в документе клиента про
|
||
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
|
||
# Единственный источник числа «12 месяцев» на фронте —
|
||
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
|
||
# экран после оплаты и SQL продления retain_until при оплате (платёжный
|
||
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
|
||
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
|
||
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
|
||
trade_in_paid_retention_days: int = 365
|
||
|
||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
|
||
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
|
||
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
|
||
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
|
||
# пересчитать такую строку через тот же estimate_quality(), что и POST
|
||
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
|
||
# в это число минут на строку — иначе каждый refresh страницы бил бы по
|
||
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
|
||
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
|
||
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
|
||
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
|
||
trade_in_revival_throttle_minutes: int = 10
|
||
|
||
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
||
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
||
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
||
# это ограничивает ОДНУ транзакцию, не общий прогресс. ENV:
|
||
# TRADE_IN_PURGE_BATCH_SIZE.
|
||
trade_in_purge_batch_size: int = 500
|
||
|
||
# ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ─────────────────────
|
||
# Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России»
|
||
# (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug
|
||
# (/ekaterinburg/, /moskva/, /ufa/ и пр.). True = отбрасывать карточки, у
|
||
# которых source_url не содержит /ekaterinburg/ (padding по России). Карточки
|
||
# без распознанного city-slug (href без /city/) пропускаются консервативно.
|
||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
||
|
||
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
|
||
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
|
||
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
|
||
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
|
||
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
|
||
# ENV: YANDEX_COOKIES_FILE.
|
||
yandex_cookies_file: str | None = None
|
||
|
||
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||
cian_login_email: str | None = None
|
||
cian_login_password: str | None = None
|
||
cian_login_url: str = "https://ekb.cian.ru/"
|
||
# Последовательность кликов до формы: открыть модалку → (опц.) другой аккаунт →
|
||
# переключить на email-вход. AnotherAccountBtn на fresh headless отсутствует (скипнется).
|
||
cian_login_pre_click_selectors: list[str] = [
|
||
"[data-name='LoginButton']",
|
||
"[data-name='AnotherAccountBtn']",
|
||
"[data-name='SwitchToEmailAuthBtn']",
|
||
]
|
||
cian_login_email_selector: str = "input[name='username']"
|
||
cian_login_password_selector: str = "input[name='password']"
|
||
cian_login_submit_selector: str = "button[data-name='ContinueAuthBtn']"
|
||
cian_login_success_cookie: str = "DMIR_AUTH"
|
||
cian_login_wait_ms: int = 4000
|
||
|
||
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
||
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
||
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
||
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
|
||
# 1 коннект/запрос) развязывает прокси-аккаунты.
|
||
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
|
||
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
|
||
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||
#
|
||
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
||
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
||
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
||
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
||
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
||
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
||
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
||
# других окружений вслепую.
|
||
avito_detail_backfill_use_curl: bool = Field(
|
||
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||
)
|
||
|
||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||
# fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда →
|
||
# budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run
|
||
# reaped как zombie (run 423 завис 7.7ч). asyncio.wait_for(fetch, timeout=X)
|
||
# отменяет зависший fetch → TimeoutError → листинг failed, loop идёт дальше.
|
||
# 90s щедрее нормального detail-fetch (~10-15s). ENV: AVITO_DETAIL_FETCH_TIMEOUT_S.
|
||
avito_detail_fetch_timeout_s: float = Field(
|
||
default=90.0, validation_alias="AVITO_DETAIL_FETCH_TIMEOUT_S"
|
||
)
|
||
|
||
# Ротация exit-IP по счётчику попыток на арендованном прокси (задача поверх #3298
|
||
# app.services.proxy_rotation.rotate_proxy). Замер вживую: билайновский порт держит
|
||
# ~15 карточек до полного обвала (13/39, последняя четверть 0%), настоящий
|
||
# мобильный оператор — ~30 (27/39, без обвала). Порог зависит от оператора порта,
|
||
# поэтому настройка, а не константа — подстройка под конкретный IP без релиза.
|
||
# Считаются ПОПЫТКИ (успех ИЛИ отказ), не только успехи: неудачная карточка тратит
|
||
# бюджет IP так же, как удачная. ENV: AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS.
|
||
avito_detail_backfill_rotate_after_attempts: int = Field(
|
||
default=15, ge=1, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS"
|
||
)
|
||
|
||
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
|
||
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
|
||
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
|
||
# до purge, #3404), но
|
||
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
|
||
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
|
||
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
|
||
# поэтому не подвержена этому каскаду и может срабатывать больше одного раза за
|
||
# прогон -- но всё равно ограничена бюджетом, чтобы не выжигать прокси-пул на
|
||
# длинной серии блоков. 0 = выключено, полный no-op (никаких доп. ротаций/логов).
|
||
# ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX.
|
||
avito_detail_backfill_rotate_on_ban_max: int = Field(
|
||
default=2, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX"
|
||
)
|
||
# Минимум попыток между ЛЮБЫМИ двумя ротациями (по счётчику ИЛИ по бану) --
|
||
# не даёт двум сбросам контекста идти подряд, даже если оба бюджета формально
|
||
# ещё не исчерпаны. ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP.
|
||
avito_detail_backfill_rotate_on_ban_min_gap: int = Field(
|
||
default=10, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP"
|
||
)
|
||
|
||
# #3184: доля блоков в скользящем окне последних N попыток -- критерий обрыва
|
||
# avito_detail_backfill (app.services.backfill_block_breaker.BlockRatioBreaker),
|
||
# взамен голого "N блоков подряд". ТОЛЬКО avito -- изначальный план распространить
|
||
# тот же критерий на domclick_detail_backfill снят ревью (#3184 review MAJOR 1):
|
||
# у Домклика один выделенный residential-прокси БЕЗ ротации (см.
|
||
# data/sql/175_scrape_schedules_seed_domclick_detail_backfill.sql), калибровка
|
||
# 20/0.7 сделана на пуле С ротацией (avito) и для него не годится без своего
|
||
# замера -- отдельная задача.
|
||
# Калибровка по 40 прогонам avito за 14 суток (2026-08-14..28): ВСЕ 40
|
||
# закончились 'banned', доля блоков колебалась 25-100% (25%/190 попыток честно
|
||
# обогащённых 125, 39%/100, 46%/71, 32%/63, 100%/5) -- "N подряд" не отличал
|
||
# выгоревший прокси-пул от здорового прогона, потому что блоки автокоррелированы
|
||
# и пачки 5-6 подряд встречаются в каждом прогоне при базовой доле 25-46%. Окно
|
||
# 20 выбрано заметно больше типичной пачки, порог 0.7 -- между здоровыми (25-48%)
|
||
# и выгоревшими (100%) прогонами большой запас. ENV: DETAIL_BACKFILL_BLOCK_RATIO_
|
||
# WINDOW / _THRESHOLD -- подкрутка без релиза.
|
||
# ge/le (#3184 review MINOR 2): WINDOW=0 или THRESHOLD в процентах (70 вместо
|
||
# 0.7, частая опечатка оператора) иначе молча выключают критерий навсегда --
|
||
# 0.7 > 70 никогда не бывает True, а окно 0 схлопывает should_abort() в no-op.
|
||
detail_backfill_block_ratio_window: int = Field(
|
||
default=20, ge=1, validation_alias="DETAIL_BACKFILL_BLOCK_RATIO_WINDOW"
|
||
)
|
||
detail_backfill_block_ratio_threshold: float = Field(
|
||
default=0.7, ge=0.0, le=1.0, validation_alias="DETAIL_BACKFILL_BLOCK_RATIO_THRESHOLD"
|
||
)
|
||
|
||
# ── #884/#905/#1805: BrowserFetcher — HTTP-клиент к tradein-browser ─────────
|
||
# scraper_fetch_mode: "browser" (дефолт с #1805 — HTTP POST к tradein-browser
|
||
# /fetch через per-provider camoufox + ротирующий backconnect-прокси) или
|
||
# "curl_cffi" (legacy TLS-impersonate путь). Phase 2 epic #883: avito SERP-фетч
|
||
# по умолчанию через браузер; curl_cffi сохранён как fallback (см.
|
||
# AvitoScraper._fetch_serp_html) и явный opt-out через ENV SCRAPER_FETCH_MODE.
|
||
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "browser"
|
||
# HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
|
||
# ENV: BROWSER_HTTP_ENDPOINT.
|
||
browser_http_endpoint: str = "http://tradein-browser:3000"
|
||
# Сколько страниц обработать в одном browser-сеансе перед перезапуском браузера
|
||
# (ограничение утечек памяти). Читается сервером из env BROWSER_RECYCLE_PAGES.
|
||
# Оставлено для справки / compat. ENV: BROWSER_RECYCLE_PAGES.
|
||
browser_recycle_pages: int = 15
|
||
# Таймаут навигации (page.goto) в мс. Читается сервером из env BROWSER_NAV_TIMEOUT_MS.
|
||
# ENV: BROWSER_NAV_TIMEOUT_MS.
|
||
browser_nav_timeout_ms: int = 60000
|
||
# Ожидание после DOMContentLoaded для JS-гидрации в мс. Читается сервером.
|
||
# ENV: BROWSER_WAIT_MS.
|
||
browser_wait_ms: int = 6000
|
||
|
||
# ── #1995: sell-time-sensitivity — честная маркировка малой выборки ────────
|
||
# /estimate/{id}/sell-time-sensitivity бьёт лоты на 4 price-бакета (cheap/
|
||
# median/plus5/plus10) и считает median/p25/p75 exposure_days по каждому.
|
||
# При n_lots ниже порога результат бакета шумный (замечена немонотонность:
|
||
# +10% продаётся быстрее +5% — артефакт малой выборки, не data-баг). Вместо
|
||
# тихого шума бакет с n_lots < порога помечается insufficient_data=True
|
||
# (SellTimeBucket) — тот же паттерн, что и AvitoImvSummary.thin_market
|
||
# (avito_imv_thin_market_threshold). НЕ сглаживаем/не выдумываем статистику —
|
||
# честная маркировка.
|
||
sell_time_sensitivity_min_n_lots: int = 10
|
||
|
||
# ── Telegram support bridge (@MERAsupport_bot) ───────────────────────────
|
||
# Клиент пишет боту в личку → зеркалится в топик support-группы → оператор
|
||
# отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone
|
||
# long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/.
|
||
# Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0
|
||
# (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN,
|
||
# TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID.
|
||
telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN")
|
||
# Telegram id форум-группы (супергруппы с включёнными топиками), куда
|
||
# зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...).
|
||
telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID")
|
||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||
|
||
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
|
||
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
|
||
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
|
||
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
|
||
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
|
||
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
|
||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||
|
||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||
# PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью:
|
||
# ни один из последующих PR (C/D/E) не должен активироваться без явного
|
||
# включения в .env.runtime прод-стека.
|
||
tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY")
|
||
# Пароль терминала — участвует в подписи Token (Init) и проверке подписи
|
||
# входящих нотификаций. SecretStr по прецеденту auth_db_password (строка
|
||
# 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs.
|
||
tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD")
|
||
tbank_api_base_url: str = Field(
|
||
default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL"
|
||
)
|
||
tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL")
|
||
tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL")
|
||
tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL")
|
||
# "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm).
|
||
# Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по
|
||
# ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow-
|
||
# decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает
|
||
# "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не
|
||
# сверившись с decision-доком.
|
||
tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE")
|
||
tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED")
|
||
tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION")
|
||
tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION")
|
||
# Kill-switch всего контура. false — checkout/notify (появятся в PR-D)
|
||
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
|
||
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
|
||
|
||
# Kill-switch анонимного расчёта на публичном домене (POST/GET
|
||
# /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их
|
||
# нет: включение публичного расчёта открывает запись адреса физлица в
|
||
# trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики
|
||
# обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у
|
||
# payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED.
|
||
public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED")
|
||
|
||
|
||
settings = Settings()
|