"""Клиент 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 collections.abc import Iterator from contextlib import contextmanager 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 def _open_stream( self, path: str, params: dict[str, Any], *, attempt: int = 0, force_refresh_token: bool = False, ) -> httpx.Response: """Открывает streaming GET, возвращает httpx.Response (не читает тело). Включает retry/auth логику идентичную _request_authed(). Caller обязан держать контекст через `with self._client.stream(...)` — вызов делается внутри stream_report() который гарантирует закрытие. """ token = self._get_token(force_refresh=force_refresh_token) url = f"{self.base_url}{path}" self._wait_rate_limit() try: r = self._client.send( self._client.build_request( "GET", url, params=params, headers={"Authorization": f"Bearer {token}"}, ), stream=True, ) except httpx.HTTPError as e: if attempt < self.retries: wait = min(2**attempt, 30) logger.warning( "Objective stream net-error %s (attempt %d/%d), retry in %ds", e, attempt + 1, self.retries, wait, ) time.sleep(wait) return self._open_stream(path, params, attempt=attempt + 1) raise ObjectiveAPIError( f"Сетевая ошибка stream после {self.retries} попыток: {e}" ) from e if r.status_code == 401: r.close() if not force_refresh_token: logger.info("Objective stream: 401, рефрешим токен и повторяем") return self._open_stream(path, params, attempt=attempt, force_refresh_token=True) raise ObjectiveAuthError("401 даже после refresh токена — apiKey невалиден") if r.status_code in (429, 502, 503, 504): retry_after_hdr = r.headers.get("Retry-After") r.close() if attempt < self.retries: 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 stream HTTP %s (attempt %d/%d), retry in %ds", r.status_code, attempt + 1, self.retries, wait, ) time.sleep(wait) return self._open_stream(path, params, attempt=attempt + 1) raise ObjectiveAPIError(f"Stream HTTP {r.status_code} после ретраев") if r.status_code != 200: body_preview = r.read()[:200] r.close() raise ObjectiveAPIError(f"Stream HTTP {r.status_code}: {body_preview!r}") return r @contextmanager def stream_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, ) -> Iterator[httpx.Response]: """Контекст-менеджер для streaming GetReport. Usage: with client.stream_report(report_type='Поквартирные', report_name='Лоты', group_name='Свердловская область') as resp: for chunk in resp.iter_bytes(65536): ... Caller итерирует тело через resp.iter_bytes() или resp.iter_raw(). Метод НЕ вызывает .json() — всё чтение на стороне caller'а. """ 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.stream_report: %s/%s/%s gr=%s cn=%s ddu=%s dkp=%s", report_section, report_type, report_name, params["GroupName"], complex_name, use_ddu, use_dkp, ) resp = self._open_stream(path, params) try: yield resp finally: try: resp.close() except Exception: pass # ── публичные методы отчётов ──────────────────────────────────────────── @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