gendesign/backend/app/services/scrapers/objective.py
lekss361 aaa5d44e77 feat(objective): integrate api.objctv.ru — auth, schema, weekly sync
backend/app/services/scrapers/objective.py:
  ObjectiveClient — GetToken (Bearer cached в Redis 25 min TTL) +
  GetReport v2 с UseDdu/UseDkp. 4 высокоуровневых метода:
  report_corpuses_summary, report_lots_summary, report_corpuses_per_flat,
  report_lots_per_flat. Retry 401/429/5xx, rate-limit 500ms, brotli.
backend/app/workers/tasks/scrape_objective.py:
  Celery task sync_objective_group — еженедельно тянет 4 канон. отчёта по
  группе, raw payload в objective_raw_reports.
celery_app.py: +include + beat «0 5 * * mon».
data/sql/68_schema_objective.sql: 6 таблиц — runs, raw_reports (jsonb),
oks (готовые ТЭП + escrow/debt), lots, lots_history (per-flat per-day),
complex_mapping (Objective ↔ domrf_kn_objects, is_reviewed workflow).
data/sql/69_objective_smoke.py: stand-alone GetToken + 4 отчёта →
data/raw/objective_smoke/<ts>/. Используется один раз чтобы понять
реальную схему payload перед написанием parser-слоя.
config: OBJECTIVE_API_KEY, OBJECTIVE_DEFAULT_GROUP=Екатеринбург,
OBJECTIVE_SYNC_CRON='0 5 * * mon'.
2026-05-07 21:04:00 +03:00

448 lines
18 KiB
Python
Raw 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.

"""Клиент 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 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
# ── публичные методы отчётов ────────────────────────────────────────────
@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