Objective API (api.objctv.ru) интегрирован как новый source of truth для
per-flat данных по новостройкам УрФО. Заменяет промежуточный Anton-SQLite
(legacy bootstrap-ETL остался как fallback в свёрнутом блоке UI).
Schema (data/sql/68_v2 — applied на проде, 6 таблиц):
- objective_lots (UPSERT по lot_id; 303 677 rows)
- objective_corpus_room_month (long-формат месяц×корпус×room_bucket; 19 738 rows)
- objective_lots_history (append-only weekly snapshots для elasticity)
- objective_complex_mapping (Objective ComplexName ↔ domrf_kn_objects.obj_id)
- objective_raw_reports (jsonb страховка на смену схемы API)
- objective_scrape_runs (журнал прогонов)
+ data/sql/72_objective_sync_config (single-row динамический конфиг)
Backend:
- services/scrapers/objective.py: ObjectiveClient — Bearer-токен (Redis +
in-memory fallback), retry на 401/429/5xx, Retry-After header support
- services/objective_etl.py: ETL SQLite Антона → PG (legacy)
- services/objective_sync_config.py: read/update single-row config
- workers/tasks/scrape_objective.py:
* sync_objective_group: 2 рабочих отчёта (corp_sum, lots_pf), inline-парсинг
* sync_all_groups: wrapper, перебирает группы из БД-конфига с
inter-group паузой; PATCH-merge explicit args > DB config
- workers/tasks/objective_etl.py: Celery task для legacy bootstrap
- workers/celery_app.py: beat читает cron из БД при старте (fallback на env)
- api/v1/admin_scrape.py: 5 новых endpoints для /objective/*
Frontend (frontend/src/app/admin/scrape/objective/page.tsx):
- PRIMARY blue блок «🌐 Наш sync» с input для override групп
- Collapsible «⚙️ Настройки» с формой (cron + 8 параметров) → PUT в БД
- Coverage-панель с PG counts + строка про SQLite Антона как legacy
- Collapsible «🛠 Bootstrap ETL» — legacy-инструмент
Beat schedule: вторник 06:00 МСК, ~10-15 мин на 4 группы (Свердл.обл +
Челябинск + Тюмень + Пермь = ~700K квартир УрФО). Расписание и параметры
меняются через админку без редеплоя (cron требует restart beat).
Эмпирические находки об API (probe 2026-05-10):
- 13 из 21 проверенных group_name доступны на тарифе (включая «Свердловская
область», «Челябинск», «Тюмень», «Пермь», но НЕ «Свердловская обл» —
формат имени строгий)
- ComplexName требует БЕЗ префикса «ЖК» и БЕЗ кавычек
- Поле «Банк» для всех 303k = NULL (тариф не отдаёт), но «Тип обременения»
работает (36% строк = ипотека) → ipoteka_share возможен, банковская
атрибуция — нет
Docker:
- bind-mount /opt/gendesign/site-finder:/data/anton-sqlite:ro в worker
GitHub backlog: добавлены #22-25 (recommend_mix v3 — 4 сабтаска по
улучшению алгоритма на основе фидбэка про POI / границы районов /
конкурентов / окно данных / success-driven mix).
Knowledge graph (memory/memory-gendesign.jsonl): обновлены entities
Objective_Integration_May07_2026, Schema_Objective_v2_May07,
Objective_API_Findings_May07, Module_Objective_Client + новый
Session_End_May07_2026.
TODO для прода: прописать OBJECTIVE_API_KEY=<key> в backend/.env +
docker compose restart worker beat.
517 lines
21 KiB
Python
517 lines
21 KiB
Python
"""ETL: SQLite Антона (sf_anton_snapshot.db) → наша PG.
|
||
|
||
Грузит 3 таблицы из его SQLite в нашу schema 68 v2:
|
||
- objective_lots (303 677 строк)
|
||
- objective_corp_month ( 19 738 строк)
|
||
- jk_objective_match ( 229 строк)
|
||
|
||
Запуск:
|
||
cd backend
|
||
DATABASE_URL=postgresql://gendesign:PWD@localhost:15432/gendesign \\
|
||
uv run python -X utf8 ../data/sql/71_etl_anton_sqlite_to_pg.py [--dry-run] [--limit N]
|
||
|
||
Идемпотентно через ON CONFLICT DO UPDATE по UNIQUE-ключам.
|
||
Для повторной заливки: данные в таблицах перезатрутся (snapshot_date обновится).
|
||
|
||
Источник: Антоновский /sf/api/* SQLite (~340 MB), скачанный в repo-root через scp:
|
||
scp gendesign:/opt/gendesign/site-finder/analysis.db sf_anton_snapshot.db
|
||
|
||
Mapping полей задокументирован в _ROW_TO_LOT / _ROW_TO_CRM / _ROW_TO_MAPPING ниже.
|
||
|
||
После прогона ETL рекомендуется DISABLE наш Celery objective-sync beat task
|
||
(Антон уже еженедельно тянет — не дублируем платный API).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
|
||
import psycopg2
|
||
import psycopg2.extras
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
logger = logging.getLogger("etl_anton")
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||
SQLITE_DEFAULT = REPO_ROOT / "sf_anton_snapshot.db"
|
||
|
||
GROUP_NAME = "Екатеринбург" # У Антона нет колонки group_name — у нас всё под Екб
|
||
|
||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _parse_date(s: str | None) -> date | None:
|
||
"""SQLite хранит как ISO-строку или None."""
|
||
if s is None or s == "":
|
||
return None
|
||
try:
|
||
return date.fromisoformat(s[:10])
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _parse_month_to_first_day(s: str | None) -> date | None:
|
||
"""'2025-08' → date(2025, 8, 1)."""
|
||
if not s:
|
||
return None
|
||
try:
|
||
y, m = s.split("-")
|
||
return date(int(y), int(m), 1)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _normalize_rooms(s: str | None) -> int | None:
|
||
"""'студия'→0, '1'→1, …, '4+'→4 (как в наш парсер 70)."""
|
||
if not s:
|
||
return None
|
||
sl = s.strip().lower()
|
||
if "студ" in sl:
|
||
return 0
|
||
m = re.match(r"^(\d+)", sl)
|
||
if m:
|
||
return min(int(m.group(1)), 5)
|
||
return None
|
||
|
||
|
||
def _bool_from_yes(v) -> bool | None:
|
||
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
|
||
|
||
|
||
def _site_id_to_obj_id(s: str | None) -> int | None:
|
||
"""'jk:49854' → 49854. 'parcel:...' → None."""
|
||
if not s:
|
||
return None
|
||
if s.startswith("jk:"):
|
||
try:
|
||
return int(s[3:])
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _to_int(v) -> int | None:
|
||
if v is None or v == "":
|
||
return None
|
||
try:
|
||
return int(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _to_num(v) -> float | None:
|
||
if v is None or v == "":
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# ── row mappings ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _row_to_lot(r: sqlite3.Row, snapshot_date: date) -> tuple:
|
||
"""SQLite objective_lots row → tuple для INSERT в наш objective_lots."""
|
||
return (
|
||
r["lot_id"], # objective_lot_id
|
||
r["project_id"], # objective_project_id
|
||
r["project"], # project_name (NOT NULL)
|
||
r["developer"],
|
||
r["city"],
|
||
r["district"],
|
||
r["corpus"], # corpus_name
|
||
r["address"],
|
||
r["obj_class"], # class
|
||
r["section"],
|
||
_to_int(r["floor"]),
|
||
r["lot_num"], # lot_number
|
||
r["room_kind"], # premise_kind
|
||
_parse_date(r["sales_start"]),
|
||
_parse_date(r["plan_date"]),
|
||
_parse_date(r["fact_date"]),
|
||
_to_int(r["readiness_pct"]),
|
||
r["construction_stage"],
|
||
r["finish_type"], # finishing
|
||
r["status"],
|
||
_bool_from_yes(r["sold"]), # is_sold
|
||
r["rooms_dev"], # rooms_dev_site
|
||
r["rooms_pd"],
|
||
r["rooms_obj"], # rooms_objective
|
||
_normalize_rooms(r["rooms_obj"]), # rooms_int (0..5)
|
||
_to_num(r["area_dev"]), # area_dev_site
|
||
_to_num(r["area_pd"]),
|
||
_to_num(r["budget_rub"]), # price_calculated_total_rub
|
||
_to_num(r["price_per_m2"]), # price_per_m2_rub (Р/м², не тыс)
|
||
_to_num(r["offer_price"]), # price_offer_total_rub
|
||
_to_num(r["delta_price_rub"]),
|
||
_to_num(r["delta_price_pct"]),
|
||
r["price_method"], # pricing_method
|
||
_parse_date(r["price_set_date"]),
|
||
_parse_date(r["price_actual_date"]),
|
||
_parse_date(r["contract_date"]),
|
||
_parse_date(r["register_date"]), # registration_date
|
||
r["deal_type"], # contract_type
|
||
r["buyer_type"],
|
||
r["register_num"], # registration_number
|
||
r["encumbrance"], # encumbrance_type
|
||
r["bank"], # bank_name
|
||
_parse_date(r["encumbrance_start"]), # encumbrance_start_date
|
||
_parse_date(r["egrn_actual_date"]),
|
||
snapshot_date, # snapshot_date — день ETL
|
||
)
|
||
|
||
|
||
_LOT_INSERT = """
|
||
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, snapshot_date
|
||
) VALUES %s
|
||
ON CONFLICT (objective_lot_id) DO UPDATE SET
|
||
project_name = EXCLUDED.project_name,
|
||
developer = EXCLUDED.developer, 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,
|
||
snapshot_date = EXCLUDED.snapshot_date,
|
||
updated_at = NOW()
|
||
"""
|
||
|
||
|
||
def _row_to_crm(r: sqlite3.Row) -> tuple:
|
||
"""objective_corp_month → objective_corpus_room_month.
|
||
|
||
Антон НЕ разделяет ДДУ vs ДКП vs всего — у нас только totals из его данных.
|
||
NULL'и в ddu/dkp колонках — Антон их не давал.
|
||
"""
|
||
return (
|
||
_parse_month_to_first_day(r["month"]), # report_month
|
||
GROUP_NAME, # group_name
|
||
r["project"], # project_name
|
||
r["developer"],
|
||
r["district"],
|
||
r["obj_class"], # class
|
||
r["corpus"], # corpus_name
|
||
r["rooms_bucket"], # room_bucket
|
||
_normalize_rooms(r["rooms_bucket"]), # rooms_int
|
||
_parse_date(r["sales_start"]),
|
||
_parse_date(r["plan_date"]),
|
||
_parse_date(r["fact_date"]),
|
||
_to_int(r["months_in_sales"]), # months_in_realization
|
||
_to_int(r["lots_pd"]), # lots_pd_count
|
||
_to_num(r["area_pd"]), # lots_pd_area
|
||
# ДДУ — у Антона нет, NULL'им
|
||
None, None, None, None, None, None, None,
|
||
# ДКП — то же
|
||
None, None, None, None, None, None, None,
|
||
# Всего (Антон даёт только это)
|
||
_to_int(r["deals_total"]), # deals_total_count
|
||
_to_int(r["deals_priced"]), # deals_total_count_priced
|
||
_to_num(r["sold_volume_m2"]), # deals_total_vol_m2
|
||
_to_num(r["sold_volume_m2"]), # deals_total_vol_m2_priced (= тот же)
|
||
None, # deals_total_sum_mln_rub — нет
|
||
_to_num(r["avg_price_m2"]), # deals_total_avg_price_thousand_rub_per_m2 (тыс.Р/м²)
|
||
_to_num(r["avg_area_m2"]), # deals_total_avg_area_m2
|
||
# Offer
|
||
_to_int(r["stock_lots"]), # offer_count
|
||
_to_num(r["stock_m2"]), # offer_area_m2
|
||
None, # offer_sum_mln_rub — нет
|
||
_to_num(r["stock_avg_price_m2"]), # offer_avg_price_thousand_rub_per_m2
|
||
)
|
||
|
||
|
||
_CRM_INSERT = """
|
||
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
|
||
) VALUES %s
|
||
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_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_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_avg_price_thousand_rub_per_m2 = EXCLUDED.offer_avg_price_thousand_rub_per_m2,
|
||
fetched_at = NOW()
|
||
"""
|
||
|
||
|
||
def _row_to_mapping(r: sqlite3.Row) -> tuple | None:
|
||
"""jk_objective_match → objective_complex_mapping.
|
||
Возвращает None если site_id не jk: (parcel пропускаем — у нас нет parcel-таблицы).
|
||
"""
|
||
obj_id = _site_id_to_obj_id(r["site_id"])
|
||
if obj_id is None:
|
||
return None
|
||
return (
|
||
r["project"], # objective_complex_name
|
||
None, # objective_project_id (Антон не хранит)
|
||
GROUP_NAME,
|
||
obj_id, # domrf_obj_id
|
||
False, # is_reviewed
|
||
r["method"], # match_method ('fuzzy', etc.)
|
||
_to_num(r["score"]), # match_score
|
||
"imported_from_anton_sf", # note
|
||
)
|
||
|
||
|
||
_MAP_INSERT = """
|
||
INSERT INTO objective_complex_mapping (
|
||
objective_complex_name, objective_project_id, objective_group,
|
||
domrf_obj_id, is_reviewed, match_method, match_score, note
|
||
) VALUES %s
|
||
ON CONFLICT (objective_complex_name, objective_group) DO UPDATE SET
|
||
domrf_obj_id = EXCLUDED.domrf_obj_id,
|
||
match_method = EXCLUDED.match_method,
|
||
match_score = EXCLUDED.match_score,
|
||
note = EXCLUDED.note,
|
||
updated_at = NOW()
|
||
"""
|
||
|
||
|
||
# ── orchestration ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def etl_lots(sl: sqlite3.Connection, pg, dry_run: bool, limit: int | None,
|
||
batch_size: int) -> int:
|
||
snap = date.today()
|
||
sql = "SELECT * FROM objective_lots"
|
||
if limit:
|
||
sql += f" LIMIT {limit}"
|
||
cur = sl.execute(sql)
|
||
n = 0
|
||
batch: list[tuple] = []
|
||
pg_cur = pg.cursor() if pg else None
|
||
while True:
|
||
rows = cur.fetchmany(batch_size)
|
||
if not rows:
|
||
break
|
||
for r in rows:
|
||
batch.append(_row_to_lot(r, snap))
|
||
if len(batch) >= batch_size:
|
||
if not dry_run:
|
||
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=batch_size)
|
||
pg.commit()
|
||
n += len(batch)
|
||
batch.clear()
|
||
if n % (batch_size * 5) == 0:
|
||
logger.info(" lots: %d upserted", n)
|
||
if batch:
|
||
if not dry_run:
|
||
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=len(batch))
|
||
pg.commit()
|
||
n += len(batch)
|
||
if pg_cur is not None:
|
||
pg_cur.close()
|
||
return n
|
||
|
||
|
||
def etl_crm(sl: sqlite3.Connection, pg, dry_run: bool, limit: int | None,
|
||
batch_size: int) -> int:
|
||
sql = "SELECT * FROM objective_corp_month"
|
||
if limit:
|
||
sql += f" LIMIT {limit}"
|
||
cur = sl.execute(sql)
|
||
n = 0
|
||
skipped = 0
|
||
batch: list[tuple] = []
|
||
pg_cur = pg.cursor() if pg else None
|
||
while True:
|
||
rows = cur.fetchmany(batch_size)
|
||
if not rows:
|
||
break
|
||
for r in rows:
|
||
t = _row_to_crm(r)
|
||
# Skip строк где report_month/project/corpus/room_bucket NULL — они нарушат UNIQUE.
|
||
if not t[0] or not t[2] or not t[6] or not t[7]:
|
||
skipped += 1
|
||
continue
|
||
batch.append(t)
|
||
if len(batch) >= batch_size:
|
||
if not dry_run:
|
||
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=batch_size)
|
||
pg.commit()
|
||
n += len(batch)
|
||
batch.clear()
|
||
if batch:
|
||
if not dry_run:
|
||
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=len(batch))
|
||
pg.commit()
|
||
n += len(batch)
|
||
if pg_cur is not None:
|
||
pg_cur.close()
|
||
if skipped:
|
||
logger.warning(" corp_month: пропущено %d строк с пустыми обязательными полями", skipped)
|
||
return n
|
||
|
||
|
||
def etl_mapping(sl: sqlite3.Connection, pg, dry_run: bool) -> int:
|
||
# ORDER BY score DESC — при дублях берём mapping с наибольшим match score.
|
||
cur = sl.execute("SELECT * FROM jk_objective_match ORDER BY score DESC NULLS LAST")
|
||
rows = cur.fetchall()
|
||
# Дедупликация по (objective_complex_name, objective_group) — наш UNIQUE.
|
||
# У Антона разные site_id (jk:N) могут матчиться в один и тот же Objective project
|
||
# (fuzzy mapping для близких имён). Берём первый = с лучшим score.
|
||
seen: set[tuple[str, str]] = set()
|
||
batch: list[tuple] = []
|
||
skipped_dups = 0
|
||
for r in rows:
|
||
t = _row_to_mapping(r)
|
||
if t is None:
|
||
continue
|
||
key = (t[0], t[2]) # (complex_name, group)
|
||
if key in seen:
|
||
skipped_dups += 1
|
||
continue
|
||
seen.add(key)
|
||
batch.append(t)
|
||
if skipped_dups:
|
||
logger.info(" mapping: дедуплицировано %d дублей (один Objective project ↔ несколько jk)", skipped_dups)
|
||
if not batch:
|
||
return 0
|
||
if not dry_run:
|
||
pg_cur = pg.cursor()
|
||
psycopg2.extras.execute_values(pg_cur, _MAP_INSERT, batch, page_size=500)
|
||
pg.commit()
|
||
pg_cur.close()
|
||
return len(batch)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--sqlite", default=str(SQLITE_DEFAULT),
|
||
help=f"Путь к SQLite (default: {SQLITE_DEFAULT})")
|
||
ap.add_argument("--db-url", default=os.environ.get("DATABASE_URL"),
|
||
help="postgres URL (или env DATABASE_URL). Через SSH-туннель: "
|
||
"postgresql://gendesign:PWD@localhost:15432/gendesign")
|
||
ap.add_argument("--dry-run", action="store_true",
|
||
help="Не писать в PG (только посчитать строки)")
|
||
ap.add_argument("--limit", type=int, default=None,
|
||
help="Ограничить кол-во строк (для пробного прогона)")
|
||
ap.add_argument("--batch-size", type=int, default=500,
|
||
help="execute_values batch size (default 500)")
|
||
ap.add_argument("--only", choices=("lots", "crm", "mapping"), default=None,
|
||
help="Грузить только одну таблицу")
|
||
args = ap.parse_args()
|
||
|
||
sqlite_path = Path(args.sqlite)
|
||
if not sqlite_path.exists():
|
||
logger.error("SQLite не найден: %s", sqlite_path)
|
||
return 1
|
||
if not args.db_url and not args.dry_run:
|
||
logger.error("Нужен --db-url или env DATABASE_URL")
|
||
return 2
|
||
|
||
logger.info("ETL: %s → %s", sqlite_path.name, args.db_url or "(dry-run)")
|
||
|
||
sl = sqlite3.connect(sqlite_path)
|
||
sl.row_factory = sqlite3.Row
|
||
|
||
pg = None
|
||
if not args.dry_run:
|
||
pg = psycopg2.connect(args.db_url)
|
||
|
||
t0 = datetime.now()
|
||
try:
|
||
if args.only in (None, "mapping"):
|
||
logger.info("[1/3] objective_complex_mapping (Антоновский match)…")
|
||
n_map = etl_mapping(sl, pg, args.dry_run)
|
||
logger.info(" → upserted %d mappings", n_map)
|
||
|
||
if args.only in (None, "crm"):
|
||
logger.info("[2/3] objective_corpus_room_month (~19 738 строк)…")
|
||
n_crm = etl_crm(sl, pg, args.dry_run, args.limit, args.batch_size)
|
||
logger.info(" → upserted %d corp_month rows", n_crm)
|
||
|
||
if args.only in (None, "lots"):
|
||
logger.info("[3/3] objective_lots (~303 677 строк)…")
|
||
n_lots = etl_lots(sl, pg, args.dry_run, args.limit, args.batch_size)
|
||
logger.info(" → upserted %d lots", n_lots)
|
||
finally:
|
||
sl.close()
|
||
if pg is not None:
|
||
pg.close()
|
||
|
||
elapsed = (datetime.now() - t0).total_seconds()
|
||
logger.info("ETL DONE in %.1fs%s", elapsed, " (dry-run)" if args.dry_run else "")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|