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:
lekss361 2026-05-07 21:04:00 +03:00
parent b44c7f157e
commit aaa5d44e77
9 changed files with 2498 additions and 3 deletions

View file

@ -43,5 +43,23 @@ class Settings(BaseSettings):
# Пауза между NSPD-запросами в мс. <600мс — высокий риск WAF 403.
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()

View 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

View file

@ -42,6 +42,7 @@ celery_app = Celery(
"app.workers.tasks.scrape_kn",
"app.workers.tasks.scrape_nspd",
"app.workers.tasks.refresh_analytics",
"app.workers.tasks.scrape_objective",
],
)
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},
}
# 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
def _resume_zombie_runs(sender=None, **_kwargs) -> None:

View 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()

View 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.';

View 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())

View 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())

View 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