"""Клиент API Объектива (api.objctv.ru) — аналитика первички/вторички. Architecture: 1. GetToken: GET /Users/User/GetToken?apiKey= → {"token": "..."} Bearer-токен короткоживущий (~30 мин), кешируется в Redis. 2. GetReport: GET /v2/Report/GetReport? с Authorization: Bearer Возвращает JSON-отчёт по комбинации (Page, ReportSection, ReportType, ReportName, GroupName, [ComplexName], [StartDate], [EndDate], [UseDdu], [UseDkp]). Структура отчётов (из их docs): - Page=Отчеты + ReportSection=Объединенные данные + ReportType={Сводные|Поквартирные} + ReportName={Корпуса|Лоты} = 4 канонические комбинации. - StartDate/EndDate в формате YYYY.MM.DD (точка-разделитель!). - UseDdu (default=true) / UseDkp (default=false) — тип сделок в свод-отчётах. Защита от лимитов: - rate-limit: 500мс между запросами (наблюдаемая безопасная зона B2B-API) - 401 → re-authenticate token (TTL истёк или revoked) - 429 / 5xx → exponential backoff - Brotli по дефолту в Accept-Encoding (их рекомендация). """ from __future__ import annotations import json import logging import time from datetime import date, datetime from typing import Any import httpx import redis from app.core.config import settings logger = logging.getLogger(__name__) _TOKEN_REDIS_KEY = "objective:bearer_token" _DEFAULT_TIMEOUT_S = 30 _DEFAULT_RATE_MS = 500 _DEFAULT_RETRIES = 3 # Process-локальный кеш токена — fallback на случай, когда Redis недоступен # (локальный запуск скриптов вне docker, сетевой сбой). Tuple = (token, expires_at). _TOKEN_MEMORY_CACHE: dict[str, tuple[str, float]] = {} class ObjectiveAuthError(RuntimeError): """Не удалось получить/обновить Bearer-токен (401 на /GetToken).""" class ObjectiveAPIError(RuntimeError): """Не-401 ошибка Объектив-API (4xx бизнес-логики или 5xx сервера).""" class ObjectiveClient: """HTTP-клиент к api.objctv.ru с кешированием токена в Redis. Usage: client = ObjectiveClient() report = client.get_report( report_section="Объединенные данные", report_type="Сводные", report_name="Корпуса", group_name="Екатеринбург", start_date=date(2025, 5, 1), end_date=date(2025, 6, 1), use_ddu=True, use_dkp=True, ) """ def __init__( self, api_key: str | None = None, base_url: str | None = None, timeout_s: int = _DEFAULT_TIMEOUT_S, rate_ms: int = _DEFAULT_RATE_MS, retries: int = _DEFAULT_RETRIES, ) -> None: self.api_key = api_key or settings.objective_api_key if not self.api_key: raise ObjectiveAuthError("OBJECTIVE_API_KEY не задан (settings.objective_api_key пуст)") self.base_url = (base_url or settings.objective_base_url).rstrip("/") self.timeout_s = timeout_s self.rate_ms = rate_ms self.retries = retries # Brotli + JSON. httpx по умолчанию умеет br/gzip. self._client = httpx.Client( timeout=timeout_s, headers={ "Accept": "application/json", "Accept-Encoding": "br, gzip", "User-Agent": "gendesign/1.0 (+https://gendsgn.ru)", }, ) self._redis = redis.Redis.from_url(settings.redis_url, decode_responses=True) self._last_request_ts: float = 0.0 # ── токен ─────────────────────────────────────────────────────────────── def _get_cached_token(self) -> str | None: try: val = self._redis.get(_TOKEN_REDIS_KEY) if val: return val except redis.RedisError as e: # Логируем один раз на инстанс — не спамить, локальный запуск это норма. if not getattr(self, "_redis_warned", False): logger.warning("Redis недоступен, fallback на in-memory кеш токена: %s", e) self._redis_warned = True # Fallback: in-memory. cached = _TOKEN_MEMORY_CACHE.get(_TOKEN_REDIS_KEY) if cached: token, expires_at = cached if time.time() < expires_at: return token _TOKEN_MEMORY_CACHE.pop(_TOKEN_REDIS_KEY, None) return None def _cache_token(self, token: str) -> None: ttl = settings.objective_token_ttl_seconds try: self._redis.set(_TOKEN_REDIS_KEY, token, ex=ttl) except redis.RedisError: pass # уже залогировали в _get_cached_token # Всегда дублируем в память — на случай если Redis потеряется между set/get. _TOKEN_MEMORY_CACHE[_TOKEN_REDIS_KEY] = (token, time.time() + ttl) def _fetch_new_token(self) -> str: """GET /Users/User/GetToken?apiKey=. Возвращает Bearer-токен.""" url = f"{self.base_url}/Users/User/GetToken" params = {"apiKey": self.api_key} logger.info("Objective: запрос нового Bearer-токена") try: r = self._client.get(url, params=params) except httpx.HTTPError as e: raise ObjectiveAuthError(f"Сетевая ошибка GetToken: {e}") from e if r.status_code == 401: raise ObjectiveAuthError("apiKey не принят (401 на GetToken)") if r.status_code != 200: raise ObjectiveAuthError(f"GetToken HTTP {r.status_code}: {r.text[:200]}") data = r.json() # Ответ: {"token": "..."} либо {"Token": "..."} либо строка — нормализуем. if isinstance(data, str): token = data elif isinstance(data, dict): token = data.get("token") or data.get("Token") or data.get("accessToken") else: token = None if not token: raise ObjectiveAuthError(f"GetToken вернул неожиданный формат: {str(data)[:200]}") self._cache_token(token) return token def _get_token(self, force_refresh: bool = False) -> str: if not force_refresh: cached = self._get_cached_token() if cached: return cached return self._fetch_new_token() # ── HTTP общий ────────────────────────────────────────────────────────── def _wait_rate_limit(self) -> None: elapsed_ms = (time.time() - self._last_request_ts) * 1000.0 if elapsed_ms < self.rate_ms: time.sleep((self.rate_ms - elapsed_ms) / 1000.0) self._last_request_ts = time.time() def _request_authed( self, path: str, params: dict[str, Any], *, attempt: int = 0, force_refresh_token: bool = False, ) -> Any: token = self._get_token(force_refresh=force_refresh_token) url = f"{self.base_url}{path}" self._wait_rate_limit() try: r = self._client.get( url, params=params, headers={"Authorization": f"Bearer {token}"}, ) except httpx.HTTPError as e: if attempt < self.retries: wait = min(2**attempt, 30) logger.warning( "Objective net-error %s (attempt %d/%d), retry in %ds", e, attempt + 1, self.retries, wait, ) time.sleep(wait) return self._request_authed(path, params, attempt=attempt + 1) raise ObjectiveAPIError(f"Сетевая ошибка после {self.retries} попыток: {e}") from e if r.status_code == 401: # TTL истёк или revoked. Один раз пробуем re-auth. if not force_refresh_token: logger.info("Objective: 401, рефрешим токен и повторяем") return self._request_authed(path, params, attempt=attempt, force_refresh_token=True) raise ObjectiveAuthError("401 даже после refresh токена — apiKey невалиден") if r.status_code in (429, 502, 503, 504): if attempt < self.retries: # Backoff: 30, 60, 120, 240… (потолок 300с) — Objective любит держать # 429 пока не пройдёт rate-window (~30с на дешёвых тарифах). # Если есть Retry-After — слушаем его (но капаем потолком). retry_after_hdr = r.headers.get("Retry-After") if retry_after_hdr and retry_after_hdr.isdigit(): wait = min(int(retry_after_hdr), 300) else: wait = min(30 * (2**attempt), 300) logger.warning( "Objective HTTP %s (attempt %d/%d), retry in %ds", r.status_code, attempt + 1, self.retries, wait, ) time.sleep(wait) return self._request_authed(path, params, attempt=attempt + 1) raise ObjectiveAPIError(f"HTTP {r.status_code} после ретраев: {r.text[:200]}") if r.status_code != 200: raise ObjectiveAPIError(f"HTTP {r.status_code}: {r.text[:500]}") try: return r.json() except json.JSONDecodeError as e: raise ObjectiveAPIError( f"Невалидный JSON: {e}; первые 200 символов: {r.text[:200]}" ) from e # ── публичные методы отчётов ──────────────────────────────────────────── @staticmethod def _fmt_date(d: date | str | None) -> str | None: if d is None: return None if isinstance(d, str): return d # ожидаем уже YYYY.MM.DD return d.strftime("%Y.%m.%d") def get_report( self, *, report_section: str = "Объединенные данные", report_type: str = "Сводные", report_name: str = "Корпуса", group_name: str | None = None, complex_name: str | None = None, start_date: date | str | None = None, end_date: date | str | None = None, use_ddu: bool | None = True, use_dkp: bool | None = None, page: str = "Отчеты", v2: bool = True, ) -> Any: """Универсальный вызов GetReport. Возвращает распарсенный JSON. Args: report_section: например "Объединенные данные" report_type: "Сводные" | "Поквартирные" report_name: "Корпуса" | "Лоты" | др. group_name: "Екатеринбург" (default из settings) complex_name: фильтр по конкретному ЖК (опционально) start_date / end_date: для Сводных — обязательны use_ddu / use_dkp: только для v2 (новый API) page: всегда "Отчеты" v2: True → /v2/Report/GetReport (с UseDdu/UseDkp), False → /Report/GetReport """ path = "/v2/Report/GetReport" if v2 else "/Report/GetReport" params: dict[str, Any] = { "Page": page, "ReportSection": report_section, "ReportType": report_type, "ReportName": report_name, "GroupName": group_name or settings.objective_default_group, } if complex_name: params["ComplexName"] = complex_name if start_date is not None: params["StartDate"] = self._fmt_date(start_date) if end_date is not None: params["EndDate"] = self._fmt_date(end_date) if v2: if use_ddu is not None: params["UseDdu"] = "true" if use_ddu else "false" if use_dkp is not None: params["UseDkp"] = "true" if use_dkp else "false" logger.info( "Objective.get_report: %s/%s/%s gr=%s cn=%s [%s..%s] ddu=%s dkp=%s", report_section, report_type, report_name, params["GroupName"], complex_name, params.get("StartDate"), params.get("EndDate"), use_ddu, use_dkp, ) return self._request_authed(path, params) # ── удобные обёртки под наши 4 канонические отчёта ────────────────────── def report_corpuses_summary( self, *, group_name: str | None = None, complex_name: str | None = None, start_date: date | None = None, end_date: date | None = None, use_ddu: bool = True, use_dkp: bool = True, ) -> Any: """Объединенные данные / Сводные / Корпуса — агрегаты per oks за период.""" return self.get_report( report_section="Объединенные данные", report_type="Сводные", report_name="Корпуса", group_name=group_name, complex_name=complex_name, start_date=start_date, end_date=end_date, use_ddu=use_ddu, use_dkp=use_dkp, ) def report_lots_per_flat( self, *, group_name: str | None = None, complex_name: str | None = None, use_ddu: bool = True, use_dkp: bool = True, ) -> Any: """Объединенные данные / Поквартирные / Лоты — каждая квартира с историей.""" return self.get_report( report_section="Объединенные данные", report_type="Поквартирные", report_name="Лоты", group_name=group_name, complex_name=complex_name, use_ddu=use_ddu, use_dkp=use_dkp, ) def report_lots_summary( self, *, group_name: str | None = None, complex_name: str | None = None, start_date: date | None = None, end_date: date | None = None, use_ddu: bool = True, use_dkp: bool = True, ) -> Any: """Объединенные данные / Сводные / Лоты — агрегаты per ЖК (mix-структура).""" return self.get_report( report_section="Объединенные данные", report_type="Сводные", report_name="Лоты", group_name=group_name, complex_name=complex_name, start_date=start_date, end_date=end_date, use_ddu=use_ddu, use_dkp=use_dkp, ) def report_corpuses_per_flat( self, *, group_name: str | None = None, complex_name: str | None = None, use_ddu: bool = True, use_dkp: bool = True, ) -> Any: """Объединенные данные / Поквартирные / Корпуса — детально по корпусам.""" return self.get_report( report_section="Объединенные данные", report_type="Поквартирные", report_name="Корпуса", group_name=group_name, complex_name=complex_name, use_ddu=use_ddu, use_dkp=use_dkp, ) # ── lifecycle ─────────────────────────────────────────────────────────── def close(self) -> None: try: self._client.close() except Exception: pass def __enter__(self) -> ObjectiveClient: return self def __exit__(self, *_exc: Any) -> None: self.close() # ── удобный one-shot helper ───────────────────────────────────────────────── def fetch_with_raw_log( fn_name: str, *, save_dir: str | None = None, **kwargs: Any, ) -> tuple[Any, str | None]: """Вызывает client.(**kwargs), сохраняет raw JSON в файл если задан save_dir. Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill.""" from pathlib import Path client = ObjectiveClient() try: method = getattr(client, fn_name) data = method(**kwargs) finally: client.close() file_path: str | None = None if save_dir: Path(save_dir).mkdir(parents=True, exist_ok=True) ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") slug = ( "_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v) or "all" ) slug = slug.replace(" ", "_").replace("/", "_") file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json" Path(file_path).write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) return data, file_path