Два P2-фикса в чувствительном estimator.py, оба за флагами. DEDUP (estimate_radius_dedup_enabled, default ON): radius-путь _fetch_analogs (Tier S-canonical/S-fallback/H/W) кэпил только per-address (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS=5), но (source,source_id)-дубли делят один address и выживали на разных rn_addr рангах → раздували n_analogs (prod 2026-06-23: yandex 48, cian 9, n1 5 excess). Добавлен rn_dup-window PARTITION BY source, CASE(id:source_id|url:source_url|ctid) ORDER BY scraped_at DESC + AND rn_dup=1 во все 4 тира — зеркалит anchor-dedup (:1556). CASE/ctid-guard не схлопывает legacy NULL-source_id. ctid доступен (все тиры FROM listings single-table). psycopg3: column::type (легально), bind-param :: отсутствует. SPLIT DISCLOSURE (estimate_wide_corridor_disclosure_enabled, default OFF): Tier A матчит по address-regex (намеренно — дом дробится на house_id), на split-доме разной этажности comp_min..max растягивает коридор до 148-170%. При corridor_pct > threshold (0.6) → понизить confidence на ступень + caveat «дом разбит на секции разной этажности — оценка ориентировочная». НЕ трогает point/median/range — только confidence+explanation. Helper _downgrade_confidence. Default OFF (no-regress, включим осознанно). Merge house_id отклонён (recon: вреден — Tier A намеренно обходит house_id-группировку). Тесты: +22 (dedup 4 тира + семантика + psycopg3-regex guard; split disclosure через реальный _run_estimate harness + does-not-touch-median/range + helper). 399 estimator passed, ruff clean, mypy pre-existing-only. Refs #1871
457 lines
35 KiB
Python
457 lines
35 KiB
Python
"""Минимальный settings для standalone trade-in MVP."""
|
||
|
||
from typing import Literal
|
||
|
||
from pydantic import Field
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
|
||
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")
|
||
|
||
cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
|
||
environment: str = "dev"
|
||
|
||
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
||
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
||
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
||
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
|
||
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
|
||
# для 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-IP sliding window). ENV: RATE_LIMIT,
|
||
# RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s
|
||
# секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от
|
||
# Caddy basic_auth) не лимитируется — см. ratelimit.py (#655).
|
||
rate_limit: int = 300
|
||
rate_limit_window_s: float = 60.0
|
||
|
||
# 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_enabled: bool = True
|
||
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-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
|
||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||
estimate_same_building_anchor_enabled: bool = True
|
||
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
|
||
# ── #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_enabled: bool = True
|
||
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. False → no-op.
|
||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||
estimate_radius_floor_enabled: bool = True
|
||
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 — не затрагиваются.
|
||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||
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 → ненадёжный якорь
|
||
# Отдельный флаг: False → точно старое поведение (гейта нет).
|
||
estimate_sb_low_conf_gate_enabled: bool = True
|
||
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
|
||
|
||
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
|
||
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
|
||
# double-count в house_placement_history → шум в помесячной медиане тренда.
|
||
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
|
||
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
|
||
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
|
||
# False = старое поведение без дедупа (backward-compat).
|
||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
||
estimate_price_trend_dedup_enabled: bool = True
|
||
|
||
# ── #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 нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
|
||
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
|
||
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
|
||
estimate_radius_dedup_enabled: bool = True
|
||
|
||
# ── #1871 P2: split-дома wide-corridor disclosure (default OFF) ────────────
|
||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||
# disclosure в explanation. НЕ трогает point/median/range. default False
|
||
# (no-regress, включаем осознанно). ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
|
||
estimate_wide_corridor_disclosure_enabled: bool = False
|
||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure (60%).
|
||
estimate_wide_corridor_threshold: float = 0.6
|
||
|
||
# ── 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-4: MAD-clip after similarity-weighting ─────────────────────────
|
||
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
|
||
# False = clip ДО weighting (старое поведение). Дефолт True.
|
||
estimate_sb_clip_after_weight: bool = True
|
||
|
||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||
sber_index_max_age_days: int = 35
|
||
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
|
||
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
|
||
avito_imv_thin_market_threshold: int = 10
|
||
|
||
# ── #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.
|
||
# За флагом: False → точно текущее поведение (backward-compatible).
|
||
estimate_quarter_index_enabled: bool = True
|
||
# Минимальное число сделок в квартале (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
|
||
|
||
# ── 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
|
||
estimate_geocode_budget_s: float = 12.0
|
||
estimate_house_meta_timeout_s: float = 8.0
|
||
|
||
# Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658).
|
||
# Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15.
|
||
estimate_quota_limit: int = 15
|
||
|
||
# Фильтр 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
|
||
|
||
# ppm2-tier asking->sold lookup (#928) -- gate + empirical-Bayes shrink parameter.
|
||
# tier_aware_ratio_enabled=False (default) -> byte-identical flag-OFF path via
|
||
# asking_to_sold_ratios (old table). True -> tiered lookup from
|
||
# asking_to_sold_ratios_tiered (requires migration 098 applied).
|
||
# ENV: TIER_AWARE_RATIO_ENABLED.
|
||
tier_aware_ratio_enabled: bool = False
|
||
# Empirical-Bayes shrink weight denominator: w = n_deals / (n_deals + K).
|
||
# K=150 -> cell with 150 deals gets w=0.5 (50% own tier ratio, 50% rooms-'all').
|
||
# Higher K -> more shrinkage toward 'all' ratio (conservative); lower -> more tier.
|
||
# ENV: TIER_RATIO_SHRINK_K.
|
||
tier_ratio_shrink_k: int = 150
|
||
|
||
# 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",
|
||
"ekaterinburg.n1.ru",
|
||
"n1.ru",
|
||
"domclick.ru",
|
||
"www.domclick.ru",
|
||
}
|
||
|
||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
||
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
|
||
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
|
||
# Пусто = прямое подключение (dev/staging без прокси).
|
||
#
|
||
# Приоритет ENV-переменных (precedence):
|
||
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
|
||
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
|
||
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
|
||
# property `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")
|
||
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
|
||
|
||
@property
|
||
def scraper_proxy_url(self) -> str | None:
|
||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
|
||
|
||
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
|
||
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
|
||
"""
|
||
return self.scraper_proxy_url_env or self.avito_proxy_url
|
||
|
||
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
|
||
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
|
||
# ENV: AVITO_PROXY_ROTATE_URL.
|
||
avito_proxy_rotate_url: str | None = None
|
||
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
|
||
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
|
||
# ротации на старте (Datadome ban recovery).
|
||
avito_proxy_max_rotations: int = 4
|
||
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
|
||
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
|
||
avito_proxy_rotate_settle_s: float = 9.0
|
||
|
||
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
|
||
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
|
||
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
|
||
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
|
||
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
|
||
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
|
||
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
|
||
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
|
||
cian_proxy_rotate_url: str | None = None
|
||
|
||
@property
|
||
def cian_proxy_url(self) -> str | None:
|
||
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
|
||
return self.cian_proxy_url_env or self.scraper_proxy_url
|
||
|
||
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
|
||
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
|
||
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
|
||
# ENV: YANDEX_PROXY_URL.
|
||
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
|
||
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
|
||
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
|
||
yandex_proxy_rotate_url: str | None = None
|
||
|
||
@property
|
||
def yandex_proxy_url(self) -> str | None:
|
||
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
|
||
return self.yandex_proxy_url_env or 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
|
||
|
||
# ── 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.
|
||
avito_detail_backfill_use_curl: bool = Field(
|
||
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||
)
|
||
|
||
# ── #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
|
||
|
||
|
||
settings = Settings()
|