204 lines
9.3 KiB
Python
204 lines
9.3 KiB
Python
"""Минимальный 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
|
||
# Всегда пишем в <repo>/data/raw/objective_test/ независимо от cwd скрипта
|
||
# (раньше был относительный путь — попадал в backend/data/raw/ когда юзер
|
||
# запускал из backend/, и эти файлы превышали 512KB pre-commit лимит).
|
||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||
out_dir = repo_root / "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())
|