gendesign/data/sql/41_load_history.py
2026-04-27 13:05:36 +03:00

283 lines
12 KiB
Python

"""Load historical scrape into prod (realization + sold_ready).
Reads: data/raw/domrf_history/<section>/<YYYY-MM>/*.json
Writes: domrf_realization, domrf_sold_ready_index/breakdown/dynamics
+ a fresh row in domrf_snapshots per period.
SNAPSHOT_DATE for each period = last day of repMonth (so PK collisions are
avoided across multiple historical pulls done on the same calendar day).
"""
import os, json, re, sys, subprocess, calendar, datetime
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_history')
PG_HOST = os.environ.get('PG_HOST', 'host.docker.internal')
PG_PORT = os.environ.get('PG_PORT', '15432')
PG_USER = os.environ.get('PG_USER', 'gendesign')
PG_DB = os.environ.get('PG_DB', 'gendesign')
PG_PASS = os.environ.get('PGPASSWORD', '2J2SBPMKuS998fiwhtQqDhMI')
def psql(sql):
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
'postgres:16-alpine', 'psql',
'-h', PG_HOST, '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
'-v', 'ON_ERROR_STOP=1', '--quiet']
res = subprocess.run(cmd, input=sql, capture_output=True, text=True, encoding='utf-8')
if res.returncode != 0:
print('psql FAILED:', (res.stderr or '')[-2000:], file=sys.stderr)
raise SystemExit(res.returncode)
return res.stdout
def esc(s):
if s is None:
return 'NULL'
return "'" + str(s).replace("'", "''") + "'"
def num(v):
if v is None or v == '' or v == '-':
return 'NULL'
try:
f = float(v)
if f != f:
return 'NULL'
return repr(f)
except (ValueError, TypeError):
return 'NULL'
def upsert_chunked(rows, table, cols, conflict_cols, batch=500):
if not rows:
return 0
update = ', '.join(f'{c}=EXCLUDED.{c}' for c in cols if c not in conflict_cols)
total = 0
for i in range(0, len(rows), batch):
chunk = rows[i:i + batch]
sql = (f"INSERT INTO {table} ({','.join(cols)}) VALUES "
+ ','.join(chunk)
+ (f" ON CONFLICT ({','.join(conflict_cols)}) DO UPDATE SET {update};"
if update else f" ON CONFLICT ({','.join(conflict_cols)}) DO NOTHING;"))
psql(sql)
total += len(chunk)
return total
# ── REALIZATION ─────────────────────────────────────────────────────────────
RPP_FNAME_RE = re.compile(
r'rpp_(total|housing|readyYear|developer)__'
r'(?:typeSquare_(total|living)_)?'
r'(?:regionCode_(\d+)_)?'
r'repMonth_(\d+)_repYear_(\d+)'
)
def load_realization(period_dir, snap):
cols = ['snapshot_date', 'endpoint_type', 'region_code', 'rep_year', 'rep_month',
'type_square', 'subject', 'subject_desc', 'share_total',
'total_square', 'open_square', 'sold_square', 'unsold_square', 'unopened_square',
'sold_perc', 'unsold_perc', 'unopened_perc', 'sold_amount', 'price_avg']
rows, seen = [], set()
for fname in sorted(os.listdir(period_dir)):
if not fname.endswith('.json') or not fname.startswith('rpp_'):
continue
m = RPP_FNAME_RE.match(fname)
if not m:
continue
ep_raw, ts_filename, rcode_filename, rmonth, ryear = m.groups()
endpoint_type = {'total': 'total', 'housing': 'housing',
'readyYear': 'ready_year', 'developer': 'developer'}[ep_raw]
region_code = int(rcode_filename) if rcode_filename else 0
try:
doc = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
except Exception:
continue
for it in doc.get('data') or []:
ts = ts_filename or it.get('typeSquare') or 'total'
subject = str(it.get('subject', ''))
if not subject:
continue
key = (endpoint_type, region_code, ts, subject)
if key in seen:
continue
seen.add(key)
rows.append(
f"({esc(snap)},{esc(endpoint_type)},{region_code},"
f"{int(ryear)},{int(rmonth)},{esc(ts)},{esc(subject)},"
f"{esc(it.get('subjectDesc'))},{num(it.get('shareTotal'))},"
f"{num(it.get('totalSquare'))},{num(it.get('openSquare'))},"
f"{num(it.get('soldSquare'))},{num(it.get('unsoldSquare'))},"
f"{num(it.get('unopenedSquare'))},{num(it.get('soldPerc'))},"
f"{num(it.get('unsoldPerc'))},{num(it.get('unopenedPerc'))},"
f"{num(it.get('soldAmount'))},{num(it.get('priceAvg'))})"
)
return upsert_chunked(
rows, 'domrf_realization', cols,
conflict_cols=['snapshot_date', 'endpoint_type', 'region_code', 'type_square', 'subject'],
)
# ── SOLD_READY ──────────────────────────────────────────────────────────────
def load_sold_ready(period_dir, snap, year, month):
n_idx = n_brk = n_dyn = 0
# index file
for fname in os.listdir(period_dir):
path = os.path.join(period_dir, fname)
if not fname.startswith('ready-construction_index') or not fname.endswith('.json'):
continue
try:
d = json.load(open(path, encoding='utf-8'))
except Exception:
continue
row = (
f"({esc(snap)},{int(year)},{int(month)},"
f"{num(d.get('squareSumIndex'))},{num(d.get('soldPercIndex'))},"
f"{num(d.get('soldSumIndex'))},{num(d.get('soldReadyPercIndex'))},"
f"{num(d.get('readyPercIndex'))})"
)
n_idx = upsert_chunked(
[row], 'domrf_sold_ready_index',
['snapshot_date', 'rep_year', 'rep_month',
'square_sum', 'sold_perc', 'sold_sum', 'sold_ready_perc', 'ready_perc'],
conflict_cols=['snapshot_date', 'rep_year', 'rep_month'],
)
# charts → breakdown (shape: {"charts":[{"chartType":"...","data":[...]}]} )
# Each chart_type has exactly one discriminator field — extracted to its
# own typed column instead of dumped into JSONB.
chart_rows = []
seen_keys = set()
for fname in os.listdir(period_dir):
if not (fname.startswith('ready-construction_charts')
or fname.startswith('ready-construction_ready-year-charts')) or not fname.endswith('.json'):
continue
try:
d = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
except Exception:
continue
# Format A: {"charts":[{"chartType":"foChart","data":[...]}, ...]}
for chart_obj in (d.get('charts') or []):
chart_type = chart_obj.get('chartType')
if not chart_type:
continue
for it in chart_obj.get('data') or []:
if not isinstance(it, dict):
continue
key = it.get('key') or it.get('id') or it.get('subject') or it.get('name') or ''
if not key:
continue
pk = (chart_type, str(key))
if pk in seen_keys:
continue
seen_keys.add(pk)
chart_rows.append(
f"({esc(snap)},{int(year)},{int(month)},{esc(chart_type)},{esc(str(key))},"
f"{num(it.get('squareSum'))},{num(it.get('soldPerc'))},"
f"{num(it.get('readyPerc'))},{num(it.get('soldReadyPerc'))},"
f"{esc(it.get('foCd'))},"
f"{esc(it.get('regionCd'))},"
f"{esc(it.get('devCalcGroupId'))},"
f"{esc(it.get('devGroupValue'))},"
f"{esc(it.get('cityPopulation'))},"
f"{esc(it.get('objClass'))},"
f"NULL)" # ready_year — only for readyYearChart format below
)
# Format B: {"readyYears":[{"readyYear":2030,...}]} → readyYearChart
for it in (d.get('readyYears') or []):
if not isinstance(it, dict):
continue
ry = it.get('readyYear')
if ry is None:
continue
key = str(ry)
pk = ('readyYearChart', key)
if pk in seen_keys:
continue
seen_keys.add(pk)
chart_rows.append(
f"({esc(snap)},{int(year)},{int(month)},{esc('readyYearChart')},{esc(key)},"
f"{num(it.get('squareSum'))},{num(it.get('soldPerc'))},"
f"{num(it.get('readyPerc'))},{num(it.get('soldReadyPerc'))},"
f"NULL,NULL,NULL,NULL,NULL,NULL,{int(ry)})"
)
n_brk = upsert_chunked(
chart_rows, 'domrf_sold_ready_breakdown',
['snapshot_date', 'rep_year', 'rep_month', 'chart_type', 'entity_key',
'square_sum', 'sold_perc', 'ready_perc', 'sold_ready_perc',
'fo_cd', 'region_cd', 'dev_calc_group_id', 'dev_group_value',
'city_population', 'obj_class', 'ready_year'],
conflict_cols=['snapshot_date', 'rep_year', 'rep_month', 'chart_type', 'entity_key'],
)
# dynamics: {"dynamicCharts":[{"dynamicChartType":"soldPercChart",
# "repYears":[{"repYear":2020,"repMonths":[{"repMonth":1,"value":41.54}]}]}]}
dyn_rows = []
dyn_seen = set()
for fname in os.listdir(period_dir):
if not fname.startswith('ready-construction_dynamics') or not fname.endswith('.json'):
continue
try:
d = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
except Exception:
continue
for chart_obj in (d.get('dynamicCharts') or []):
ch_type = chart_obj.get('dynamicChartType')
if not ch_type:
continue
for ry_obj in chart_obj.get('repYears') or []:
ry = ry_obj.get('repYear')
if ry is None:
continue
for pt in ry_obj.get('repMonths') or []:
rm = pt.get('repMonth')
if rm is None:
continue
pk = (ch_type, ry, rm)
if pk in dyn_seen:
continue
dyn_seen.add(pk)
dyn_rows.append(
f"({esc(snap)},{esc(ch_type)},{int(ry)},{int(rm)},{num(pt.get('value'))})"
)
n_dyn = upsert_chunked(
dyn_rows, 'domrf_sold_ready_dynamics',
['snapshot_date', 'dynamic_chart_type', 'rep_year', 'rep_month', 'value'],
conflict_cols=['snapshot_date', 'dynamic_chart_type', 'rep_year', 'rep_month'],
)
return n_idx, n_brk, n_dyn
def main():
if not os.path.isdir(ROOT):
print(f'{ROOT} not found, run 40_scrape_history.py first')
return
# Discover periods from realization subdirs
realization_root = os.path.join(ROOT, 'realization')
periods = sorted(
d for d in os.listdir(realization_root)
if re.match(r'^\d{4}-\d{2}$', d) and os.path.isdir(os.path.join(realization_root, d))
)
print(f'Periods to load: {periods}')
for period in periods:
year, month = map(int, period.split('-'))
last_day = calendar.monthrange(year, month)[1]
snap = f'{year:04d}-{month:02d}-{last_day:02d}'
# Ensure parent snapshot row
psql(f"INSERT INTO domrf_snapshots(snapshot_date) VALUES ({esc(snap)}) ON CONFLICT DO NOTHING;")
period_dir = os.path.join(realization_root, period)
n_real = load_realization(period_dir, snap)
sr_dir = os.path.join(ROOT, 'sold_ready', period)
n_idx = n_brk = n_dyn = 0
if os.path.isdir(sr_dir):
n_idx, n_brk, n_dyn = load_sold_ready(sr_dir, snap, year, month)
print(f'{period} (snap={snap}): realization={n_real} '
f'sr_index={n_idx} sr_breakdown={n_brk} sr_dynamics={n_dyn}')
if __name__ == '__main__':
main()