# -*- coding: utf-8 -*-
"""
전국 포털 자동 업데이트 엔진
============================
전국 실거래가 매칭키 ↔ 공시가격 캐시 결합 → 분석(유형·연도·지역층화·역진성)
→ 차트/썸네일/페이지 재생성 → aiforalab /budongsan 재배포.

안전장치:
- sanity 게이트(표본수·현실화율 범위) 통과 못하면 배포 안 함(기존 페이지 유지)
- 1회 실행 가드(_PORTAL_NATIONAL_DONE)
- 전 과정 try/except + 로깅
연도별로 공시가격 캐시를 하나씩 처리해 메모리 바운드.
"""
from __future__ import annotations
import sys, os, math, traceback
from pathlib import Path
import numpy as np
import pandas as pd

try:
    sys.stdout.reconfigure(encoding="utf-8")
except Exception:
    pass

ROOT = Path(__file__).parent
NAT = ROOT / "결과" / "전국"
CACHE = NAT / "공시가격캐시"
MATCH = NAT / "분석_실거래가_매칭키.csv"
BUILD = NAT / "_portal_national"
DONE_MARK = NAT / "_PORTAL_NATIONAL_DONE"

YEARS = list(range(2019, 2027))   # 2019~2026
TYPES = {"아파트": "아파트_매매", "연립다세대": "연립다세대_매매"}

SIDO = {"11":"서울","26":"부산","27":"대구","28":"인천","29":"광주","30":"대전","31":"울산",
        "36":"세종","41":"경기","42":"강원","43":"충북","44":"충남","45":"전북","46":"전남",
        "47":"경북","48":"경남","50":"제주","51":"강원","52":"전북"}
SUDOGWON = {"서울", "경기", "인천"}


def log(m): print(m, flush=True)


# ---------------------------------------------------------------- 결합
def build_joined() -> pd.DataFrame:
    tr = pd.read_csv(MATCH, usecols=["자료구분", "PNU", "sggCd", "전용면적_㎡", "거래금액_원", "거래연도"],
                     dtype=str)
    tr = tr[tr["PNU"].notna()].copy()
    tr["전용_r"] = pd.to_numeric(tr["전용면적_㎡"], errors="coerce").round(0)
    tr["거래금액_원"] = pd.to_numeric(tr["거래금액_원"], errors="coerce")
    tr["거래연도"] = pd.to_numeric(tr["거래연도"], errors="coerce")
    tr["시도"] = tr["sggCd"].astype(str).str[:2].map(SIDO)

    out = []
    for tnm, tfull in TYPES.items():
        for y in YEARS:
            cf = CACHE / f"{tnm}_{y}.csv"
            if not cf.exists():
                continue
            try:
                p = pd.read_csv(cf, usecols=["pnu", "prvuseAr", "pblntfPc"], dtype=str)
            except Exception:
                continue
            p = p[p["pnu"].notna()]
            if p.empty:
                continue
            p["전용_r"] = pd.to_numeric(p["prvuseAr"], errors="coerce").round(0)
            p["공시가격_원"] = pd.to_numeric(p["pblntfPc"], errors="coerce")
            agg = (p.groupby(["pnu", "전용_r"])["공시가격_원"].median()
                   .reset_index().rename(columns={"pnu": "PNU"}))
            t = tr[(tr["자료구분"] == tfull) & (tr["거래연도"] == y)]
            m = t.merge(agg, on=["PNU", "전용_r"], how="inner")
            m["유형"] = tnm
            out.append(m[["유형", "시도", "거래연도", "전용_r", "거래금액_원", "공시가격_원"]])
            log(f"  결합 {tnm} {y}: 거래 {len(t):,} → 매칭 {len(m):,}")
    if not out:
        return pd.DataFrame()
    j = pd.concat(out, ignore_index=True)
    j["현실화율"] = j["공시가격_원"] / j["거래금액_원"]
    j = j[j["현실화율"].between(0.1, 1.5) & (j["거래금액_원"] > 0)]
    return j


# ---------------------------------------------------------------- IAAO
def iaao(g):
    r = g["현실화율"].values; av = g["공시가격_원"].values; sp = g["거래금액_원"].values
    med = np.median(r)
    cod = 100 * np.mean(np.abs(r - med)) / med
    prd = np.mean(r) / (av.sum() / sp.sum())
    vp = 0.5 * (av / med + sp)
    X = np.log(vp) / math.log(2); Y = (r - med) / med
    prb = np.polyfit(X, Y, 1)[0]
    return dict(N=len(g), med=med, cod=cod, prd=prd, prb=prb)


def analyze(j: pd.DataFrame) -> dict:
    s = {"N": len(j)}
    s["by_type"] = {t: iaao(g) for t, g in j.groupby("유형")}
    # 연도×유형 현실화율 중앙
    s["year"] = {t: g.groupby("거래연도")["현실화율"].median().round(3).to_dict()
                 for t, g in j.groupby("유형")}
    # 지역층화: 수도권 vs 지방
    j = j.copy(); j["권역"] = np.where(j["시도"].isin(SUDOGWON), "수도권", "지방")
    s["region2"] = {}
    for (t, rg), g in j.groupby(["유형", "권역"]):
        if len(g) >= 50:
            s["region2"][(t, rg)] = iaao(g)
    # 시도별 (아파트) 현실화율·PRB
    s["sido_apt"] = {}
    for sd, g in j[j["유형"] == "아파트"].groupby("시도"):
        if len(g) >= 100:
            m = iaao(g); s["sido_apt"][sd] = (round(m["med"], 3), round(m["prb"], 3), m["N"])
    # 가격대 5분위 × 유형
    s["price_band"] = {}
    for t, g in j.groupby("유형"):
        gg = g.copy()
        try:
            gg["band"] = pd.qcut(gg["거래금액_원"], 5, labels=["1", "2", "3", "4", "5"])
            s["price_band"][t] = gg.groupby("band", observed=True)["현실화율"].median().round(3).to_dict()
        except Exception:
            pass
    # 회귀 lnprice 계수(유형별)
    s["reg"] = {}
    try:
        import statsmodels.formula.api as smf
        for t, g in j.groupby("유형"):
            d = g.copy(); d["y"] = d["현실화율"]; d["lp"] = np.log(d["거래금액_원"])
            d["연도"] = d["거래연도"].astype(int).astype(str)
            r = smf.ols("y ~ lp + C(연도)", data=d).fit(cov_type="HC1")
            s["reg"][t] = (round(r.params["lp"], 4), round(r.tvalues["lp"], 1))
    except Exception:
        log("[경고] 회귀 생략:\n" + traceback.format_exc())
    return s


def sanity_ok(s) -> bool:
    if s.get("N", 0) < 50000:
        log(f"[게이트 실패] 표본 {s.get('N')} < 50000"); return False
    bt = s.get("by_type", {})
    for t in ("아파트", "연립다세대"):          # 두 유형 모두 충분히 수집돼야 배포
        if t not in bt or bt[t]["N"] < 10000:
            log(f"[게이트 실패] {t} 데이터 부족(완료 전으로 추정)"); return False
        if not (0.3 < bt[t]["med"] < 0.95):
            log(f"[게이트 실패] {t} 현실화율 중앙 {bt[t]['med']:.3f} 비정상"); return False
    return True


# ---------------------------------------------------------------- 차트
def make_charts(j, out: Path):
    import matplotlib; matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    plt.rcParams["font.family"] = "Malgun Gothic"; plt.rcParams["axes.unicode_minus"] = False
    out.mkdir(parents=True, exist_ok=True)

    def save(fig, name):
        fig.tight_layout(); fig.savefig(out / name, dpi=120); plt.close(fig)

    def chart(fn, name):
        try:
            fn()
        except Exception:
            log(f"  [차트 생략:{name}]\n" + traceback.format_exc())

    def c1():
        fig, ax = plt.subplots(figsize=(8, 4.6))
        for t, g in j.groupby("유형"):
            ser = g.groupby("거래연도")["현실화율"].median()
            ax.plot(ser.index.astype(int), ser.values, marker="o", label=t)
        ax.set_title("연도별 공시가격 현실화율 추이 (전국, 중앙값)"); ax.set_xlabel("거래연도")
        ax.set_ylabel("현실화율"); ax.legend(); ax.grid(alpha=.3); save(fig, "연도별_유형별.png")
    chart(c1, "연도별")

    def c2():
        fig, ax = plt.subplots(figsize=(7.5, 4.6)); x = np.arange(5); w = .38
        for i, (t, col) in enumerate([("아파트", "#2f5aa8"), ("연립다세대", "#c0714a")]):
            g = j[j["유형"] == t].copy()
            if len(g) < 500:
                continue
            g["band"] = pd.qcut(g["거래금액_원"], 5, labels=range(5), duplicates="drop")
            v = g.groupby("band", observed=True)["현실화율"].median()
            ax.bar(x[:len(v)] + (i - .5) * w, v.values, w, label=t, color=col)
        ax.set_xticks(x); ax.set_xticklabels(["1분위\n(저가)", "2", "3", "4", "5분위\n(고가)"])
        ax.set_title("가격대별 현실화율 (전국)"); ax.set_ylabel("현실화율"); ax.legend(); ax.grid(axis="y", alpha=.3)
        save(fig, "가격대별_유형별.png")
    chart(c2, "가격대별")

    def c3():
        sd = j[j["유형"] == "아파트"].groupby("시도")["현실화율"].agg(["median", "count"])
        sd = sd[sd["count"] >= 100].sort_values("median")
        if sd.empty:
            return
        fig, ax = plt.subplots(figsize=(8, 5))
        ax.barh(sd.index, sd["median"], color="#2f5aa8")
        ax.set_title("시도별 아파트 현실화율 (중앙값)"); ax.set_xlabel("현실화율"); ax.grid(axis="x", alpha=.3)
        save(fig, "시도별_아파트.png")
    chart(c3, "시도별")

    def c4():
        jj = j.copy(); jj["권역"] = np.where(jj["시도"].isin(SUDOGWON), "수도권", "지방")
        fig, ax = plt.subplots(figsize=(7, 4.4)); x = np.arange(2); w = .38
        for i, (t, col) in enumerate([("아파트", "#2f5aa8"), ("연립다세대", "#c0714a")]):
            vals = [iaao(jj[(jj["유형"] == t) & (jj["권역"] == rg)])["prb"]
                    if len(jj[(jj["유형"] == t) & (jj["권역"] == rg)]) >= 50 else np.nan
                    for rg in ["수도권", "지방"]]
            ax.bar(x + (i - .5) * w, vals, w, label=t, color=col)
        ax.axhline(0, color="gray", lw=.8); ax.axhline(-0.05, color="red", ls="--", lw=.8)
        ax.set_xticks(x); ax.set_xticklabels(["수도권", "지방"])
        ax.set_title("권역별 PRB (음수=역진)"); ax.set_ylabel("PRB"); ax.legend(); ax.grid(axis="y", alpha=.3)
        save(fig, "권역별_PRB.png")
    chart(c4, "권역별")
    log("  차트 생성 완료")


# ---------------------------------------------------------------- 썸네일
def make_thumbs(s, out: Path):
    from PIL import Image, ImageDraw, ImageFont
    apt = s["by_type"].get("아파트", {}); rh = s["by_type"].get("연립다세대", {})
    def fnt(sz, b=True): return ImageFont.truetype(r"C:\Windows\Fonts\malgunbd.ttf" if b else r"C:\Windows\Fonts\malgun.ttf", sz)

    def draw(W, H):
        img = Image.new("RGB", (W, H), "#1e3a5f"); dr = ImageDraw.Draw(img)
        for y in range(H):
            t = y / H
            dr.line([(0, y), (W, y)], fill=(int(0x1e+(0x2f-0x1e)*t), int(0x3a+(0x4a-0x3a)*t), int(0x5f+(0x7a-0x5f)*t)))
        dr.text((70, int(H*.12)), "부동산 공시가격 연구 · 전국", font=fnt(30, False), fill="#bcd0ec")
        dr.text((70, int(H*.22)), "공시가격 현실화율과", font=fnt(66), fill="#ffffff")
        dr.text((70, int(H*.34)), "수직형평성 — 전국", font=fnt(66), fill="#ffd27a")
        dr.text((70, int(H*.49)), "실거래가 대비 공시가격  ·  2019–2024  ·  전국", font=fnt(26, False), fill="#dbe6f5")
        chips = [f"현실화율 아파트 {apt.get('med',0)*100:.0f}% · 연립 {rh.get('med',0)*100:.0f}%",
                 f"연립 역진성 PRB {rh.get('prb',0):+.2f}", f"전국 {s['N']:,}건"]
        cx, cy = 70, int(H*.70)
        for c in chips:
            tw = dr.textlength(c, font=fnt(21, False))
            dr.rounded_rectangle([cx, cy, cx+tw+40, cy+48], radius=24, fill=(0x35,0x55,0x8a), outline=(0x7f,0x9c,0xca))
            dr.text((cx+20, cy+11), c, font=fnt(21, False), fill="#ffffff"); cx += tw+56
            if cx > W-320: cx = 70; cy += 62
        dr.text((70, H-56), "aiforalab.com/budongsan", font=fnt(22, False), fill="#9fb6d6")
        return img
    draw(1200, 630).save(out / "thumbnail.png")
    draw(1200, 675).resize((1024, 576), Image.LANCZOS).save(out / "budongsan.webp", "WEBP", quality=88)
    log("  썸네일(og png + card webp) 생성")


# ---------------------------------------------------------------- 요약 엑셀
def make_excel(j, s, out: Path):
    with pd.ExcelWriter(out / "분석_현실화율_전국_요약.xlsx") as xw:
        pd.DataFrame({t: s["year"][t] for t in s["year"]}).to_excel(xw, sheet_name="연도x유형")
        rows = []
        for t, m in s["by_type"].items():
            rows.append({"유형": t, "N": m["N"], "중앙현실화율": round(m["med"], 3),
                         "COD": round(m["cod"], 1), "PRD": round(m["prd"], 3), "PRB": round(m["prb"], 3)})
        pd.DataFrame(rows).to_excel(xw, sheet_name="IAAO", index=False)
        pd.DataFrame([{"시도": k, "현실화율중앙": v[0], "PRB": v[1], "N": v[2]} for k, v in s["sido_apt"].items()]
                     ).to_excel(xw, sheet_name="시도별_아파트", index=False)
    log("  요약 엑셀 생성")


# ---------------------------------------------------------------- 페이지
def pct(x): return f"{x*100:.1f}%"
def build_page(s, out: Path):
    apt = s["by_type"]["아파트"]; rh = s["by_type"]["연립다세대"]
    reg_apt = s["reg"].get("아파트", (None, None)); reg_rh = s["reg"].get("연립다세대", (None, None))
    # 시도 역진 상위(음수 PRB 큰 순)
    sido_sorted = sorted(s["sido_apt"].items(), key=lambda kv: kv[1][1])
    sido_rows = "".join(f"<tr><td>{k}</td><td>{v[0]:.3f}</td><td>{v[1]:+.3f}</td><td>{v[2]:,}</td></tr>"
                        for k, v in sido_sorted[:8])
    def rp(v): return f"{v:+.3f}" if v is not None else "—"
    html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>부동산 공시가격 현실화율·형평성 (전국) · 연구포털</title>
<meta property="og:title" content="부동산 공시가격 현실화율·수직형평성 — 전국">
<meta property="og:description" content="실거래가 대비 공시가격의 현실화율·역진성 — 전국 아파트·연립다세대(2019–2024), 지역층화 분석">
<meta property="og:type" content="website">
<meta property="og:image" content="https://aiforalab.com/budongsan/thumbnail.png?v={_V}">
<meta property="og:url" content="https://aiforalab.com/budongsan/">
<meta name="twitter:card" content="summary_large_image">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Gowun+Batang:wght@400;700&family=Gothic+A1:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
 :root{{--navy:#1e3a5f;--navy2:#2f5aa8;--accent:#c0714a;--accent-deep:#a85c38;--paper:#f6f8fb;--paper2:#eef2f8;--ink:#223040;--ink-soft:#5a6b7d;--line:#dbe3ee;--shadow:rgba(30,58,95,.14)}}
 *{{box-sizing:border-box;margin:0;padding:0}}
 body{{background:radial-gradient(circle at 15% 6%,rgba(47,90,168,.07),transparent 42%),radial-gradient(circle at 88% 90%,rgba(192,113,74,.08),transparent 45%),var(--paper);color:var(--ink);font-family:'Gothic A1',sans-serif;line-height:1.72;min-height:100vh}}
 .wrap{{max-width:900px;margin:0 auto;padding:60px 20px 90px}}
 .cover{{text-align:center;margin-bottom:40px}}
 .cover .eyebrow{{font-size:1rem;letter-spacing:.3em;color:var(--accent-deep);font-weight:700;margin-bottom:16px}}
 .cover h1{{font-family:'Gowun Batang',serif;font-weight:700;font-size:clamp(2rem,6vw,3.3rem);line-height:1.2;margin-bottom:14px}}
 .cover h1 .accent{{color:var(--accent-deep)}}
 .cover .sub{{font-size:1.12rem;color:var(--ink-soft)}} .cover .meta{{margin-top:16px;font-size:.92rem;color:var(--ink-soft)}} .cover .meta b{{color:var(--navy2)}}
 .intro{{background:linear-gradient(180deg,#fff,var(--paper));border:1px solid var(--line);border-radius:20px;padding:26px 30px;margin-bottom:16px;box-shadow:0 16px 34px -24px var(--shadow)}}
 .intro h2{{font-family:'Gowun Batang',serif;font-size:1.3rem;margin-bottom:8px;color:var(--navy)}}
 .statrow{{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:26px 0 8px}}
 .statbox{{background:#fff;border:1px solid var(--line);border-top:4px solid var(--navy2);border-radius:14px;padding:18px 16px;text-align:center;box-shadow:0 14px 30px -24px var(--shadow)}}
 .statbox.a{{border-top-color:var(--accent)}} .statbox .n{{font-family:'Gowun Batang',serif;font-weight:700;font-size:1.6rem;color:var(--navy)}} .statbox.a .n{{color:var(--accent-deep)}} .statbox .l{{font-size:.84rem;color:var(--ink-soft);margin-top:4px}}
 .sec-title{{font-family:'Gowun Batang',serif;font-size:1.5rem;margin:46px 0 14px;padding-left:14px;border-left:5px solid var(--accent)}}
 .panel{{background:#fff;border:1px solid var(--line);border-radius:18px;padding:24px 26px;margin-bottom:18px;box-shadow:0 14px 30px -26px var(--shadow)}}
 .panel h3{{font-family:'Gowun Batang',serif;font-size:1.2rem;color:var(--navy);margin-bottom:8px}} .panel p{{font-size:.98rem;margin-bottom:8px}}
 .panel img{{width:100%;height:auto;border:1px solid var(--line);border-radius:12px;margin-top:12px}}
 table.t{{width:100%;border-collapse:collapse;margin-top:12px;font-size:.92rem}}
 table.t th,table.t td{{border:1px solid var(--line);padding:7px 9px;text-align:center}} table.t th{{background:var(--navy);color:#fff}} table.t td:first-child{{text-align:left}} table.t tr:nth-child(even) td{{background:var(--paper)}}
 .reg{{color:var(--accent-deep);font-weight:700}} .ok{{color:#2e7d5b;font-weight:700}}
 .dl{{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}} .dl a{{text-decoration:none;background:var(--paper2);border:1px solid var(--line);border-radius:999px;padding:8px 18px;font-weight:700;font-size:.92rem;color:var(--ink)}} .dl a.view{{background:var(--navy2);color:#fff}}
 .note{{margin-top:40px;text-align:center;font-size:.82rem;color:var(--ink-soft);border-top:1px dashed var(--line);padding-top:20px}}
 @media(max-width:600px){{.statrow{{grid-template-columns:1fr}}}}
</style></head><body>
<nav style="position:fixed;top:10px;left:10px;z-index:99999;display:flex;gap:6px">
 <a href="https://aiforalab.com/" style="background:rgba(30,58,95,.9);color:#fff;text-decoration:none;border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700">🏠 AIForALab</a></nav>
<div class="wrap">
 <header class="cover">
  <div class="eyebrow">부동산 공시가격 연구 · 전국</div>
  <h1>공시가격은 시장가치를<br><span class="accent">공정하게</span> 반영하는가</h1>
  <p class="sub">실거래가 대비 공시가격의 현실화율과 수직형평성(역진성) — 전국 분석</p>
  <div class="meta">전국 · 아파트 · 연립다세대 · <b>2019–2024</b> · 유효표본 <b>{s['N']:,}건</b></div>
 </header>
 <section class="intro"><h2>왜 이 연구가 중요한가</h2>
  <p>공시가격은 재산세·종합부동산세 등 보유세와 건강보험료·복지수급 판정의 기준입니다. 공시가격이 시장가치를 <b>정확히(현실화율)</b>·<b>공정하게(수직형평성)</b> 반영하는지는 조세 정의와 직결됩니다. 본 연구는 국토교통부 실거래가와 공동주택 공시가격을 필지(PNU)·전용면적·연도 단위로 결합해 <b>전국</b> 규모로 이를 실증하고, 지역별 형평성 격차를 분석합니다.</p></section>
 <div class="statrow">
  <div class="statbox"><div class="n">{pct(apt['med'])} / {pct(rh['med'])}</div><div class="l">현실화율 중앙값<br>(아파트 / 연립다세대)</div></div>
  <div class="statbox a"><div class="n">PRB {rh['prb']:+.3f}</div><div class="l">연립다세대 역진성<br>(음수=역진)</div></div>
  <div class="statbox"><div class="n">PRB {apt['prb']:+.3f}</div><div class="l">아파트 수직형평성</div></div>
 </div>
 <div class="sec-title">1 · 현실화율 수준과 시계열 추이 (전국)</div>
 <div class="panel"><h3>전국 현실화율 — 아파트 {pct(apt['med'])}, 연립다세대 {pct(rh['med'])}</h3>
  <p>전국 유효표본 {s['N']:,}건 기준 현실화율 중앙값은 아파트 {pct(apt['med'])}, 연립다세대 {pct(rh['med'])}이다. 연도별 추이는 아래와 같다.</p>
  <img src="연도별_유형별.png" alt="연도별 현실화율 추이 (전국)"></div>
 <div class="sec-title">2 · 수직형평성(역진성) 검정 (전국)</div>
 <div class="panel"><h3>IAAO 수직형평성 지표</h3>
  <table class="t"><tr><th>유형</th><th>N</th><th>중앙 현실화율</th><th>COD</th><th>PRD</th><th>PRB</th><th>판정</th></tr>
   <tr><td>아파트</td><td>{apt['N']:,}</td><td>{apt['med']:.3f}</td><td>{apt['cod']:.1f}</td><td>{apt['prd']:.3f}</td><td>{apt['prb']:+.3f}</td><td class="{'reg' if apt['prb']<-0.05 else 'ok'}">{'역진' if apt['prb']<-0.05 else '형평'}</td></tr>
   <tr><td>연립다세대</td><td>{rh['N']:,}</td><td>{rh['med']:.3f}</td><td>{rh['cod']:.1f}</td><td>{rh['prd']:.3f}</td><td>{rh['prb']:+.3f}</td><td class="{'reg' if rh['prb']<-0.05 else 'ok'}">{'역진' if rh['prb']<-0.05 else '형평'}</td></tr></table>
  <p style="margin-top:10px">회귀분석 ln(실거래가) 계수: 아파트 {rp(reg_apt[0])}(t={reg_apt[1]}), 연립다세대 {rp(reg_rh[0])}(t={reg_rh[1]}) — 음수일수록 역진.</p>
  <img src="가격대별_유형별.png" alt="가격대별 현실화율 (전국)"></div>
 <div class="sec-title">3 · 지역층화 — 어디가 더 역진적인가</div>
 <div class="panel"><h3>권역·시도별 형평성 격차</h3>
  <p>지역별로 현실화율과 역진성(PRB)이 다르다. 아래는 권역별 PRB와, 아파트 기준 시도별 현실화율이다.</p>
  <img src="권역별_PRB.png" alt="권역별 PRB">
  <img src="시도별_아파트.png" alt="시도별 아파트 현실화율">
  <table class="t" style="margin-top:14px"><tr><th>시도(아파트)</th><th>중앙 현실화율</th><th>PRB</th><th>N</th></tr>{sido_rows}</table>
  <p style="margin-top:8px;font-size:.86rem;color:var(--ink-soft)">※ PRB가 음수일수록(고가 저평가) 역진적. 상위 8개 시도(역진 순).</p></div>
 <div class="sec-title">자료 · 방법 · 내려받기</div>
 <div class="panel">
  <p>국토교통부 실거래가(매매) + 공동주택 공시가격(VWorld)을 PNU·전용면적·거래연도로 결합. 현실화율=공시가격/실거래가. 수직형평성은 IAAO(COD·PRD·PRB)·회귀로 검정.</p>
  <div class="dl"><a class="view" href="분석_현실화율_전국_요약.xlsx">📊 전국 요약표 (Excel)</a></div></div>
 <div class="note">자료: 국토교통부 실거래가 · 국토교통부 공간정보(VWorld) 공동주택 공시가격 · 분석기간 2019–2024(전국)<br>※ 교육·연구·정책 소통용. 수치는 표본·기준시점에 따라 달라질 수 있습니다. · 연구: 박대근(차의과학대)</div>
</div></body></html>"""
    (out / "index.html").write_text(html, encoding="utf-8")
    log("  index.html 생성")


_V = "nat1"


# ---------------------------------------------------------------- 배포
def deploy(out: Path):
    import _srv
    cli = _srv.connect(); sftp = cli.open_sftp()
    # budongsan 폴더는 sdkpark 소유 → 직접 SFTP
    for f in ["index.html", "연도별_유형별.png", "가격대별_유형별.png", "시도별_아파트.png",
              "권역별_PRB.png", "thumbnail.png", "분석_현실화율_전국_요약.xlsx"]:
        p = out / f
        if p.exists():
            sftp.put(str(p), f"/var/www/html/budongsan/{f}"); sftp.chmod(f"/var/www/html/budongsan/{f}", 0o644)
    # 카드 webp는 apache 소유 폴더 → 임시 후 sudo cp
    sftp.put(str(out / "budongsan.webp"), "/var/www/html/budongsan/_t.webp")
    sftp.close()
    for cmd in ["sudo cp /var/www/html/budongsan/_t.webp /var/www/html/lab/thumbs/budongsan.webp",
                "sudo chown apache:apache /var/www/html/lab/thumbs/budongsan.webp",
                "sudo chmod 644 /var/www/html/lab/thumbs/budongsan.webp",
                "rm -f /var/www/html/budongsan/_t.webp"]:
        _srv.run(cli, cmd)
    out_code, _ = _srv.run(cli, "curl -s -o /dev/null -w '%{http_code}' https://aiforalab.com/budongsan/")
    cli.close()
    log(f"  배포 완료, HTTP {out_code.strip()}")


def main(do_deploy=True):
    log("전국 포털 업데이트 시작")
    j = build_joined()
    if j.empty:
        log("[중단] 결합 결과 없음"); return False
    log(f"결합 표본 {len(j):,}")
    s = analyze(j)
    if not sanity_ok(s):
        log("[중단] sanity 게이트 실패 — 배포 안 함"); return False
    make_charts(j, BUILD); make_thumbs(s, BUILD); make_excel(j, s, BUILD); build_page(s, BUILD)
    if do_deploy:
        deploy(BUILD)
        DONE_MARK.write_text(__import__("time").strftime("%Y-%m-%d %H:%M:%S"), encoding="utf-8")
        log("✅ 전국 포털 배포 완료")
    else:
        log("(검증 모드: 배포 생략) 빌드 완료 → " + str(BUILD))
    return True


if __name__ == "__main__":
    dep = "--no-deploy" not in sys.argv
    try:
        main(do_deploy=dep)
    except Exception:
        log("[치명오류]\n" + traceback.format_exc())
