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'.
This commit is contained in:
parent
b44c7f157e
commit
aaa5d44e77
9 changed files with 2498 additions and 3 deletions
|
|
@ -43,5 +43,23 @@ class Settings(BaseSettings):
|
||||||
# Пауза между NSPD-запросами в мс. <600мс — высокий риск WAF 403.
|
# Пауза между NSPD-запросами в мс. <600мс — высокий риск WAF 403.
|
||||||
scrape_nspd_rate_ms: int = 600
|
scrape_nspd_rate_ms: int = 600
|
||||||
|
|
||||||
|
# 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 = "Екатеринбург"
|
||||||
|
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
|
||||||
|
# Кешируем в Redis с этим TTL (с запасом 5 мин).
|
||||||
|
objective_token_ttl_seconds: int = 25 * 60
|
||||||
|
# Расписание sync — еженедельно по понедельникам в 05:00 МСК (после kn-sweep).
|
||||||
|
objective_sync_cron: str = "0 5 * * mon"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
448
backend/app/services/scrapers/objective.py
Normal file
448
backend/app/services/scrapers/objective.py
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
"""Клиент 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
|
||||||
|
|
@ -42,6 +42,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.scrape_kn",
|
"app.workers.tasks.scrape_kn",
|
||||||
"app.workers.tasks.scrape_nspd",
|
"app.workers.tasks.scrape_nspd",
|
||||||
"app.workers.tasks.refresh_analytics",
|
"app.workers.tasks.refresh_analytics",
|
||||||
|
"app.workers.tasks.scrape_objective",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
@ -85,6 +86,17 @@ celery_app.conf.beat_schedule["refresh-ekb-districts-medians"] = {
|
||||||
"kwargs": {"window_months": 24, "min_deals": 50},
|
"kwargs": {"window_months": 24, "min_deals": 50},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Objective sync — еженедельно (объёмы умеренные, ключ платный = бережём limits).
|
||||||
|
# По умолчанию: пн 05:00 МСК. Если OBJECTIVE_API_KEY пуст — task сразу skipped.
|
||||||
|
celery_app.conf.beat_schedule["objective-sync"] = {
|
||||||
|
"task": "tasks.scrape_objective.sync_objective_group",
|
||||||
|
"schedule": _parse_cron(settings.objective_sync_cron),
|
||||||
|
"kwargs": {
|
||||||
|
"group_name": settings.objective_default_group,
|
||||||
|
"triggered_by": "beat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@worker_ready.connect
|
@worker_ready.connect
|
||||||
def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
|
|
|
||||||
328
backend/app/workers/tasks/scrape_objective.py
Normal file
328
backend/app/workers/tasks/scrape_objective.py
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
"""Celery task для регулярного sync с api.objctv.ru.
|
||||||
|
|
||||||
|
Стратегия:
|
||||||
|
Раз в неделю (по умолчанию пн 05:00 МСК) тянем 2 канонических отчёта
|
||||||
|
по группе из settings.objective_default_group:
|
||||||
|
- Сводные/Корпуса (за последний полный месяц) → objective_corpus_room_month
|
||||||
|
- Поквартирные/Лоты (без дат — текущая шахматка) → objective_lots + history
|
||||||
|
|
||||||
|
NB: На текущем тарифе Объектива «Сводные/Лоты» и «Поквартирные/Корпуса»
|
||||||
|
отвечают HTTP 500 (проверено data/sql/69b_objective_minimal.py). Когда тариф
|
||||||
|
расширят — добавить обратно в `jobs`.
|
||||||
|
|
||||||
|
Все payload-ы пишутся в objective_raw_reports (jsonb-колонка) и сразу же
|
||||||
|
парсятся в нормализованный слой через data/sql/70_parse_objective_raw.py.
|
||||||
|
Если парсинг упал — raw остаётся, можно re-parse через
|
||||||
|
`python data/sql/70_parse_objective_raw.py --latest-run`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.scrapers.objective import (
|
||||||
|
ObjectiveAPIError,
|
||||||
|
ObjectiveAuthError,
|
||||||
|
ObjectiveClient,
|
||||||
|
)
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_run(db: Session, group_name: str, triggered_by: str) -> int:
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
|
||||||
|
VALUES (:gn, :tb, 'running')
|
||||||
|
RETURNING run_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"gn": group_name, "tb": triggered_by},
|
||||||
|
).scalar_one()
|
||||||
|
db.commit()
|
||||||
|
return int(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _heartbeat(db: Session, run_id: int, **counts: int) -> None:
|
||||||
|
sets = ["heartbeat_at = NOW()"]
|
||||||
|
params: dict[str, Any] = {"rid": run_id}
|
||||||
|
for k, v in counts.items():
|
||||||
|
sets.append(f"{k} = :{k}")
|
||||||
|
params[k] = v
|
||||||
|
db.execute(
|
||||||
|
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_run(
|
||||||
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
error: str | None = None,
|
||||||
|
**counts: int,
|
||||||
|
) -> None:
|
||||||
|
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
|
||||||
|
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
|
||||||
|
for k, v in counts.items():
|
||||||
|
sets.append(f"{k} = :{k}")
|
||||||
|
params[k] = v
|
||||||
|
db.execute(
|
||||||
|
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_raw(
|
||||||
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
report_section: str,
|
||||||
|
report_type: str,
|
||||||
|
report_name: str,
|
||||||
|
group_name: str,
|
||||||
|
complex_name: str | None,
|
||||||
|
start_date: date | None,
|
||||||
|
end_date: date | None,
|
||||||
|
use_ddu: bool,
|
||||||
|
use_dkp: bool,
|
||||||
|
payload: Any,
|
||||||
|
) -> int:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False)
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO objective_raw_reports
|
||||||
|
(run_id, page, report_section, report_type, report_name,
|
||||||
|
group_name, complex_name, start_date, end_date,
|
||||||
|
use_ddu, use_dkp, api_version, payload, payload_size)
|
||||||
|
VALUES (:run_id, 'Отчеты', :section, :type, :name,
|
||||||
|
:group_name, :complex_name, :start_date, :end_date,
|
||||||
|
:use_ddu, :use_dkp, 'v2', CAST(:payload AS jsonb), :size)
|
||||||
|
RETURNING raw_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"section": report_section,
|
||||||
|
"type": report_type,
|
||||||
|
"name": 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,
|
||||||
|
"payload": body,
|
||||||
|
"size": len(body.encode("utf-8")),
|
||||||
|
},
|
||||||
|
).scalar_one()
|
||||||
|
db.commit()
|
||||||
|
return int(row)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
name="tasks.scrape_objective.sync_objective_group",
|
||||||
|
max_retries=2,
|
||||||
|
)
|
||||||
|
def sync_objective_group(
|
||||||
|
self: Any,
|
||||||
|
group_name: str | None = None,
|
||||||
|
triggered_by: str = "beat",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Sync 4 канонических отчёта Объектив для одной группы (территории).
|
||||||
|
|
||||||
|
Идемпотентность: каждый прогон создаёт новые objective_raw_reports
|
||||||
|
(history-style). Нормализация (parse → objective_oks/lots) — отдельная
|
||||||
|
задача после изучения реальной схемы payload через smoke-script.
|
||||||
|
"""
|
||||||
|
if not settings.objective_api_key:
|
||||||
|
logger.warning("sync_objective_group skipped: OBJECTIVE_API_KEY не задан")
|
||||||
|
return {"skipped": True, "reason": "no_api_key"}
|
||||||
|
|
||||||
|
group = group_name or settings.objective_default_group
|
||||||
|
end_date = date.today().replace(day=1)
|
||||||
|
start_date = (end_date - timedelta(days=1)).replace(day=1)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
run_id: int | None = None
|
||||||
|
try:
|
||||||
|
run_id = _start_run(db, group, triggered_by)
|
||||||
|
client = ObjectiveClient()
|
||||||
|
|
||||||
|
reports_ok = 0
|
||||||
|
reports_failed = 0
|
||||||
|
n_requests = 0
|
||||||
|
rows_corpus_room = 0
|
||||||
|
rows_lots = 0
|
||||||
|
rows_history = 0
|
||||||
|
|
||||||
|
# Импортируем парсер и автоматически нормализуем после save_raw.
|
||||||
|
# Сделано как late-import чтобы scrape_objective.py подгружался без
|
||||||
|
# data/sql при unit-тестах celery_app.
|
||||||
|
from importlib import util as _importlib_util
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
_parser_path = (
|
||||||
|
_Path(__file__).resolve().parents[3] / "data" / "sql" / "70_parse_objective_raw.py"
|
||||||
|
)
|
||||||
|
_spec = _importlib_util.spec_from_file_location("objective_parser_70", _parser_path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Не удалось загрузить парсер по пути {_parser_path}")
|
||||||
|
parser_mod = _importlib_util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(parser_mod)
|
||||||
|
|
||||||
|
# На нашем тарифе доступны только Сводные/Корпуса и Поквартирные/Лоты.
|
||||||
|
# «Сводные/Лоты» и «Поквартирные/Корпуса» возвращают 500 (проверено).
|
||||||
|
jobs: list[tuple[str, str, dict, str, str, str]] = [
|
||||||
|
(
|
||||||
|
"corp_sum",
|
||||||
|
"report_corpuses_summary",
|
||||||
|
{
|
||||||
|
"group_name": group,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"use_ddu": True,
|
||||||
|
"use_dkp": True,
|
||||||
|
},
|
||||||
|
"Объединенные данные",
|
||||||
|
"Сводные",
|
||||||
|
"Корпуса",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lots_pf",
|
||||||
|
"report_lots_per_flat",
|
||||||
|
{"group_name": group, "use_ddu": True, "use_dkp": True},
|
||||||
|
"Объединенные данные",
|
||||||
|
"Поквартирные",
|
||||||
|
"Лоты",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for kind, fn_name, params, section, rtype, rname in jobs:
|
||||||
|
try:
|
||||||
|
method = getattr(client, fn_name)
|
||||||
|
payload = method(**params)
|
||||||
|
n_requests += 1
|
||||||
|
raw_id = _save_raw(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
report_section=section,
|
||||||
|
report_type=rtype,
|
||||||
|
report_name=rname,
|
||||||
|
group_name=group,
|
||||||
|
complex_name=None,
|
||||||
|
start_date=params.get("start_date"),
|
||||||
|
end_date=params.get("end_date"),
|
||||||
|
use_ddu=params.get("use_ddu", True),
|
||||||
|
use_dkp=params.get("use_dkp", False),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
reports_ok += 1
|
||||||
|
|
||||||
|
# Inline-нормализация в objective_corpus_room_month / lots / history.
|
||||||
|
snap = date.today()
|
||||||
|
try:
|
||||||
|
if kind == "corp_sum":
|
||||||
|
n = parser_mod.parse_corp_sum(payload, group, raw_id, db, dry_run=False)
|
||||||
|
rows_corpus_room += n
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE objective_raw_reports "
|
||||||
|
" SET rows_extracted = :n WHERE raw_id = :rid"
|
||||||
|
),
|
||||||
|
{"n": n, "rid": raw_id},
|
||||||
|
)
|
||||||
|
elif kind == "lots_pf":
|
||||||
|
n_lots, n_hist = parser_mod.parse_lots_pf(
|
||||||
|
payload, raw_id, snap, db, dry_run=False
|
||||||
|
)
|
||||||
|
rows_lots += n_lots
|
||||||
|
rows_history += n_hist
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE objective_raw_reports "
|
||||||
|
" SET rows_extracted = :n WHERE raw_id = :rid"
|
||||||
|
),
|
||||||
|
{"n": n_lots, "rid": raw_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as parse_err:
|
||||||
|
# Парсинг упал — raw уже сохранён, можно re-parse позже.
|
||||||
|
db.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"sync_objective_group: parser failed for %s/%s/%s raw_id=%s: %s",
|
||||||
|
section,
|
||||||
|
rtype,
|
||||||
|
rname,
|
||||||
|
raw_id,
|
||||||
|
parse_err,
|
||||||
|
)
|
||||||
|
|
||||||
|
_heartbeat(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
reports_ok=reports_ok,
|
||||||
|
reports_failed=reports_failed,
|
||||||
|
requests_count=n_requests,
|
||||||
|
rows_corpus_room=rows_corpus_room,
|
||||||
|
rows_lots=rows_lots,
|
||||||
|
rows_history=rows_history,
|
||||||
|
)
|
||||||
|
except (ObjectiveAuthError, ObjectiveAPIError) as e:
|
||||||
|
reports_failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"sync_objective_group: %s/%s/%s failed: %s", section, rtype, rname, e
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
_finish_run(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
status="done" if reports_failed == 0 else "failed",
|
||||||
|
error=None if reports_failed == 0 else f"{reports_failed} reports failed",
|
||||||
|
reports_ok=reports_ok,
|
||||||
|
reports_failed=reports_failed,
|
||||||
|
requests_count=n_requests,
|
||||||
|
rows_corpus_room=rows_corpus_room,
|
||||||
|
rows_lots=rows_lots,
|
||||||
|
rows_history=rows_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": run_id,
|
||||||
|
"group_name": group,
|
||||||
|
"reports_ok": reports_ok,
|
||||||
|
"reports_failed": reports_failed,
|
||||||
|
"requests": n_requests,
|
||||||
|
"rows_corpus_room": rows_corpus_room,
|
||||||
|
"rows_lots": rows_lots,
|
||||||
|
"rows_history": rows_history,
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"end_date": end_date.isoformat(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
if run_id:
|
||||||
|
try:
|
||||||
|
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
333
data/sql/68_schema_objective.sql
Normal file
333
data/sql/68_schema_objective.sql
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
-- Storage для данных API Объектива (api.objctv.ru).
|
||||||
|
--
|
||||||
|
-- Схема ВЫРОВНЕНА по реальной форме payload, наблюдаемой на пробных вызовах
|
||||||
|
-- (data/raw/objective_test/20260507_*/), а не по гипотезам.
|
||||||
|
--
|
||||||
|
-- Реальная форма данных:
|
||||||
|
-- 1. /v2/Report/GetReport?ReportType=Сводные&ReportName=Корпуса
|
||||||
|
-- → {"result": [ {Месяц, Проект, Девелопер, Район, Класс, Корпус,
|
||||||
|
-- "Количество комнат (Данные Объектива)",
|
||||||
|
-- Лотов по ПД шт, Площадь по ПД м2,
|
||||||
|
-- Сделки ДДУ/ДКП/всего × {count, count_priced, vol, vol_priced,
|
||||||
|
-- sum_priced_млн, avg_price_per_m2_тыс,
|
||||||
|
-- avg_area},
|
||||||
|
-- Объем предложения шт/м2, Сумм. стоимость в продаже млн,
|
||||||
|
-- Средняя цена м2 в продаже тыс
|
||||||
|
-- }, ...] }
|
||||||
|
-- → long-формат: одна строка = (месяц × корпус × room_bucket) per-row.
|
||||||
|
-- → corp_sum.json для одного ЖК Парк Культуры за 7 дней = 8 строк (4 bucket × 2 мес).
|
||||||
|
--
|
||||||
|
-- 2. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Лоты
|
||||||
|
-- → {"result": [ {Id, "Id проекта", Проект, Корпус, Адрес, Класс, Секция, Этаж,
|
||||||
|
-- "Номер лота", "Вид помещения", Статус, Продано,
|
||||||
|
-- "Количество комнат(Сайт девелопера/ПД/Данные объектива)",
|
||||||
|
-- "Площадь, м2(Сайт девелопера/ПД)",
|
||||||
|
-- "Расчетный бюджет лота, Р", "Цена за м2, Р",
|
||||||
|
-- "Цена предложения, Р", "Дельта цена, Р/%",
|
||||||
|
-- "Дата договора", "Дата регистрации", "Тип сделки",
|
||||||
|
-- "Тип покупателя", "Номер регистрации",
|
||||||
|
-- "Тип обременения", Банк, "Дата начала обременения",
|
||||||
|
-- "Дата актуальности данных из ЕГРН"}, ...] }
|
||||||
|
-- → per-flat snapshot. `Id` (Objective lot_id) — stable, можно использовать как UPSERT-ключ.
|
||||||
|
--
|
||||||
|
-- 3. /v2/Report/GetReport?ReportType=Сводные&ReportName=Лоты — HTTP 500 на нашем тарифе
|
||||||
|
-- 4. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Корпуса — HTTP 500 на нашем тарифе
|
||||||
|
-- Эти 2 отчёта недоступны / не реализованы у нас. Если откроют — добавим.
|
||||||
|
--
|
||||||
|
-- Стратегия storage:
|
||||||
|
-- - RAW: objective_raw_reports (jsonb payload) — страховка от ломки схемы.
|
||||||
|
-- - NORMALIZED:
|
||||||
|
-- - objective_corpus_room_month (long-формат из corp_sum)
|
||||||
|
-- - objective_lots (текущая запись per-flat, UPSERT по Objective.Id)
|
||||||
|
-- - objective_lots_history (append-only снапшоты per-flat per snapshot_date)
|
||||||
|
-- - MAPPING: objective_complex_mapping связывает Objective.ComplexName с domrf_kn_objects.obj_id.
|
||||||
|
--
|
||||||
|
-- Идемпотентно через IF NOT EXISTS.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- ── 0. Runs (журнал sync) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_scrape_runs (
|
||||||
|
run_id BIGSERIAL PRIMARY KEY,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
heartbeat_at timestamptz,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
reports_ok INT NOT NULL DEFAULT 0,
|
||||||
|
reports_failed INT NOT NULL DEFAULT 0,
|
||||||
|
rows_corpus_room INT NOT NULL DEFAULT 0,
|
||||||
|
rows_lots INT NOT NULL DEFAULT 0,
|
||||||
|
rows_history INT NOT NULL DEFAULT 0,
|
||||||
|
requests_count INT NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running', -- running | done | failed | zombie
|
||||||
|
error TEXT,
|
||||||
|
triggered_by TEXT NOT NULL DEFAULT 'beat' -- beat | manual | resume
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_runs_status_idx
|
||||||
|
ON objective_scrape_runs(status, started_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_scrape_runs IS
|
||||||
|
'Журнал запусков sync с api.objctv.ru. Один прогон = один (group_name) пакет отчётов.';
|
||||||
|
|
||||||
|
-- ── 1. RAW payloads ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_raw_reports (
|
||||||
|
raw_id BIGSERIAL PRIMARY KEY,
|
||||||
|
run_id BIGINT REFERENCES objective_scrape_runs(run_id) ON DELETE SET NULL,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
page TEXT NOT NULL,
|
||||||
|
report_section TEXT NOT NULL,
|
||||||
|
report_type TEXT NOT NULL,
|
||||||
|
report_name TEXT NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
complex_name TEXT,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
use_ddu BOOLEAN,
|
||||||
|
use_dkp BOOLEAN,
|
||||||
|
api_version TEXT NOT NULL DEFAULT 'v2',
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
payload_size INT NOT NULL,
|
||||||
|
rows_extracted INT -- сколько rows распарсено в normalized слой
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_raw_combo_idx
|
||||||
|
ON objective_raw_reports(report_section, report_type, report_name,
|
||||||
|
group_name, fetched_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_raw_dates_idx
|
||||||
|
ON objective_raw_reports(start_date, end_date) WHERE start_date IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_raw_run_idx
|
||||||
|
ON objective_raw_reports(run_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_raw_reports IS
|
||||||
|
'Сырой JSON-payload каждого GetReport-вызова. Источник для re-parse при '
|
||||||
|
'изменениях схемы Objective или переноса normalized-слоя на новую версию.';
|
||||||
|
|
||||||
|
-- ── 2. NORMALIZED — Сводные/Корпуса (long: month × corpus × room_bucket) ────
|
||||||
|
|
||||||
|
-- Заменяет старую objective_oks (была спроектирована под per-corpus с
|
||||||
|
-- rooms[N]_count колонками, не подходит под реальный long-формат).
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_corpus_room_month (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
-- Ключ записи (month × corpus × room_bucket)
|
||||||
|
report_month DATE NOT NULL, -- 1-е число месяца из "Месяц": "апрель-2026"
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
project_name TEXT NOT NULL, -- "Парк Культуры"
|
||||||
|
developer TEXT, -- "PRINZIP"
|
||||||
|
district TEXT, -- "Автовокзал"
|
||||||
|
class TEXT, -- "Комфорт" / "Бизнес" / "Стандарт" / ...
|
||||||
|
corpus_name TEXT NOT NULL, -- "1.1"
|
||||||
|
room_bucket TEXT NOT NULL, -- "студия" | "1" | "2" | "3" | "4" | "5+"
|
||||||
|
rooms_int SMALLINT, -- нормализованный 0=студия, 1, 2, 3, 4, 5
|
||||||
|
-- Динамика стройки (повторяется в каждой строке, but stable per-corpus per snapshot)
|
||||||
|
sales_start_date DATE,
|
||||||
|
plan_completion_date DATE, -- "Планируемая дата ввода"
|
||||||
|
actual_completion_date DATE, -- "Фактическая дата ввода"
|
||||||
|
months_in_realization INT,
|
||||||
|
-- Capacity по ПД
|
||||||
|
lots_pd_count INT, -- "Лотов по ПД, шт."
|
||||||
|
lots_pd_area NUMERIC, -- "Площадь по ПД, м2."
|
||||||
|
-- Сделки ДДУ
|
||||||
|
deals_ddu_count INT,
|
||||||
|
deals_ddu_count_priced INT, -- из них с известной ценой
|
||||||
|
deals_ddu_vol_m2 NUMERIC,
|
||||||
|
deals_ddu_vol_m2_priced NUMERIC,
|
||||||
|
deals_ddu_sum_mln_rub NUMERIC, -- "млн.Р"
|
||||||
|
deals_ddu_avg_price_thousand_rub_per_m2 NUMERIC, -- "тыс.Р/м2"
|
||||||
|
deals_ddu_avg_area_m2 NUMERIC,
|
||||||
|
-- Сделки ДКП
|
||||||
|
deals_dkp_count INT,
|
||||||
|
deals_dkp_count_priced INT,
|
||||||
|
deals_dkp_vol_m2 NUMERIC,
|
||||||
|
deals_dkp_vol_m2_priced NUMERIC,
|
||||||
|
deals_dkp_sum_mln_rub NUMERIC,
|
||||||
|
deals_dkp_avg_price_thousand_rub_per_m2 NUMERIC,
|
||||||
|
deals_dkp_avg_area_m2 NUMERIC,
|
||||||
|
-- Сделки всего (= ДДУ + ДКП)
|
||||||
|
deals_total_count INT,
|
||||||
|
deals_total_count_priced INT,
|
||||||
|
deals_total_vol_m2 NUMERIC,
|
||||||
|
deals_total_vol_m2_priced NUMERIC,
|
||||||
|
deals_total_sum_mln_rub NUMERIC,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2 NUMERIC,
|
||||||
|
deals_total_avg_area_m2 NUMERIC,
|
||||||
|
-- Текущее предложение (stock на конец месяца)
|
||||||
|
offer_count INT, -- "Объем предложения, шт."
|
||||||
|
offer_area_m2 NUMERIC, -- "Объем предложения, м2."
|
||||||
|
offer_sum_mln_rub NUMERIC, -- "Суммарная стоимость лотов в продаже, млн.Р"
|
||||||
|
offer_avg_price_thousand_rub_per_m2 NUMERIC, -- "Средняя цена м2 лота в продаже, тыс.Р/м2"
|
||||||
|
-- Audit
|
||||||
|
raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL,
|
||||||
|
raw_props jsonb,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Один (месяц, корпус, room_bucket, group) — одна запись.
|
||||||
|
UNIQUE (report_month, group_name, project_name, corpus_name, room_bucket)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_crm_project_month_idx
|
||||||
|
ON objective_corpus_room_month(project_name, report_month DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_crm_district_class_idx
|
||||||
|
ON objective_corpus_room_month(district, class, report_month DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_crm_developer_idx
|
||||||
|
ON objective_corpus_room_month(developer, report_month DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_crm_rooms_idx
|
||||||
|
ON objective_corpus_room_month(rooms_int, report_month DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_corpus_room_month IS
|
||||||
|
'Сводные/Корпуса в реальном long-формате: (месяц × корпус × количество комнат). '
|
||||||
|
'Главное преимущество над domrf_kn — готовая monthly history с разбивкой по mix '
|
||||||
|
'(studio/1/2/3/4) с явными метриками: остатки, средняя цена, средняя площадь.';
|
||||||
|
|
||||||
|
-- ── 3. NORMALIZED — Поквартирные/Лоты ────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_lots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
objective_lot_id BIGINT NOT NULL UNIQUE, -- "Id" в payload — stable Objective ID
|
||||||
|
objective_project_id BIGINT, -- "Id проекта"
|
||||||
|
-- Identification
|
||||||
|
project_name TEXT NOT NULL,
|
||||||
|
developer TEXT,
|
||||||
|
city TEXT,
|
||||||
|
district TEXT,
|
||||||
|
corpus_name TEXT,
|
||||||
|
address TEXT,
|
||||||
|
class TEXT, -- "комфорт" в lots_pf (lowercase!) vs "Комфорт" в corp_sum
|
||||||
|
section TEXT,
|
||||||
|
floor INT,
|
||||||
|
lot_number TEXT, -- "Номер лота" — может быть "1а" etc, поэтому TEXT
|
||||||
|
premise_kind TEXT, -- "квартира" | "нежилое" | "паркинг" | "кладовая"
|
||||||
|
-- Stage
|
||||||
|
sales_start_date DATE,
|
||||||
|
plan_completion_date DATE,
|
||||||
|
actual_completion_date DATE,
|
||||||
|
readiness_pct SMALLINT, -- "17%" → 17
|
||||||
|
construction_stage TEXT, -- "строящийся" | "сдан" | ...
|
||||||
|
finishing TEXT, -- "Отделка по корпусу" — "под чистовую" | "с отделкой" | ...
|
||||||
|
-- Status
|
||||||
|
status TEXT, -- "резерв" | "свободна" | "продан" | "бронь"
|
||||||
|
is_sold BOOLEAN, -- "Продано": "да"/"нет" → true/false
|
||||||
|
-- Mix (3 источника от Объектива)
|
||||||
|
rooms_dev_site TEXT, -- "Количество комнат(Сайт девелопера)"
|
||||||
|
rooms_pd TEXT, -- "Количество комнат(ПД)"
|
||||||
|
rooms_objective TEXT, -- "Количество комнат(Данные объектива)" — самое нормализованное
|
||||||
|
rooms_int SMALLINT, -- нормализуем objective: студия→0, "1"→1, ...
|
||||||
|
-- Areas
|
||||||
|
area_dev_site NUMERIC,
|
||||||
|
area_pd NUMERIC,
|
||||||
|
-- Pricing
|
||||||
|
price_calculated_total_rub NUMERIC, -- "Расчетный бюджет лота, Р"
|
||||||
|
price_per_m2_rub NUMERIC, -- "Цена за м2, Р"
|
||||||
|
price_offer_total_rub NUMERIC, -- "Цена предложения, Р"
|
||||||
|
price_delta_rub NUMERIC, -- "Дельта цена, Р"
|
||||||
|
price_delta_pct NUMERIC, -- "Дельта цена, %"
|
||||||
|
pricing_method TEXT, -- "Способ определения цены"
|
||||||
|
price_set_date DATE, -- "Дата установки цены"
|
||||||
|
price_actual_date DATE, -- "Дата актуальности цены"
|
||||||
|
-- Deal
|
||||||
|
contract_date DATE, -- "Дата договора"
|
||||||
|
registration_date DATE, -- "Дата регистрации"
|
||||||
|
contract_type TEXT, -- "Тип сделки": ДДУ | ДКП | ...
|
||||||
|
buyer_type TEXT, -- "Тип покупателя"
|
||||||
|
registration_number TEXT, -- "Номер регистрации"
|
||||||
|
-- Encumbrance / mortgage
|
||||||
|
encumbrance_type TEXT, -- "Тип обременения"
|
||||||
|
bank_name TEXT, -- "Банк"
|
||||||
|
encumbrance_start_date DATE, -- "Дата начала обременения"
|
||||||
|
egrn_actual_date DATE, -- "Дата актуальности данных из ЕГРН"
|
||||||
|
-- Audit
|
||||||
|
raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL,
|
||||||
|
raw_props jsonb,
|
||||||
|
snapshot_date DATE NOT NULL, -- дата снимка (= когда мы синкнули)
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_project_idx
|
||||||
|
ON objective_lots(project_name, snapshot_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_status_idx
|
||||||
|
ON objective_lots(status) WHERE status IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_rooms_area_idx
|
||||||
|
ON objective_lots(rooms_int, area_pd) WHERE area_pd IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_district_class_idx
|
||||||
|
ON objective_lots(district, class) WHERE district IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_bank_idx
|
||||||
|
ON objective_lots(bank_name) WHERE bank_name IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_reg_date_idx
|
||||||
|
ON objective_lots(registration_date) WHERE registration_date IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_lots IS
|
||||||
|
'Per-flat snapshot из Объектива (Поквартирные/Лоты). UPSERT по objective_lot_id. '
|
||||||
|
'Текущее (самое последнее) состояние каждой квартиры. Полная history — '
|
||||||
|
'в objective_lots_history.';
|
||||||
|
|
||||||
|
-- ── 4. NORMALIZED — История квартир (append-only snapshots) ─────────────────
|
||||||
|
|
||||||
|
-- Объектив НЕ отдаёт per-day price/status timeline в одном вызове.
|
||||||
|
-- Зато при еженедельном sync мы можем собирать сами: при каждом вызове
|
||||||
|
-- делаем INSERT в _history для каждого изменившегося лота. Это даст нам
|
||||||
|
-- weekly-resolution timeline price/status/area per flat — достаточно для
|
||||||
|
-- elasticity и velocity-anomaly детектора.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_lots_history (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
objective_lot_id BIGINT NOT NULL, -- ссылка на objective_lots.objective_lot_id
|
||||||
|
snapshot_date DATE NOT NULL, -- день sync
|
||||||
|
status TEXT,
|
||||||
|
is_sold BOOLEAN,
|
||||||
|
price_calculated_total_rub NUMERIC,
|
||||||
|
price_per_m2_rub NUMERIC,
|
||||||
|
price_offer_total_rub NUMERIC,
|
||||||
|
price_delta_pct NUMERIC,
|
||||||
|
area_pd NUMERIC,
|
||||||
|
contract_date DATE,
|
||||||
|
registration_date DATE,
|
||||||
|
contract_type TEXT,
|
||||||
|
bank_name TEXT,
|
||||||
|
raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
-- На каждый (lot, snapshot_date) — ровно одна запись.
|
||||||
|
UNIQUE (objective_lot_id, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_history_lot_idx
|
||||||
|
ON objective_lots_history(objective_lot_id, snapshot_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_history_date_idx
|
||||||
|
ON objective_lots_history(snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_lots_history_reg_idx
|
||||||
|
ON objective_lots_history(registration_date)
|
||||||
|
WHERE registration_date IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_lots_history IS
|
||||||
|
'Append-only weekly snapshots каждого лота. Главное преимущество для '
|
||||||
|
'velocity-anomaly detection и для elasticity (точность регрессии × лет).';
|
||||||
|
|
||||||
|
-- ── 5. MAPPING — Objective ↔ domrf_kn_objects ────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_complex_mapping (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
objective_complex_name TEXT NOT NULL, -- "Парк Культуры"
|
||||||
|
objective_project_id BIGINT, -- из payload "Id проекта"
|
||||||
|
objective_group TEXT NOT NULL,
|
||||||
|
domrf_obj_id BIGINT, -- domrf_kn_objects.obj_id; NULL = не сматчили
|
||||||
|
is_reviewed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
match_method TEXT, -- 'exact_name' | 'fuzzy_name' | 'manual' | 'spatial'
|
||||||
|
match_score NUMERIC, -- 0..1 для fuzzy
|
||||||
|
note TEXT,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (objective_complex_name, objective_group)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_mapping_domrf_idx
|
||||||
|
ON objective_complex_mapping(domrf_obj_id) WHERE domrf_obj_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_mapping_unreviewed_idx
|
||||||
|
ON objective_complex_mapping(is_reviewed) WHERE is_reviewed = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS objective_mapping_project_id_idx
|
||||||
|
ON objective_complex_mapping(objective_project_id) WHERE objective_project_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_complex_mapping IS
|
||||||
|
'Маппинг Objective.ComplexName ↔ domrf_kn_objects.obj_id. is_reviewed=TRUE — '
|
||||||
|
'человек подтвердил соответствие. Auto-matching пишет с is_reviewed=FALSE.';
|
||||||
121
data/sql/69_objective_smoke.py
Normal file
121
data/sql/69_objective_smoke.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Smoke-тест Objective API: GetToken + 4 канонических отчёта.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd backend
|
||||||
|
OBJECTIVE_API_KEY=<your_key> uv run python ../data/sql/69_objective_smoke.py
|
||||||
|
|
||||||
|
Что делает:
|
||||||
|
1. Получает Bearer-токен (кеш в Redis с TTL).
|
||||||
|
2. Тянет 4 отчёта по Екатеринбургу:
|
||||||
|
a. Сводные/Корпуса (период last 1 month)
|
||||||
|
b. Сводные/Лоты (период last 1 month)
|
||||||
|
c. Поквартирные/Корпуса
|
||||||
|
d. Поквартирные/Лоты
|
||||||
|
3. Сохраняет raw JSON в data/raw/objective_smoke/<timestamp>/
|
||||||
|
4. Печатает size + первые поля каждого отчёта (схему-прицел).
|
||||||
|
|
||||||
|
Это нужно один раз, чтобы понять реальную форму payload и доработать
|
||||||
|
парсер 70_parse_objective.py (следующий шаг — нормализация в objective_*).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Гарантия что запускается из репо-root через `cd backend; uv run python ...`
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
|
||||||
|
|
||||||
|
from app.services.scrapers.objective import ObjectiveAuthError, ObjectiveClient
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not os.environ.get("OBJECTIVE_API_KEY"):
|
||||||
|
print(
|
||||||
|
"ERROR: переменная OBJECTIVE_API_KEY не задана. "
|
||||||
|
"Запусти: OBJECTIVE_API_KEY=<key> uv run python ../data/sql/69_objective_smoke.py"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
end = date.today().replace(day=1)
|
||||||
|
start = (end - timedelta(days=1)).replace(day=1)
|
||||||
|
out_dir = Path("data/raw/objective_smoke") / datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = ObjectiveClient()
|
||||||
|
except ObjectiveAuthError as e:
|
||||||
|
print(f"AUTH ERROR: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
reports: list[tuple[str, str, dict]] = [
|
||||||
|
(
|
||||||
|
"corpuses_summary",
|
||||||
|
"Объединенные данные / Сводные / Корпуса",
|
||||||
|
dict(start_date=start, end_date=end, use_ddu=True, use_dkp=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lots_summary",
|
||||||
|
"Объединенные данные / Сводные / Лоты",
|
||||||
|
dict(start_date=start, end_date=end, use_ddu=True, use_dkp=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"corpuses_per_flat",
|
||||||
|
"Объединенные данные / Поквартирные / Корпуса",
|
||||||
|
dict(use_ddu=True, use_dkp=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lots_per_flat",
|
||||||
|
"Объединенные данные / Поквартирные / Лоты",
|
||||||
|
dict(use_ddu=True, use_dkp=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
method_map = {
|
||||||
|
"corpuses_summary": client.report_corpuses_summary,
|
||||||
|
"lots_summary": client.report_lots_summary,
|
||||||
|
"corpuses_per_flat": client.report_corpuses_per_flat,
|
||||||
|
"lots_per_flat": client.report_lots_per_flat,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"=== Objective smoke ({start} → {end}) → {out_dir}")
|
||||||
|
|
||||||
|
for slug, label, params in reports:
|
||||||
|
print(f"\n--- {label}")
|
||||||
|
try:
|
||||||
|
data = method_map[slug](**params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {type(e).__name__}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = out_dir / f"{slug}.json"
|
||||||
|
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
size_kb = len(text) / 1024.0
|
||||||
|
|
||||||
|
# Краткий schema-probe
|
||||||
|
if isinstance(data, list):
|
||||||
|
shape = f"list len={len(data)}"
|
||||||
|
sample_keys = list(data[0].keys()) if data and isinstance(data[0], dict) else []
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
shape = f"dict keys={list(data.keys())}"
|
||||||
|
sample_keys = []
|
||||||
|
else:
|
||||||
|
shape = f"{type(data).__name__}"
|
||||||
|
sample_keys = []
|
||||||
|
|
||||||
|
print(f" ✓ saved {path.name} ({size_kb:.1f} KB)")
|
||||||
|
print(f" shape: {shape}")
|
||||||
|
if sample_keys:
|
||||||
|
print(f" first row keys: {sample_keys[:25]}")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print(f"\nDONE. Папка: {out_dir.absolute()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
200
data/sql/69b_objective_minimal.py
Normal file
200
data/sql/69b_objective_minimal.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""Минимальный probe Objective API: 4 отчёта × минимальная выборка.
|
||||||
|
|
||||||
|
Цель — посмотреть реальную форму payload-ов, не выкачивая весь Екатеринбург.
|
||||||
|
Ограничения объёма:
|
||||||
|
- period: только последние 7 дней (для Сводных)
|
||||||
|
- complex_name: фильтр по 1 ЖК (по умолчанию «ЖК "Парк Культуры"»; меняется флагом)
|
||||||
|
|
||||||
|
Что выводит:
|
||||||
|
- HTTP-статус через клиент (raw exception если что)
|
||||||
|
- размер JSON в KB
|
||||||
|
- тип (dict/list) + len/keys
|
||||||
|
- первые 1-2 элемента (для list) или верхние ключи (для dict) — обрезанные до 1KB
|
||||||
|
- сохраняет полный payload в data/raw/objective_test/<timestamp>/<slug>.json
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd backend
|
||||||
|
OBJECTIVE_API_KEY=<key> uv run python -X utf8 ../data/sql/69b_objective_minimal.py
|
||||||
|
OBJECTIVE_API_KEY=<key> uv run python -X utf8 ../data/sql/69b_objective_minimal.py --complex 'ЖК "Сибирский сад"'
|
||||||
|
OBJECTIVE_API_KEY=<key> uv run python -X utf8 ../data/sql/69b_objective_minimal.py --no-complex --days 3
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Если поле ComplexName в Objective отличается ('ЖК "Парк Культуры"' vs 'Парк Культуры'
|
||||||
|
vs 'ЖК Парк Культуры') — поменяй --complex или поставь --no-complex.
|
||||||
|
- --no-complex берёт ВСЕ ЖК группы, но всё ещё с период=N дней (для Сводных).
|
||||||
|
Для Поквартирных без --complex может быть очень большой ответ — будь осторожен.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# repo-root → /backend для import'а app.*
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
|
||||||
|
|
||||||
|
from app.services.scrapers.objective import ( # noqa: E402
|
||||||
|
ObjectiveAPIError,
|
||||||
|
ObjectiveAuthError,
|
||||||
|
ObjectiveClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape(data) -> str:
|
||||||
|
if isinstance(data, list):
|
||||||
|
return f"list[{len(data)}]"
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return f"dict(keys={list(data.keys())[:15]})"
|
||||||
|
return type(data).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _peek(data, max_chars: int = 1200) -> str:
|
||||||
|
"""Короткий человеко-читаемый peek первых элементов / ключей."""
|
||||||
|
if isinstance(data, list):
|
||||||
|
if not data:
|
||||||
|
return " (пустой список)"
|
||||||
|
sample = data[0]
|
||||||
|
if isinstance(sample, dict):
|
||||||
|
keys = list(sample.keys())
|
||||||
|
return f" [0] keys ({len(keys)}): {keys[:30]}"
|
||||||
|
return f" [0]: {str(sample)[:max_chars]}"
|
||||||
|
if isinstance(data, dict):
|
||||||
|
out = []
|
||||||
|
for k, v in list(data.items())[:10]:
|
||||||
|
v_repr = (
|
||||||
|
f"list[{len(v)}]" if isinstance(v, list)
|
||||||
|
else f"dict({len(v)} keys)" if isinstance(v, dict)
|
||||||
|
else str(v)[:120]
|
||||||
|
)
|
||||||
|
out.append(f" {k!r} -> {v_repr}")
|
||||||
|
return "\n".join(out)
|
||||||
|
s = str(data)
|
||||||
|
return f" {s[:max_chars]}{'…' if len(s) > max_chars else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--complex", default='Парк Культуры',
|
||||||
|
help='ComplexName-фильтр (default: "Парк Культуры", без префикса ЖК и без кавычек — '
|
||||||
|
'эмпирически проверено что Объектив требует именно такой формат)')
|
||||||
|
ap.add_argument("--no-complex", action="store_true",
|
||||||
|
help="Не передавать ComplexName (вся группа)")
|
||||||
|
ap.add_argument("--group", default=None,
|
||||||
|
help="GroupName (default: settings.objective_default_group = Екатеринбург)")
|
||||||
|
ap.add_argument("--days", type=int, default=7,
|
||||||
|
help="Период для Сводных отчётов в днях (default: 7)")
|
||||||
|
ap.add_argument("--only", choices=("corp_sum", "lots_sum", "corp_pf", "lots_pf"),
|
||||||
|
help="Запустить только один отчёт")
|
||||||
|
ap.add_argument("--inter-delay", type=int, default=15,
|
||||||
|
help="Пауза в секундах между отчётами (default: 15). "
|
||||||
|
"У Objective жёсткий rate-limit: <1 запрос/30с на дешёвых тарифах.")
|
||||||
|
ap.add_argument("--rate-ms", type=int, default=3000,
|
||||||
|
help="Минимальный интервал между HTTP-запросами в мс (default: 3000)")
|
||||||
|
ap.add_argument("--retries", type=int, default=2,
|
||||||
|
help="Сколько раз ретраить 429/5xx (default: 2). 0 = не ретраить вообще.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.environ.get("OBJECTIVE_API_KEY"):
|
||||||
|
# Конфиг также читает из backend/.env — попробуем через settings.
|
||||||
|
from app.core.config import settings
|
||||||
|
if not settings.objective_api_key:
|
||||||
|
print("ERROR: OBJECTIVE_API_KEY не задан (env + backend/.env пустые).")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
end_date = date.today()
|
||||||
|
start_date = end_date - timedelta(days=args.days)
|
||||||
|
complex_name = None if args.no_complex else args.complex
|
||||||
|
out_dir = Path("data/raw/objective_test") / datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"=== Objective minimal probe")
|
||||||
|
print(f" group = {args.group or '(default settings)'}")
|
||||||
|
print(f" complex = {complex_name or '(none — вся группа)'}")
|
||||||
|
print(f" period = {start_date} .. {end_date} ({args.days}d)")
|
||||||
|
print(f" output = {out_dir.absolute()}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = ObjectiveClient(rate_ms=args.rate_ms, retries=args.retries)
|
||||||
|
except ObjectiveAuthError as e:
|
||||||
|
print(f"AUTH ERROR при создании клиента: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# 4 канонических отчёта × минимальные параметры
|
||||||
|
plan = [
|
||||||
|
("corp_sum", "1. Сводные / Корпуса",
|
||||||
|
lambda: client.report_corpuses_summary(
|
||||||
|
group_name=args.group, complex_name=complex_name,
|
||||||
|
start_date=start_date, end_date=end_date,
|
||||||
|
use_ddu=True, use_dkp=True,
|
||||||
|
)),
|
||||||
|
("lots_sum", "2. Сводные / Лоты",
|
||||||
|
lambda: client.report_lots_summary(
|
||||||
|
group_name=args.group, complex_name=complex_name,
|
||||||
|
start_date=start_date, end_date=end_date,
|
||||||
|
use_ddu=True, use_dkp=True,
|
||||||
|
)),
|
||||||
|
("corp_pf", "3. Поквартирные / Корпуса",
|
||||||
|
lambda: client.report_corpuses_per_flat(
|
||||||
|
group_name=args.group, complex_name=complex_name,
|
||||||
|
use_ddu=True, use_dkp=True,
|
||||||
|
)),
|
||||||
|
("lots_pf", "4. Поквартирные / Лоты",
|
||||||
|
lambda: client.report_lots_per_flat(
|
||||||
|
group_name=args.group, complex_name=complex_name,
|
||||||
|
use_ddu=True, use_dkp=True,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
if args.only:
|
||||||
|
plan = [p for p in plan if p[0] == args.only]
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
for idx, (slug, label, fn) in enumerate(plan):
|
||||||
|
if idx > 0 and args.inter_delay > 0:
|
||||||
|
print(f"... пауза {args.inter_delay}s перед следующим отчётом (rate-limit Objective)")
|
||||||
|
time.sleep(args.inter_delay)
|
||||||
|
print(f"--- {label}")
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
data = fn()
|
||||||
|
except ObjectiveAuthError as e:
|
||||||
|
print(f" ❌ AUTH: {e}\n")
|
||||||
|
summary.append((slug, "auth_error", 0))
|
||||||
|
continue
|
||||||
|
except ObjectiveAPIError as e:
|
||||||
|
print(f" ❌ API: {e}\n")
|
||||||
|
summary.append((slug, "api_error", 0))
|
||||||
|
continue
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" ❌ {type(e).__name__}: {e}\n")
|
||||||
|
summary.append((slug, type(e).__name__, 0))
|
||||||
|
continue
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
size_kb = len(text) / 1024.0
|
||||||
|
path = out_dir / f"{slug}.json"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
print(f" ✓ {elapsed:.2f}s size={size_kb:.1f} KB shape={_shape(data)}")
|
||||||
|
print(_peek(data))
|
||||||
|
print(f" saved → {path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path}")
|
||||||
|
print()
|
||||||
|
summary.append((slug, "ok", size_kb))
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
print("=== Сводка")
|
||||||
|
for slug, status, size in summary:
|
||||||
|
print(f" {slug:10} {status:12} {size:.1f} KB")
|
||||||
|
print(f"\nDONE. Файлы в: {out_dir.absolute()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
638
data/sql/70_parse_objective_raw.py
Normal file
638
data/sql/70_parse_objective_raw.py
Normal file
|
|
@ -0,0 +1,638 @@
|
||||||
|
"""Парсер Objective raw → normalized слой.
|
||||||
|
|
||||||
|
Читает payload (либо из БД objective_raw_reports.raw_id, либо из локального
|
||||||
|
JSON-файла для smoke-теста) и раскладывает по таблицам:
|
||||||
|
|
||||||
|
- corp_sum (Сводные/Корпуса) → objective_corpus_room_month
|
||||||
|
- lots_pf (Поквартирные/Лоты) → objective_lots (UPSERT) + objective_lots_history (INSERT)
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Smoke по локальным файлам (без БД):
|
||||||
|
uv run python -X utf8 ../data/sql/70_parse_objective_raw.py \
|
||||||
|
--file-corp-sum ../data/raw/objective_test/20260507_162110/corp_sum.json \
|
||||||
|
--file-lots-pf ../data/raw/objective_test/20260507_162110/lots_pf.json \
|
||||||
|
--dry-run
|
||||||
|
|
||||||
|
# Из БД по raw_id (нужен schema 68 + DATABASE_URL):
|
||||||
|
uv run python -X utf8 ../data/sql/70_parse_objective_raw.py --raw-id 123
|
||||||
|
|
||||||
|
# Все unparsed raw из последнего run:
|
||||||
|
uv run python -X utf8 ../data/sql/70_parse_objective_raw.py --latest-run
|
||||||
|
|
||||||
|
Что считается «unparsed»:
|
||||||
|
rows_extracted IS NULL ИЛИ rows_extracted = 0 в objective_raw_reports.
|
||||||
|
|
||||||
|
Идемпотентность:
|
||||||
|
- corpus_room_month: UNIQUE (report_month, group, project, corpus, room_bucket)
|
||||||
|
→ ON CONFLICT DO UPDATE (перезатирает старое значение метрик; это правильно
|
||||||
|
т.к. отчёт за месяц может уточняться неделя-к-неделе пока месяц не закрыт)
|
||||||
|
- lots: UNIQUE objective_lot_id → ON CONFLICT DO UPDATE
|
||||||
|
- lots_history: UNIQUE (objective_lot_id, snapshot_date) → ON CONFLICT DO NOTHING
|
||||||
|
(один снимок per день — повторный sync в тот же день не создаёт дубль)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# repo-root → /backend
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
|
||||||
|
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("objective_parser")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_RU_MONTHS = {
|
||||||
|
"январь": 1, "февраль": 2, "март": 3, "апрель": 4, "май": 5, "июнь": 6,
|
||||||
|
"июль": 7, "август": 8, "сентябрь": 9, "октябрь": 10, "ноябрь": 11, "декабрь": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ru_month(s: str | None) -> date | None:
|
||||||
|
"""'апрель-2026' → date(2026, 4, 1). Также допускает 'Апрель 2026'."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
parts = re.split(r"[-\s]+", s.strip().lower())
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None
|
||||||
|
month_name, year_str = parts
|
||||||
|
month = _RU_MONTHS.get(month_name)
|
||||||
|
if not month:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date(int(year_str), month, 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_date(s: str | None) -> date | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Объектив отдаёт YYYY-MM-DD
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(s[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(v: Any) -> int | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_num(v: Any) -> float | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rooms(s: str | None) -> int | None:
|
||||||
|
"""'студия' → 0, '1' → 1, … 'неопределенный тип' → None."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
s_low = s.strip().lower()
|
||||||
|
if "студ" in s_low:
|
||||||
|
return 0
|
||||||
|
# Извлекаем первое число
|
||||||
|
m = re.match(r"^(\d+)", s_low)
|
||||||
|
if m:
|
||||||
|
n = int(m.group(1))
|
||||||
|
return min(n, 5) # 5+ всё агрегируем под 5
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pct(s: str | None) -> int | None:
|
||||||
|
"""'17%' → 17."""
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
if isinstance(s, (int, float)):
|
||||||
|
return int(s)
|
||||||
|
m = re.match(r"^\s*(\d+)", str(s))
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool_yes(v: Any) -> bool | None:
|
||||||
|
"""'да' → True, 'нет' → False."""
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if s in ("да", "true", "1", "yes"):
|
||||||
|
return True
|
||||||
|
if s in ("нет", "false", "0", "no"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── corp_sum (Сводные/Корпуса) → objective_corpus_room_month ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_corp_room_month(row: dict, group_name: str, raw_id: int | None) -> dict:
|
||||||
|
rooms_raw = row.get("Количество комнат (Данные Объектива)")
|
||||||
|
return {
|
||||||
|
"report_month": _parse_ru_month(row.get("Месяц")),
|
||||||
|
"group_name": group_name,
|
||||||
|
"project_name": row.get("Проект"),
|
||||||
|
"developer": row.get("Девелопер"),
|
||||||
|
"district": row.get("Район"),
|
||||||
|
"class": row.get("Класс"),
|
||||||
|
"corpus_name": row.get("Корпус"),
|
||||||
|
"room_bucket": rooms_raw,
|
||||||
|
"rooms_int": _normalize_rooms(rooms_raw),
|
||||||
|
"sales_start_date": _parse_iso_date(row.get("Старт продаж")),
|
||||||
|
"plan_completion_date": _parse_iso_date(row.get("Планируемая дата ввода")),
|
||||||
|
"actual_completion_date": _parse_iso_date(row.get("Фактическая дата ввода")),
|
||||||
|
"months_in_realization": _to_int(row.get("Месяцев в реализации")),
|
||||||
|
"lots_pd_count": _to_int(row.get("Лотов по ПД, шт.")),
|
||||||
|
"lots_pd_area": _to_num(row.get("Площадь по ПД, м2.")),
|
||||||
|
# ДДУ
|
||||||
|
"deals_ddu_count": _to_int(row.get("Количество в сделках (ДДУ), шт.")),
|
||||||
|
"deals_ddu_count_priced": _to_int(row.get("Количество лотов в сделках (ДДУ с ценами), шт.")),
|
||||||
|
"deals_ddu_vol_m2": _to_num(row.get("Объем реализации (ДДУ), м2.")),
|
||||||
|
"deals_ddu_vol_m2_priced": _to_num(row.get("Объем реализации (ДДУ с ценами), м2.")),
|
||||||
|
"deals_ddu_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (ДДУ с ценами), млн.Р")),
|
||||||
|
"deals_ddu_avg_price_thousand_rub_per_m2": _to_num(
|
||||||
|
row.get("Средняя цена м2 лота в сделках (ДДУ), тыс.Р/м2")
|
||||||
|
),
|
||||||
|
"deals_ddu_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках (ДДУ), м2")),
|
||||||
|
# ДКП
|
||||||
|
"deals_dkp_count": _to_int(row.get("Количество в сделках (ДКП), шт.")),
|
||||||
|
"deals_dkp_count_priced": _to_int(row.get("Количество лотов в сделках (ДКП с ценами), шт.")),
|
||||||
|
"deals_dkp_vol_m2": _to_num(row.get("Объем реализации (ДКП), м2.")),
|
||||||
|
"deals_dkp_vol_m2_priced": _to_num(row.get("Объем реализации (ДКП с ценами), м2.")),
|
||||||
|
"deals_dkp_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (ДКП с ценами), млн.Р")),
|
||||||
|
"deals_dkp_avg_price_thousand_rub_per_m2": _to_num(
|
||||||
|
row.get("Средняя цена м2 лота в сделках (ДКП), тыс.Р/м2")
|
||||||
|
),
|
||||||
|
"deals_dkp_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках (ДКП), м2")),
|
||||||
|
# Всего
|
||||||
|
"deals_total_count": _to_int(row.get("Количество в сделках (всего), шт.")),
|
||||||
|
"deals_total_count_priced": _to_int(row.get("Количество лотов в сделках (с ценами), шт.")),
|
||||||
|
"deals_total_vol_m2": _to_num(row.get("Объем реализации (всего), м2.")),
|
||||||
|
"deals_total_vol_m2_priced": _to_num(row.get("Объем реализации (с ценами), м2.")),
|
||||||
|
"deals_total_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (с ценами), млн.Р")),
|
||||||
|
"deals_total_avg_price_thousand_rub_per_m2": _to_num(
|
||||||
|
row.get("Средняя цена м2 лота в сделках, тыс.Р/м2")
|
||||||
|
),
|
||||||
|
"deals_total_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках, м2")),
|
||||||
|
# Предложение
|
||||||
|
"offer_count": _to_int(row.get("Объем предложения, шт.")),
|
||||||
|
"offer_area_m2": _to_num(row.get("Объем предложения, м2.")),
|
||||||
|
"offer_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов в продаже, млн.Р")),
|
||||||
|
"offer_avg_price_thousand_rub_per_m2": _to_num(
|
||||||
|
row.get("Средняя цена м2 лота в продаже, тыс.Р/м2")
|
||||||
|
),
|
||||||
|
# Audit
|
||||||
|
"raw_id": raw_id,
|
||||||
|
"raw_props": json.dumps(row, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CRM_UPSERT = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO objective_corpus_room_month (
|
||||||
|
report_month, group_name, project_name, developer, district, class,
|
||||||
|
corpus_name, room_bucket, rooms_int,
|
||||||
|
sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
months_in_realization, lots_pd_count, lots_pd_area,
|
||||||
|
deals_ddu_count, deals_ddu_count_priced, deals_ddu_vol_m2,
|
||||||
|
deals_ddu_vol_m2_priced, deals_ddu_sum_mln_rub,
|
||||||
|
deals_ddu_avg_price_thousand_rub_per_m2, deals_ddu_avg_area_m2,
|
||||||
|
deals_dkp_count, deals_dkp_count_priced, deals_dkp_vol_m2,
|
||||||
|
deals_dkp_vol_m2_priced, deals_dkp_sum_mln_rub,
|
||||||
|
deals_dkp_avg_price_thousand_rub_per_m2, deals_dkp_avg_area_m2,
|
||||||
|
deals_total_count, deals_total_count_priced, deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced, deals_total_sum_mln_rub,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2, deals_total_avg_area_m2,
|
||||||
|
offer_count, offer_area_m2, offer_sum_mln_rub,
|
||||||
|
offer_avg_price_thousand_rub_per_m2,
|
||||||
|
raw_id, raw_props
|
||||||
|
) VALUES (
|
||||||
|
:report_month, :group_name, :project_name, :developer, :district, :class,
|
||||||
|
:corpus_name, :room_bucket, :rooms_int,
|
||||||
|
:sales_start_date, :plan_completion_date, :actual_completion_date,
|
||||||
|
:months_in_realization, :lots_pd_count, :lots_pd_area,
|
||||||
|
:deals_ddu_count, :deals_ddu_count_priced, :deals_ddu_vol_m2,
|
||||||
|
:deals_ddu_vol_m2_priced, :deals_ddu_sum_mln_rub,
|
||||||
|
:deals_ddu_avg_price_thousand_rub_per_m2, :deals_ddu_avg_area_m2,
|
||||||
|
:deals_dkp_count, :deals_dkp_count_priced, :deals_dkp_vol_m2,
|
||||||
|
:deals_dkp_vol_m2_priced, :deals_dkp_sum_mln_rub,
|
||||||
|
:deals_dkp_avg_price_thousand_rub_per_m2, :deals_dkp_avg_area_m2,
|
||||||
|
:deals_total_count, :deals_total_count_priced, :deals_total_vol_m2,
|
||||||
|
:deals_total_vol_m2_priced, :deals_total_sum_mln_rub,
|
||||||
|
:deals_total_avg_price_thousand_rub_per_m2, :deals_total_avg_area_m2,
|
||||||
|
:offer_count, :offer_area_m2, :offer_sum_mln_rub,
|
||||||
|
:offer_avg_price_thousand_rub_per_m2,
|
||||||
|
:raw_id, CAST(:raw_props AS jsonb)
|
||||||
|
)
|
||||||
|
ON CONFLICT (report_month, group_name, project_name, corpus_name, room_bucket)
|
||||||
|
DO UPDATE SET
|
||||||
|
developer = EXCLUDED.developer,
|
||||||
|
district = EXCLUDED.district,
|
||||||
|
class = EXCLUDED.class,
|
||||||
|
rooms_int = EXCLUDED.rooms_int,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
months_in_realization = EXCLUDED.months_in_realization,
|
||||||
|
lots_pd_count = EXCLUDED.lots_pd_count,
|
||||||
|
lots_pd_area = EXCLUDED.lots_pd_area,
|
||||||
|
deals_ddu_count = EXCLUDED.deals_ddu_count,
|
||||||
|
deals_ddu_count_priced = EXCLUDED.deals_ddu_count_priced,
|
||||||
|
deals_ddu_vol_m2 = EXCLUDED.deals_ddu_vol_m2,
|
||||||
|
deals_ddu_vol_m2_priced = EXCLUDED.deals_ddu_vol_m2_priced,
|
||||||
|
deals_ddu_sum_mln_rub = EXCLUDED.deals_ddu_sum_mln_rub,
|
||||||
|
deals_ddu_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_ddu_avg_price_thousand_rub_per_m2,
|
||||||
|
deals_ddu_avg_area_m2 = EXCLUDED.deals_ddu_avg_area_m2,
|
||||||
|
deals_dkp_count = EXCLUDED.deals_dkp_count,
|
||||||
|
deals_dkp_count_priced = EXCLUDED.deals_dkp_count_priced,
|
||||||
|
deals_dkp_vol_m2 = EXCLUDED.deals_dkp_vol_m2,
|
||||||
|
deals_dkp_vol_m2_priced = EXCLUDED.deals_dkp_vol_m2_priced,
|
||||||
|
deals_dkp_sum_mln_rub = EXCLUDED.deals_dkp_sum_mln_rub,
|
||||||
|
deals_dkp_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_dkp_avg_price_thousand_rub_per_m2,
|
||||||
|
deals_dkp_avg_area_m2 = EXCLUDED.deals_dkp_avg_area_m2,
|
||||||
|
deals_total_count = EXCLUDED.deals_total_count,
|
||||||
|
deals_total_count_priced = EXCLUDED.deals_total_count_priced,
|
||||||
|
deals_total_vol_m2 = EXCLUDED.deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced = EXCLUDED.deals_total_vol_m2_priced,
|
||||||
|
deals_total_sum_mln_rub = EXCLUDED.deals_total_sum_mln_rub,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_total_avg_price_thousand_rub_per_m2,
|
||||||
|
deals_total_avg_area_m2 = EXCLUDED.deals_total_avg_area_m2,
|
||||||
|
offer_count = EXCLUDED.offer_count,
|
||||||
|
offer_area_m2 = EXCLUDED.offer_area_m2,
|
||||||
|
offer_sum_mln_rub = EXCLUDED.offer_sum_mln_rub,
|
||||||
|
offer_avg_price_thousand_rub_per_m2 = EXCLUDED.offer_avg_price_thousand_rub_per_m2,
|
||||||
|
raw_id = EXCLUDED.raw_id,
|
||||||
|
raw_props = EXCLUDED.raw_props,
|
||||||
|
fetched_at = NOW()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── lots_pf (Поквартирные/Лоты) → objective_lots + objective_lots_history ───
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_lot(row: dict, raw_id: int | None, snapshot_date: date) -> dict:
|
||||||
|
rooms_obj = row.get("Количество комнат(Данные объектива)")
|
||||||
|
return {
|
||||||
|
"objective_lot_id": _to_int(row.get("Id")),
|
||||||
|
"objective_project_id": _to_int(row.get("Id проекта")),
|
||||||
|
"project_name": row.get("Проект"),
|
||||||
|
"developer": row.get("Девелопер"),
|
||||||
|
"city": row.get("Город"),
|
||||||
|
"district": row.get("Район"),
|
||||||
|
"corpus_name": row.get("Корпус"),
|
||||||
|
"address": row.get("Адрес"),
|
||||||
|
"class": row.get("Класс"),
|
||||||
|
"section": row.get("Секция"),
|
||||||
|
"floor": _to_int(row.get("Этаж")),
|
||||||
|
"lot_number": row.get("Номер лота"),
|
||||||
|
"premise_kind": row.get("Вид помещения"),
|
||||||
|
"sales_start_date": _parse_iso_date(row.get("Старт продаж")),
|
||||||
|
"plan_completion_date": _parse_iso_date(row.get("Планируемая дата ввода")),
|
||||||
|
"actual_completion_date": _parse_iso_date(row.get("Фактическая дата ввода")),
|
||||||
|
"readiness_pct": _parse_pct(row.get("Готовность")),
|
||||||
|
"construction_stage": row.get("Стадия строительства"),
|
||||||
|
"finishing": row.get("Отделка по корпусу"),
|
||||||
|
"status": row.get("Статус"),
|
||||||
|
"is_sold": _parse_bool_yes(row.get("Продано")),
|
||||||
|
"rooms_dev_site": row.get("Количество комнат(Сайт девелопера)"),
|
||||||
|
"rooms_pd": row.get("Количество комнат(ПД)"),
|
||||||
|
"rooms_objective": rooms_obj,
|
||||||
|
"rooms_int": _normalize_rooms(rooms_obj),
|
||||||
|
"area_dev_site": _to_num(row.get("Площадь, м2(Сайт девелопера)")),
|
||||||
|
"area_pd": _to_num(row.get("Площадь, м2(ПД)")),
|
||||||
|
"price_calculated_total_rub": _to_num(row.get("Расчетный бюджет лота, Р")),
|
||||||
|
"price_per_m2_rub": _to_num(row.get("Цена за м2, Р")),
|
||||||
|
"price_offer_total_rub": _to_num(row.get("Цена предложения, Р")),
|
||||||
|
"price_delta_rub": _to_num(row.get("Дельта цена, Р")),
|
||||||
|
"price_delta_pct": _to_num(row.get("Дельта цена, %")),
|
||||||
|
"pricing_method": row.get("Способ определения цены"),
|
||||||
|
"price_set_date": _parse_iso_date(row.get("Дата установки цены")),
|
||||||
|
"price_actual_date": _parse_iso_date(row.get("Дата актуальности цены")),
|
||||||
|
"contract_date": _parse_iso_date(row.get("Дата договора")),
|
||||||
|
"registration_date": _parse_iso_date(row.get("Дата регистрации")),
|
||||||
|
"contract_type": row.get("Тип сделки"),
|
||||||
|
"buyer_type": row.get("Тип покупателя"),
|
||||||
|
"registration_number": row.get("Номер регистрации"),
|
||||||
|
"encumbrance_type": row.get("Тип обременения"),
|
||||||
|
"bank_name": row.get("Банк"),
|
||||||
|
"encumbrance_start_date": _parse_iso_date(row.get("Дата начала обременения")),
|
||||||
|
"egrn_actual_date": _parse_iso_date(row.get("Дата актуальности данных из ЕГРН")),
|
||||||
|
"raw_id": raw_id,
|
||||||
|
"raw_props": json.dumps(row, ensure_ascii=False),
|
||||||
|
"snapshot_date": snapshot_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_LOTS_UPSERT = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO objective_lots (
|
||||||
|
objective_lot_id, objective_project_id, project_name, developer, city,
|
||||||
|
district, corpus_name, address, class, section, floor, lot_number,
|
||||||
|
premise_kind, sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
readiness_pct, construction_stage, finishing, status, is_sold,
|
||||||
|
rooms_dev_site, rooms_pd, rooms_objective, rooms_int,
|
||||||
|
area_dev_site, area_pd,
|
||||||
|
price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub,
|
||||||
|
price_delta_rub, price_delta_pct, pricing_method,
|
||||||
|
price_set_date, price_actual_date,
|
||||||
|
contract_date, registration_date, contract_type, buyer_type,
|
||||||
|
registration_number, encumbrance_type, bank_name, encumbrance_start_date,
|
||||||
|
egrn_actual_date,
|
||||||
|
raw_id, raw_props, snapshot_date
|
||||||
|
) VALUES (
|
||||||
|
:objective_lot_id, :objective_project_id, :project_name, :developer, :city,
|
||||||
|
:district, :corpus_name, :address, :class, :section, :floor, :lot_number,
|
||||||
|
:premise_kind, :sales_start_date, :plan_completion_date, :actual_completion_date,
|
||||||
|
:readiness_pct, :construction_stage, :finishing, :status, :is_sold,
|
||||||
|
:rooms_dev_site, :rooms_pd, :rooms_objective, :rooms_int,
|
||||||
|
:area_dev_site, :area_pd,
|
||||||
|
:price_calculated_total_rub, :price_per_m2_rub, :price_offer_total_rub,
|
||||||
|
:price_delta_rub, :price_delta_pct, :pricing_method,
|
||||||
|
:price_set_date, :price_actual_date,
|
||||||
|
:contract_date, :registration_date, :contract_type, :buyer_type,
|
||||||
|
:registration_number, :encumbrance_type, :bank_name, :encumbrance_start_date,
|
||||||
|
:egrn_actual_date,
|
||||||
|
:raw_id, CAST(:raw_props AS jsonb), :snapshot_date
|
||||||
|
)
|
||||||
|
ON CONFLICT (objective_lot_id) DO UPDATE SET
|
||||||
|
objective_project_id = EXCLUDED.objective_project_id,
|
||||||
|
project_name = EXCLUDED.project_name,
|
||||||
|
developer = EXCLUDED.developer,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
district = EXCLUDED.district,
|
||||||
|
corpus_name = EXCLUDED.corpus_name,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
class = EXCLUDED.class,
|
||||||
|
section = EXCLUDED.section,
|
||||||
|
floor = EXCLUDED.floor,
|
||||||
|
lot_number = EXCLUDED.lot_number,
|
||||||
|
premise_kind = EXCLUDED.premise_kind,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
readiness_pct = EXCLUDED.readiness_pct,
|
||||||
|
construction_stage = EXCLUDED.construction_stage,
|
||||||
|
finishing = EXCLUDED.finishing,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
is_sold = EXCLUDED.is_sold,
|
||||||
|
rooms_dev_site = EXCLUDED.rooms_dev_site,
|
||||||
|
rooms_pd = EXCLUDED.rooms_pd,
|
||||||
|
rooms_objective = EXCLUDED.rooms_objective,
|
||||||
|
rooms_int = EXCLUDED.rooms_int,
|
||||||
|
area_dev_site = EXCLUDED.area_dev_site,
|
||||||
|
area_pd = EXCLUDED.area_pd,
|
||||||
|
price_calculated_total_rub = EXCLUDED.price_calculated_total_rub,
|
||||||
|
price_per_m2_rub = EXCLUDED.price_per_m2_rub,
|
||||||
|
price_offer_total_rub = EXCLUDED.price_offer_total_rub,
|
||||||
|
price_delta_rub = EXCLUDED.price_delta_rub,
|
||||||
|
price_delta_pct = EXCLUDED.price_delta_pct,
|
||||||
|
pricing_method = EXCLUDED.pricing_method,
|
||||||
|
price_set_date = EXCLUDED.price_set_date,
|
||||||
|
price_actual_date = EXCLUDED.price_actual_date,
|
||||||
|
contract_date = EXCLUDED.contract_date,
|
||||||
|
registration_date = EXCLUDED.registration_date,
|
||||||
|
contract_type = EXCLUDED.contract_type,
|
||||||
|
buyer_type = EXCLUDED.buyer_type,
|
||||||
|
registration_number = EXCLUDED.registration_number,
|
||||||
|
encumbrance_type = EXCLUDED.encumbrance_type,
|
||||||
|
bank_name = EXCLUDED.bank_name,
|
||||||
|
encumbrance_start_date = EXCLUDED.encumbrance_start_date,
|
||||||
|
egrn_actual_date = EXCLUDED.egrn_actual_date,
|
||||||
|
raw_id = EXCLUDED.raw_id,
|
||||||
|
raw_props = EXCLUDED.raw_props,
|
||||||
|
snapshot_date = EXCLUDED.snapshot_date,
|
||||||
|
updated_at = NOW()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOTS_HISTORY_INSERT = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO objective_lots_history (
|
||||||
|
objective_lot_id, snapshot_date, status, is_sold,
|
||||||
|
price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub,
|
||||||
|
price_delta_pct, area_pd, contract_date, registration_date,
|
||||||
|
contract_type, bank_name, raw_id
|
||||||
|
) VALUES (
|
||||||
|
:objective_lot_id, :snapshot_date, :status, :is_sold,
|
||||||
|
:price_calculated_total_rub, :price_per_m2_rub, :price_offer_total_rub,
|
||||||
|
:price_delta_pct, :area_pd, :contract_date, :registration_date,
|
||||||
|
:contract_type, :bank_name, :raw_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (objective_lot_id, snapshot_date) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── orchestration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_corp_sum(payload: dict, group_name: str, raw_id: int | None,
|
||||||
|
db, dry_run: bool) -> int:
|
||||||
|
rows = payload.get("result") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
logger.warning("corp_sum: payload без list result, skip")
|
||||||
|
return 0
|
||||||
|
n_inserted = 0
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
params = _row_to_corp_room_month(row, group_name, raw_id)
|
||||||
|
if not params["report_month"] or not params["project_name"] or not params["corpus_name"]:
|
||||||
|
logger.warning("corp_sum: пропуск row без обязательных полей: %s",
|
||||||
|
{k: row.get(k) for k in ("Месяц", "Проект", "Корпус")})
|
||||||
|
continue
|
||||||
|
if not dry_run:
|
||||||
|
db.execute(_CRM_UPSERT, params)
|
||||||
|
n_inserted += 1
|
||||||
|
return n_inserted
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lots_pf(payload: dict, raw_id: int | None, snapshot_date: date,
|
||||||
|
db, dry_run: bool) -> tuple[int, int]:
|
||||||
|
rows = payload.get("result") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
logger.warning("lots_pf: payload без list result, skip")
|
||||||
|
return 0, 0
|
||||||
|
n_lots = 0
|
||||||
|
n_history = 0
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
params = _row_to_lot(row, raw_id, snapshot_date)
|
||||||
|
if not params["objective_lot_id"]:
|
||||||
|
logger.warning("lots_pf: пропуск row без Id")
|
||||||
|
continue
|
||||||
|
if not dry_run:
|
||||||
|
db.execute(_LOTS_UPSERT, params)
|
||||||
|
db.execute(_LOTS_HISTORY_INSERT, {
|
||||||
|
"objective_lot_id": params["objective_lot_id"],
|
||||||
|
"snapshot_date": snapshot_date,
|
||||||
|
"status": params["status"],
|
||||||
|
"is_sold": params["is_sold"],
|
||||||
|
"price_calculated_total_rub": params["price_calculated_total_rub"],
|
||||||
|
"price_per_m2_rub": params["price_per_m2_rub"],
|
||||||
|
"price_offer_total_rub": params["price_offer_total_rub"],
|
||||||
|
"price_delta_pct": params["price_delta_pct"],
|
||||||
|
"area_pd": params["area_pd"],
|
||||||
|
"contract_date": params["contract_date"],
|
||||||
|
"registration_date": params["registration_date"],
|
||||||
|
"contract_type": params["contract_type"],
|
||||||
|
"bank_name": params["bank_name"],
|
||||||
|
"raw_id": raw_id,
|
||||||
|
})
|
||||||
|
n_lots += 1
|
||||||
|
n_history += 1
|
||||||
|
return n_lots, n_history
|
||||||
|
|
||||||
|
|
||||||
|
def detect_kind(payload: Any) -> str | None:
|
||||||
|
"""Эвристика по полям первого row: 'corp_sum' | 'lots_pf' | None."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
rows = payload.get("result")
|
||||||
|
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
|
||||||
|
return None
|
||||||
|
first = rows[0]
|
||||||
|
if "Месяц" in first and "Корпус" in first and "Объем предложения, шт." in first:
|
||||||
|
return "corp_sum"
|
||||||
|
if "Id" in first and "Номер лота" in first and "Площадь, м2(ПД)" in first:
|
||||||
|
return "lots_pf"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--file-corp-sum", type=str,
|
||||||
|
help="Локальный JSON-файл с corp_sum payload")
|
||||||
|
ap.add_argument("--file-lots-pf", type=str,
|
||||||
|
help="Локальный JSON-файл с lots_pf payload")
|
||||||
|
ap.add_argument("--raw-id", type=int,
|
||||||
|
help="Парсить один объект из objective_raw_reports по raw_id")
|
||||||
|
ap.add_argument("--latest-run", action="store_true",
|
||||||
|
help="Парсить все unparsed raw из последнего objective_scrape_runs.run_id")
|
||||||
|
ap.add_argument("--group-name", default="Екатеринбург",
|
||||||
|
help="GroupName для file-режима (default: Екатеринбург)")
|
||||||
|
ap.add_argument("--snapshot-date", type=str, default=None,
|
||||||
|
help="Дата снимка для lots_pf (default: сегодня)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Не писать в БД (только parse + считать rows)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
snap = _parse_iso_date(args.snapshot_date) or date.today()
|
||||||
|
|
||||||
|
# Local-file mode (smoke без БД)
|
||||||
|
if args.file_corp_sum or args.file_lots_pf:
|
||||||
|
if not args.dry_run:
|
||||||
|
db = SessionLocal()
|
||||||
|
else:
|
||||||
|
db = None # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.file_corp_sum:
|
||||||
|
payload = json.loads(Path(args.file_corp_sum).read_text(encoding="utf-8"))
|
||||||
|
n = parse_corp_sum(payload, args.group_name, None, db, args.dry_run)
|
||||||
|
print(f"corp_sum: {n} rows {'(dry-run)' if args.dry_run else 'upserted'}")
|
||||||
|
|
||||||
|
if args.file_lots_pf:
|
||||||
|
payload = json.loads(Path(args.file_lots_pf).read_text(encoding="utf-8"))
|
||||||
|
n_lots, n_hist = parse_lots_pf(payload, None, snap, db, args.dry_run)
|
||||||
|
print(f"lots_pf: {n_lots} lots / {n_hist} history rows "
|
||||||
|
f"{'(dry-run)' if args.dry_run else 'upserted'}")
|
||||||
|
|
||||||
|
if not args.dry_run and db is not None:
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# DB mode — нужна реальная БД
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if args.raw_id:
|
||||||
|
raw_ids = [args.raw_id]
|
||||||
|
elif args.latest_run:
|
||||||
|
row = db.execute(text(
|
||||||
|
"SELECT MAX(run_id) FROM objective_scrape_runs"
|
||||||
|
)).scalar()
|
||||||
|
if not row:
|
||||||
|
print("Нет ни одного run.")
|
||||||
|
return 1
|
||||||
|
raw_ids = [r[0] for r in db.execute(text(
|
||||||
|
"""
|
||||||
|
SELECT raw_id FROM objective_raw_reports
|
||||||
|
WHERE run_id = :rid
|
||||||
|
AND (rows_extracted IS NULL OR rows_extracted = 0)
|
||||||
|
ORDER BY raw_id
|
||||||
|
"""
|
||||||
|
), {"rid": row}).all()]
|
||||||
|
if not raw_ids:
|
||||||
|
print(f"В run {row} нет unparsed raw.")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
ap.error("Нужен --raw-id, --latest-run, или --file-corp-sum/--file-lots-pf")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
for rid in raw_ids:
|
||||||
|
r = db.execute(text(
|
||||||
|
"""
|
||||||
|
SELECT raw_id, report_type, report_name, group_name, payload
|
||||||
|
FROM objective_raw_reports WHERE raw_id = :rid
|
||||||
|
"""
|
||||||
|
), {"rid": rid}).mappings().first()
|
||||||
|
if not r:
|
||||||
|
logger.warning("raw_id=%s не найден", rid)
|
||||||
|
continue
|
||||||
|
kind = detect_kind(r["payload"])
|
||||||
|
print(f"raw_id={rid} type={r['report_type']}/{r['report_name']} kind={kind}")
|
||||||
|
n = 0
|
||||||
|
if kind == "corp_sum":
|
||||||
|
n = parse_corp_sum(r["payload"], r["group_name"], rid, db, args.dry_run)
|
||||||
|
elif kind == "lots_pf":
|
||||||
|
n_lots, _ = parse_lots_pf(r["payload"], rid, snap, db, args.dry_run)
|
||||||
|
n = n_lots
|
||||||
|
else:
|
||||||
|
logger.warning("raw_id=%s — kind неизвестен (тариф не отдал?), skip", rid)
|
||||||
|
|
||||||
|
if not args.dry_run and n > 0:
|
||||||
|
db.execute(text(
|
||||||
|
"UPDATE objective_raw_reports SET rows_extracted = :n WHERE raw_id = :rid"
|
||||||
|
), {"n": n, "rid": rid})
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
print("DONE.")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue