gendesign/backend/app/core/config.py
bot-backend 7315d04396
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
fix(scrapers): WAF-guard эндпоинтов + throttled catalog BrowserSession (#2445-D1/D2) (#2459)
2026-07-05 20:09:06 +00:00

375 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import warnings
from typing import Annotated
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign"
redis_url: str = "redis://localhost:6379/0"
cors_origins: list[str] = ["http://localhost:3000"]
# GlitchTip error tracking (Sentry-compatible self-hosted).
# Формат DSN: https://<key>@errors.gendsgn.ru/<project_id>
# Пустая строка / None = SDK не инициализируется (no-op).
glitchtip_dsn: str | None = None
glitchtip_traces_sample_rate: float = 0.05
environment: str = "dev"
# Test-mode flag (env TESTING=1). СТРОГО default False — в проде RBAC-гейт
# (app/main.py rbac_guard) активен. True только в pytest (tests/conftest.py),
# где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
testing: bool = False
# ── §25.3 «own portfolio» (#1169) ─────────────────────────────────────────
# Целочисленные DOM.РФ developer-id «наших» застройщиков (env OWN_DEVELOPER_IDS,
# comma-separated → list[int]; пусто/не задано → []). Источник «текущих» проектов
# для движка каннибализации §25.3: domrf_kn_objects фильтруется по этим id (по
# числовому префиксу composite dev_id '<devId>_<suffix>', см.
# site_finder/own_portfolio.py).
#
# СТРОГО default [] и НИ ОДНОГО реального id в коде — конфигурируется только через
# окружение прод-контейнера. Пустой список = «текущих» проектов нет: §25.3 own-
# portfolio деградирует ИЗЯЩНО (get_own_portfolio вернёт только manual/future-строки,
# а при их отсутствии — пустой список; PR2-движок честно отдаёт прокси/None вместо
# фабрикации), а НЕ падает.
#
# NoDecode: отключает дефолтный JSON-предпарсинг сложного типа (pydantic-settings для
# list[int] иначе ждёт JSON-литерал '[1,2]' и падает на «человеческой» строке
# '12345,67890'). С NoDecode сырое env-значение доходит до _parse_own_developer_ids
# (mode='before'), где и парсится comma-separated.
own_developer_ids: Annotated[list[int], NoDecode] = []
@field_validator("own_developer_ids", mode="before")
@classmethod
def _parse_own_developer_ids(cls, value: object) -> object:
"""Распарсить OWN_DEVELOPER_IDS из comma-separated env-строки в list[int].
pydantic-settings по умолчанию ждёт для list[int] JSON-литерал ('[1,2]'),
поэтому «человеческую» строку '12345,67890' нужно распарсить вручную. Правила:
• пусто / не задано / '' → [] (graceful default, §25.3 деградирует изящно);
'12345, 67890 ,42' → [12345, 67890, 42] (trim + пропуск пустых токенов);
• уже list (тесты/JSON-env) → нормализуем элементы в int as-is.
Нечисловой токен → ValueError (явная ошибка конфигурации, а не тихий дроп).
"""
if value is None:
return []
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
return [int(tok.strip()) for tok in stripped.split(",") if tok.strip()]
if isinstance(value, list | tuple):
return [int(item) for item in value]
return value
@model_validator(mode="after")
def _promote_legacy_sentry_dsn(self) -> "Settings":
"""Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.
Старый VPS .env.runtime мог содержать SENTRY_DSN=https://...@sentry.io/...
(legacy SaaS Sentry). Промоутить его НЕЛЬЗЯ — события пойдут в чужой проект.
Принимаем только URLs указывающие на наш errors.gendsgn.ru host.
"""
if not self.glitchtip_dsn:
legacy = os.getenv("SENTRY_DSN")
if legacy:
if "errors.gendsgn.ru" in legacy:
warnings.warn(
"SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. "
"Auto-promoting for backward compat.",
DeprecationWarning,
stacklevel=2,
)
self.glitchtip_dsn = legacy
else:
warnings.warn(
f"SENTRY_DSN points to non-GlitchTip host "
f"({legacy.split('@', 1)[-1][:40]}...) — ignoring. "
"Set GLITCHTIP_DSN explicitly.",
UserWarning,
stacklevel=2,
)
return self
# External APIs (Stage 2)
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
overpass_url: str = "https://overpass-api.de/api/interpreter"
# Scraper schedule (наш.дом.рф kn-API).
# Crontab format: "minute hour day_of_month month day_of_week" (Celery crontab).
# Default: каждый понедельник в окне 04:0005:00 по МСК.
scrape_kn_cron: str = "15 4 * * mon"
# Random delay window in seconds added on top of scheduled start so the worker
# does not hit DOM.РФ at exactly the same minute every cycle. 0 = disabled.
scrape_kn_jitter_seconds: int = 1800
# Default region(s) for scheduled sweeps. Comma-separated.
scrape_kn_default_regions: str = "66"
# Path to a pre-captured Playwright storage_state.json (committed in repo,
# used by worker to skip cold-start WAF challenge).
scrape_kn_state_path: str = "data/playwright_state.json"
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
#
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
# (#2443). nspd и прочие скраперы вне этого path family продолжают
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
# 2 — эмпирически безопасный потолок против volume-бана.
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
scrape_kn_browser_concurrency: int = 2
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
# (6001500), чтобы размазать запросы во времени и не триггерить rate-ban.
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
# ширим до 12003000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
scrape_kn_request_jitter_min_ms: int = 1200
scrape_kn_request_jitter_max_ms: int = 3000
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
# через этот прокси (формат http://user:pass@host:port; парсится в
# Playwright proxy={server,username,password}). Сильнейший рычаг против
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
# ENV: SCRAPE_KN_PROXY_URL.
scrape_kn_proxy_url: str | None = None
# Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
# Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
# на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
# отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
# сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
# #1945) рухнул 3670→9 именно из-за этого.
#
# True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
# НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
# best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
# и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
# poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
scrape_kn_extras_isolated: bool = True
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
# Settings оставлены deprecated на случай отката (можно удалить позже).
scrape_nspd_cron: str = "30 3 20 2,5,8,11 *" # DEPRECATED
scrape_nspd_default_regions: str = "66" # DEPRECATED
scrape_nspd_rate_ms: int = 600 # DEPRECATED — see nspd_lite_rate_ms
# Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички.
# Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с
# самой богатой моделью (per-flat per-day, escrow/банк-долг, ЕГРН-ИНН).
# Получить ключ: личный закрытый ApiKey от Объектива (тариф per-территория).
# Tank-схема:
# 1. GET /Users/User/GetToken?apiKey=<KEY> → JSON с Bearer-токеном
# 2. GET /v2/Report/GetReport?... + Authorization: Bearer <token>
objective_api_key: str | None = None
objective_base_url: str = "https://api.objctv.ru"
# Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
# городом/областью. Доступные группы определяются тарифом ключа.
objective_default_group: str = "Екатеринбург"
# Список групп для еженедельного auto-sync (csv). Эмпирически проверено
# 2026-05-10 что эти группы доступны на нашем тарифе:
# - "Свердловская область" → 1626 corp_sum/3д (= ЕКБ + спутники + регион)
# - "Челябинск" → 355
# - "Тюмень" → 985
# - "Пермь" → 423
# Стратегия Variant B (включить наш sync параллельно Антоновому ETL):
# Антон тянет только Екб (303k lots). Наш sync даёт +30k Свердл.обл
# за пределами Екб + ~700k квартир УрФО (Челябинск, Тюмень, Пермь).
# Итог в PG: ~1М квартир уральского + западносибирского рынка.
# Между группами task делает паузу _OBJECTIVE_INTER_GROUP_DELAY = 30с
# чтобы не упереться в rate-limit Объектива.
objective_sync_groups: str = "Свердловская область,Челябинск,Тюмень,Пермь"
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
# Кешируем в Redis с этим TTL (с запасом 5 мин).
objective_token_ttl_seconds: int = 25 * 60
# Расписание sync — еженедельно по вторникам в 06:00 МСК.
# ПОЧЕМУ ВТОРНИК (а не понедельник как раньше): Антон тянет в понедельник,
# к утру вторника его SQLite уже обновлён — наш sync захватывает свежий
# snapshot за тот же отчётный период.
objective_sync_cron: str = "0 6 * * tue"
# NSPD lite (urllib через WAF) — feature toggle для переключения с
# Playwright-based nspd_kn.py на минималистичный nspd_lite.py.
# 2026-05-11: эмпирически проверено что urllib проходит WAF nspd.gov.ru
# (TLS-fingerprint stdlib не блокируется). Старый Playwright-путь
# остаётся как fallback на случай возврата WAF-проблем.
use_nspd_lite: bool = True
# Default rate-limit для nspd_lite fetcher (мс между запросами).
nspd_lite_rate_ms: int = 600
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
reports_dir: str = "/app/reports"
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
# На проде монтируется bind-mount-ом docker-compose:
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
# Триггерится вручную через POST /api/v1/admin/scrape/objective.
# Для локальной разработки — указать абсолютный путь к скачанному snapshot:
# OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"
# Cross-load ETL tradein→gendesign (#976 950-E5).
# Прямой psycopg-коннект к tradein-postgres через gendesign_shared network.
# Пример: postgresql://gendesign_reader:<pw>@tradein-postgres:5432/tradein
# Если пусто — ETL отключён (warn-log, задача возвращает {"disabled": true}).
tradein_database_url: str = ""
# OpenRouteService API (https://openrouteservice.org/dev/#/signup).
# Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones.
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
openrouteservice_api_key: str = ""
# ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
# Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
# (self-hosted, /table GET с annotations=distance → метры) вместо
# straight-line ST_Distance в POI-скоринге.
#
# use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
# на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
# (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
use_osrm_distances: bool = False
# URL локального OSRM (docker-compose service `osrm`, DRIVING-граф). /table:
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
osrm_local_url: str = "http://osrm:5000"
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
# сервисе — при превышении → fallback на straight-line.
osrm_distance_timeout_s: float = 12.0
# ── Per-category OSRM routing (#39 A3) ────────────────────────────────────
# Валидация показала: DRIVING-расстояние ПЕРЕОЦЕНИВАЕТ пешеходно-релевантные
# POI (школа/магазин/парк) — медиана 1.62.9× straight-line. Фикс: walk-POI
# маршрутизируем по FOOT-графу (отдельный сервис `osrm-walk`), car-POI — по
# существующему DRIVING-графу.
#
# URL локального OSRM с FOOT-графом (docker-compose service `osrm-walk`):
# GET {url}/table/v1/foot/{coords}?sources=0&annotations=distance
osrm_walk_local_url: str = "http://osrm-walk:5000"
# Категории POI, маршрутизируемые по FOOT-графу (пешая доступность). Всё, чего
# НЕТ в этом множестве — incl. `shop_mall`, `hospital` и любая неизвестная/None
# категория — идёт через DRIVING-граф как безопасный дефолт (drive-side).
osrm_walk_categories: frozenset[str] = frozenset(
{
"shop_small",
"pharmacy",
"shop_supermarket",
"kindergarten",
"school",
"park",
"bus_stop",
"tram_stop",
"metro_stop",
}
)
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
# zone_regulation_cache (#1102). Override через env ENABLE_IRD_ANALYZE=false остаётся
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = True
# РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
# analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
# СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
# (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
# post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
# Override через env ENABLE_RIASURT_GATE=true.
enable_riasurt_gate: bool = False
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
# (callable вручную для smoke-тестирования конкретного bbox).
enable_riasurt_harvest: bool = False
# Реальный ПЗЗ-градрегламент зоны (КСИТ/max_far, высота, этажность, %застройки,
# min площадь ЗУ) в ответе analyze: поле `nspd_zoning` дополняется числовыми
# предельными параметрами из zone_regulation_cache (Route 2 — coordinate-based
# resolver get_or_fetch_zone_regulation, cache-first + bounded live geoportal на
# miss). Кэш прогрет для ЕКБ (33 зоны), поэтому live-вызовы редки и закапаны
# коротким timeout'ом. Hot-path-safe: любой сбой/таймаут → поля None, analyze не
# падает. По умолчанию ON; выключение (env ENABLE_ZONING_REGULATION_IN_ANALYZE=false)
# полностью пропускает резолв — поведение analyze без изменений.
enable_zoning_regulation_in_analyze: bool = True
# Единый bounded-timeout (сек) для live geoportal-вызовов резолвера ПЗЗ-регламента в
# hot-пути analyze (#1850). Раньше было два расходящихся значения: parcels.py=3s,
# ird_analyze.py=4s для ОДНОГО и того же резолвера. Унифицировано в 4s (более безопасный
# запас). Оба колл-сайта работают cache-first (+ мемоизация coord→zone_index), поэтому
# живые вызовы редки. Дефолт клиента EKBGeoportalClient (20s) слишком долог для sync-хэндлера.
# Override через env GEOPORTAL_TIMEOUT_S.
geoportal_timeout_s: int = 4
# Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
# ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
# застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
# участок — МКД сажается на необременённом остатке. Поэтому такая зона блокирует МКД
# ТОЛЬКО когда покрывает > этой доли площади участка (фактически стерилизует его);
# ниже порога — warning «учесть при посадке». Default 0.6: на проде ЕКБ медиана покрытия
# ~6%, p90 ~47% → блокирует лишь экстремальный хвост (1/17 участков). Override через env
# GATE_ZOUIT_ENGINEERING_BLOCKER_MIN_COVERAGE.
gate_zouit_engineering_blocker_min_coverage: float = 0.6
# ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
# fallback (см. app/services/llm/client.py).
#
# llm_enabled СТРОГО default False: пока секреты не настроены в проде, клиент
# НЕ делает сетевых вызовов вообще (guard #2 в client.complete). Включать только
# после того как OPENAI_API_KEY заведён в окружение прод-контейнера И принято
# решение по §19 data-residency (провайдер внешний — данные покидают РФ).
llm_enabled: bool = False
# Ключ читается ТОЛЬКО отсюда (env OPENAI_API_KEY). Нигде в коде/тестах нет
# литерала ключа. None = ключ не задан → клиент ведёт себя как при llm_enabled=False.
openai_api_key: str | None = None
llm_model: str = "gpt-4o-mini"
llm_base_url: str = "https://api.openai.com/v1"
# Таймаут одного HTTP-вызова к провайдеру (сек). Воркер не должен висеть.
llm_timeout_s: float = 30.0
llm_max_output_tokens: int = 1024
# Верхняя граница числа LLM-вызовов в рамках одной логической операции
# (граддок-extraction / chat-turn) — защита от случайного цикла у консьюмера.
llm_max_calls_per_request: int = 4
# Бюджетный потолок (USD) — пока только логируется как оценка (tokens→$).
# None = без потолка. Жёсткий enforcement добавит консьюмер при необходимости.
llm_daily_cost_cap_usd: float | None = None
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
llm_max_retries: int = 2
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
dadata_api_token: str | None = None
dadata_api_secret: str | None = None
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
dadata_timeout_s: float = 8.0
settings = Settings()