"""Load historical scrape into prod (realization + sold_ready). Reads: data/raw/domrf_history/
//*.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()