592 lines
24 KiB
Python
592 lines
24 KiB
Python
"""Клиент API Объектива (api.objctv.ru) — аналитика первички/вторички.
|
||
|
||
Architecture:
|
||
1. GetToken: GET /Users/User/GetToken?apiKey=<KEY> → {"token": "..."}
|
||
Bearer-токен короткоживущий (~30 мин), кешируется в Redis.
|
||
2. GetReport: GET /v2/Report/GetReport?<params> с Authorization: Bearer <token>
|
||
Возвращает 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=<KEY>. Возвращает 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.<fn_name>(**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
|