"""전북특별자치도 14개 시·군 + 제주특별자치도 2개 시 Phase 1 일괄 처리.

매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md
출력: 각 폴더의 {기관명}.xlsx (D~K열)

파서 매핑은 _probe_jeonbuk*.py 탐색 결과 기반.
"""
import json
import os
import re
import shutil
import ssl
import sys
import warnings
from copy import copy
from urllib.parse import urljoin

import openpyxl
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context
from bs4 import BeautifulSoup
from openpyxl.styles import Alignment, Font

warnings.filterwarnings('ignore')

UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
H = {'User-Agent': UA}
ROOT = r'D:\01.프로젝트\DB수집'
TEMPLATE = ROOT + r'\자료_취합_예시.xlsx'


class WeakSSLAdapter(HTTPAdapter):
    def init_poolmanager(self, *args, **kwargs):
        ctx = create_urllib3_context()
        ctx.set_ciphers('DEFAULT@SECLEVEL=0')
        ctx.options |= 0x4
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        kwargs['ssl_context'] = ctx
        return super().init_poolmanager(*args, **kwargs)


def make_session(weak_ssl=False):
    s = requests.Session()
    s.headers.update(H)
    if weak_ssl:
        s.mount('https://', WeakSSLAdapter())
    return s


def fetch_html(url, session=None, timeout=25):
    s = session or requests.Session()
    if not session:
        s.headers.update(H)
    r = s.get(url, timeout=timeout, verify=False, allow_redirects=True)
    meta = re.search(rb'<meta[^>]*charset=["\']?\s*([\w-]+)', r.content[:4096], re.I)
    r.encoding = meta.group(1).decode('ascii', errors='ignore') if meta else r.apparent_encoding
    return r.text


def clean_text(s):
    return re.sub(r'\s+', ' ', s or '').strip().replace('\xa0', '').lstrip('-').strip()


def extract_href(a):
    if a is None:
        return ''
    href = (a.get('href') or '').strip()
    if not href or href.startswith('#') or href.lower().startswith('javascript:'):
        return ''
    return href


COLS = 'DEFGHIJ'


def rows_from_paths(tmp):
    """[{'path':[(text,href)...], 'href':..}] → D~J dict 행 리스트."""
    rows = []
    for item in tmp:
        p = item['path']
        row = {c: '' for c in COLS}
        row['href'] = item['href']
        for i, (t, _) in enumerate(p):
            row[COLS[i] if i < len(COLS) else 'J'] = t
        rows.append(row)
    return rows


def walk_ul(ul, base_path, out, recursive_li=True):
    """ul > li > a (+ 중첩 ul) 재귀. 망가진 마크업(li 안 li)도 허용."""
    for li in ul.find_all('li', recursive=False):
        a = li.find('a', recursive=False)
        if not a:
            # div>a 형태(전주) 허용
            d = li.find('div', recursive=False)
            a = d.find('a', recursive=False) if d else None
        if not a:
            continue
        text = clean_text(a.get_text())
        href = extract_href(a)
        path = base_path + [(text, href)]
        if not text:
            continue
        # 자식 ul (정상) 또는 li 직접 중첩(망가진 마크업)
        child_uls = li.find_all('ul', recursive=False)
        child_lis = [c for c in li.find_all('li', recursive=False)]
        if child_uls:
            out.append({'path': list(path), 'href': href})
            for cul in child_uls:
                walk_ul(cul, path, out)
        elif recursive_li and child_lis:
            out.append({'path': list(path), 'href': href})
            for cli in child_lis:
                # cli를 단일 li로 감싼 가짜 ul처럼 처리
                sub = a.find_parent()  # not used
                _walk_single_li(cli, path, out)
        else:
            out.append({'path': list(path), 'href': href})


def _walk_single_li(li, base_path, out):
    a = li.find('a', recursive=False)
    if not a:
        d = li.find('div', recursive=False)
        a = d.find('a', recursive=False) if d else None
    if not a:
        return
    text = clean_text(a.get_text())
    href = extract_href(a)
    path = base_path + [(text, href)]
    if not text:
        return
    child_uls = li.find_all('ul', recursive=False)
    child_lis = li.find_all('li', recursive=False)
    if child_uls:
        out.append({'path': list(path), 'href': href})
        for cul in child_uls:
            walk_ul(cul, path, out)
    elif child_lis:
        out.append({'path': list(path), 'href': href})
        for cli in child_lis:
            _walk_single_li(cli, path, out)
    else:
        out.append({'path': list(path), 'href': href})


# ================================================================
# 파서들
# ================================================================

def parse_menu_div(soup, base):
    """고창/김제/임실: div.sitemap > div.menuN > h4>a (D) + div > ul > li>a (E) + ul (F) 재귀."""
    rows = []
    sm = soup.select_one('div.sitemap')
    if not sm:
        return rows
    for block in sm.find_all('div', recursive=False):
        h4 = block.find('h4')
        D = clean_text(h4.get_text()) if h4 else ''
        if not D:
            continue
        inner = block.find('div', recursive=False)
        ul = inner.find('ul', recursive=False) if inner else block.find('ul', recursive=False)
        if not ul:
            rows.append({'D': D, 'href': '', **{c: '' for c in 'EFGHIJ'}})
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_jeongeup(soup, base):
    """정읍: div.sitemap > div.st_mapNN > p.tit>a (D) + ul > li > b>a (E) + ul > li>a (F)."""
    rows = []
    sm = soup.select_one('div.sitemap')
    if not sm:
        return rows
    for block in sm.find_all('div', recursive=False):
        ptit = block.find('p', class_='tit')
        D = clean_text(ptit.get_text()) if ptit else ''
        if not D:
            continue
        ul = block.find('ul', recursive=False)
        if not ul:
            rows.append({'D': D, 'href': '', **{c: '' for c in 'EFGHIJ'}})
            continue
        for li in ul.find_all('li', recursive=False):
            b = li.find('b', recursive=False)
            b_a = b.find('a') if b else li.find('a', recursive=False)
            E = clean_text(b_a.get_text()) if b_a else ''
            E_href = extract_href(b_a) if b_a else ''
            sub = li.find('ul', recursive=False)
            if not sub:
                rows.append({'D': D, 'E': E, 'href': E_href, **{c: '' for c in 'FGHIJ'}})
                continue
            rows.append({'D': D, 'E': E, 'href': E_href, **{c: '' for c in 'FGHIJ'}})
            tmp = []
            walk_ul(sub, [], tmp)
            for r in rows_from_paths(tmp):
                rows.append({'D': D, 'E': E, 'F': r.get('D', ''), 'G': r.get('E', ''),
                             'H': r.get('F', ''), 'I': r.get('G', ''), 'J': r.get('H', ''),
                             'href': r.get('href', '')})
    return rows


def parse_group_sitemap(soup, base):
    """남원/익산: div.sitemap_group (여러개) > h4.title (D) + ul.sitemap_2dep > li>a (E) + ul (F) 재귀."""
    rows = []
    groups = soup.select('div.sitemap_group')
    for g in groups:
        h4 = g.find('h4', class_='title') or g.find('h4')
        D = clean_text(h4.get_text()) if h4 else ''
        if not D:
            continue
        ul = g.find('ul', class_='sitemap_2dep') or g.find('ul', recursive=False)
        if not ul:
            rows.append({'D': D, 'href': '', **{c: '' for c in 'EFGHIJ'}})
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_namwon(soup, base):
    """남원: div.sitemap > (h4 (D) + ul (E/F 재귀)) 형제 반복."""
    rows = []
    sm = soup.select_one('div.sitemap')
    if not sm:
        return rows
    curD = ''
    for child in sm.find_all(['h4', 'ul'], recursive=False):
        if child.name == 'h4':
            curD = clean_text(child.get_text())
        elif child.name == 'ul' and curD:
            tmp = []
            walk_ul(child, [], tmp)
            for r in rows_from_paths(tmp):
                rows.append({'D': curD, 'E': r.get('D', ''), 'F': r.get('E', ''),
                             'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                             'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_muju(soup, base):
    """무주: div#sitemap > div.sitemapN > (h4.sNN>a (D) + ul>li>a (E)) 형제 반복."""
    rows = []
    cont = soup.select_one('div#sitemap')
    if not cont:
        return rows
    for box in cont.find_all('div', recursive=False):
        curD = ''
        for child in box.find_all(['h4', 'ul'], recursive=False):
            if child.name == 'h4':
                a = child.find('a')
                curD = clean_text(a.get_text() if a else child.get_text())
            elif child.name == 'ul' and curD:
                for li in child.find_all('li', recursive=False):
                    a = li.find('a', recursive=False)
                    if not a:
                        continue
                    E = clean_text(a.get_text())
                    rows.append({'D': curD, 'E': E, 'href': extract_href(a),
                                 **{c: '' for c in 'FGHIJ'}})
    return rows


def parse_buan(soup, base):
    """부안: nav#onmenu > ul > li > div.depth_box > div.depth_boxcon > strong (D) + ul>li>a (E) + ul (F)."""
    rows = []
    nav = soup.select_one('nav#onmenu')
    if not nav:
        return rows
    top = nav.find('ul')
    if not top:
        return rows
    for li in top.find_all('li', recursive=False):
        box = li.find('div', class_='depth_boxcon')
        if not box:
            continue
        strong = box.find('strong')
        a0 = li.find('a', recursive=False)
        D = clean_text(strong.get_text()) if strong else clean_text(a0.get_text() if a0 else '')
        if not D:
            continue
        ul = box.find('ul')
        if not ul:
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_sunchang(soup, base):
    """순창: ul.gnb > li > a (D) + div.box ul.gnb_2dep > li>a (E) + ul.gnb_3dep (F) + ul.gnb_4dep (G)."""
    rows = []
    gnb = soup.select_one('ul.gnb')
    if not gnb:
        return rows
    for li in gnb.find_all('li', recursive=False):
        a0 = li.find('a', recursive=False)
        D = clean_text(a0.get_text()) if a0 else ''
        if not D:
            continue
        ul2 = li.find('ul', class_='gnb_2dep')
        if not ul2:
            rows.append({'D': D, 'href': extract_href(a0), **{c: '' for c in 'EFGHIJ'}})
            continue
        for li2 in ul2.find_all('li', recursive=False):
            a2 = li2.find('a', recursive=False)
            if not a2:
                continue
            E = clean_text(a2.get_text())
            E_href = extract_href(a2)
            ul3 = li2.find('ul', class_='gnb_3dep')
            if not ul3:
                rows.append({'D': D, 'E': E, 'href': E_href, **{c: '' for c in 'FGHIJ'}})
                continue
            rows.append({'D': D, 'E': E, 'href': E_href, **{c: '' for c in 'FGHIJ'}})
            for li3 in ul3.find_all('li', recursive=False):
                a3 = li3.find('a', recursive=False)
                if not a3:
                    continue
                F = clean_text(a3.get_text())
                F_href = extract_href(a3)
                ul4 = li3.find('ul', class_='gnb_4dep')
                if not ul4:
                    rows.append({'D': D, 'E': E, 'F': F, 'href': F_href, **{c: '' for c in 'GHIJ'}})
                    continue
                rows.append({'D': D, 'E': E, 'F': F, 'href': F_href, **{c: '' for c in 'GHIJ'}})
                for li4 in ul4.find_all('li', recursive=False):
                    a4 = li4.find('a', recursive=False)
                    if not a4:
                        continue
                    rows.append({'D': D, 'E': E, 'F': F, 'G': clean_text(a4.get_text()),
                                 'href': extract_href(a4), **{c: '' for c in 'HIJ'}})
    return rows


def parse_jangsu(soup, base):
    """장수: div.sitemap > ul.siteMapList > li.sml_1depth > a.sml_1depthBtn (D) + ul.sml_2depthList > li>a (E) + ul 재귀."""
    rows = []
    ul = soup.select_one('ul.siteMapList')
    if not ul:
        return rows
    for li in ul.find_all('li', recursive=False):
        a0 = li.find('a', recursive=False)
        D = clean_text(a0.get_text()) if a0 else ''
        if not D:
            continue
        ul2 = li.find('ul', recursive=False)
        if not ul2:
            rows.append({'D': D, 'href': extract_href(a0), **{c: '' for c in 'EFGHIJ'}})
            continue
        tmp = []
        walk_ul(ul2, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_jeonju(soup, base):
    """전주: div.sitemap_Warp (여러개) > h4.title_h4 (D) + ul > li > div>a (E) + ul/li (F) 망가진 마크업."""
    rows = []
    warps = soup.select('div.sitemap_Warp')
    for w in warps:
        h4 = w.find('h4', class_='title_h4') or w.find('h4')
        D = clean_text(h4.get_text()) if h4 else ''
        if not D:
            continue
        ul = w.find('ul', recursive=False)
        if not ul:
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_jinan(soup, base):
    """진안: div.sitemap > dl > dt (D) + dd > ul > li>a (E) + ul (F) 재귀."""
    rows = []
    sm = soup.select_one('div.sitemap')
    if not sm:
        return rows
    for dl in sm.find_all('dl', recursive=False):
        dt = dl.find('dt')
        D = clean_text(dt.get_text()) if dt else ''
        if not D:
            continue
        dd = dl.find('dd')
        ul = dd.find('ul', recursive=False) if dd else None
        if not ul:
            rows.append({'D': D, 'href': '', **{c: '' for c in 'EFGHIJ'}})
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_box_heading(soup, base):
    """서귀포/제주시: div.sitemap > div.sitemapBox|sitemap_menu > h3|h4 (D) + ul > li>a (E)."""
    rows = []
    sm = soup.select_one('div.sitemap')
    if not sm:
        return rows
    for box in sm.find_all('div', recursive=False):
        h = box.find(['h3', 'h4'])
        D = clean_text(h.get_text()) if h else ''
        if not D:
            continue
        ul = box.find('ul')
        if not ul:
            rows.append({'D': D, 'href': '', **{c: '' for c in 'EFGHIJ'}})
            continue
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


def parse_wanju_json(soup, base):
    """완주: Playwright로 추출한 _wanju_menu.json 로드."""
    rows = []
    data = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), '_wanju_menu.json'), encoding='utf-8'))
    for r in data:
        rows.append({'D': r.get('D', ''), 'E': r.get('E', ''), 'F': r.get('F', ''),
                     'href': r.get('href', ''), 'G': '', 'H': '', 'I': '', 'J': ''})
    return rows


# ================================================================
# 사이트 설정
# ================================================================
def jb(i, name, folder, base, sitemap, parser, domain, weak=False, fetch_kind='html'):
    return {'idx': i, 'name': name, 'base': base, 'sitemap': sitemap,
            'sheet': f'{i:02d}_{name}', 'parser': parser, 'domain': domain,
            'folder': fr'{ROOT}\작업파일\광역_사이트맵\전북특별자치도\{i}.{name}', 'weak_ssl': weak,
            'fetch_kind': fetch_kind}


def jj(i, name, base, sitemap, parser, domain):
    return {'idx': i, 'name': name, 'base': base, 'sitemap': sitemap,
            'sheet': f'{i:02d}_{name}', 'parser': parser, 'domain': domain,
            'folder': fr'{ROOT}\작업파일\광역_사이트맵\제주특별자치도\{i}.{name}', 'weak_ssl': False,
            'fetch_kind': 'html'}


SITES = [
    jb(1, '고창군', '', 'https://www.gochang.go.kr',
       'https://www.gochang.go.kr/index.gochang?menuCd=DOM_000000106006000000', parse_menu_div, 'gochang.go.kr'),
    jb(2, '군산시', '', 'https://www.gunsan.go.kr',
       'https://www.gunsan.go.kr/main', parse_gunsan if False else None, 'gunsan.go.kr'),
    jb(3, '김제시', '', 'https://www.gimje.go.kr',
       'https://www.gimje.go.kr/index.gimje?menuCd=DOM_000000107002000000', parse_menu_div, 'gimje.go.kr'),
    jb(4, '남원시', '', 'https://www.namwon.go.kr',
       'https://www.namwon.go.kr/index.do?menuUid=ff8080818f2717db018f277767500088', parse_namwon, 'namwon.go.kr'),
    jb(5, '무주군', '', 'https://www.muju.go.kr',
       'https://www.muju.go.kr/index.9is?contentUid=ff8080816db80238016dc8e98fa10ef6', parse_muju, 'muju.go.kr'),
    jb(6, '부안군', '', 'https://www.buan.go.kr',
       'https://www.buan.go.kr/index.buan?contentsSid=1', parse_buan, 'buan.go.kr'),
    jb(7, '순창군', '', 'https://www.sunchang.go.kr',
       'https://www.sunchang.go.kr/', parse_sunchang, 'sunchang.go.kr'),
    jb(8, '완주군', '', 'https://www.wanju.go.kr',
       'JSON', parse_wanju_json, 'wanju.go.kr', fetch_kind='json'),
    jb(9, '익산시', '', 'https://www.iksan.go.kr',
       'https://www.iksan.go.kr/index.do?menuUid=ff80808199f0d11c019a041b8e35174a', parse_group_sitemap, 'iksan.go.kr'),
    jb(10, '임실군', '', 'https://www.imsil.go.kr',
       'https://www.imsil.go.kr/index.imsil?menuCd=DOM_000000107003000000', parse_menu_div, 'imsil.go.kr'),
    jb(11, '장수군', '', 'https://www.jangsu.go.kr',
       'https://www.jangsu.go.kr/index.jangsu?menuCd=DOM_000000107001000000', parse_jangsu, 'jangsu.go.kr'),
    jb(12, '전주시', '', 'https://www.jeonju.go.kr',
       'https://www.jeonju.go.kr/index.9is?contentUid=ff8080818c7c2e8e018c7fd67b9a03b6', parse_jeonju, 'jeonju.go.kr', fetch_kind='html5'),
    jb(13, '정읍시', '', 'https://www.jeongeup.go.kr',
       'https://www.jeongeup.go.kr/index.jeongeup?menuCd=DOM_000000106002000000', parse_jeongeup, 'jeongeup.go.kr'),
    jb(14, '진안군', '', 'https://www.jinan.go.kr',
       'https://www.jinan.go.kr/index.jinan?menuCd=DOM_000000110002000000', parse_jinan, 'jinan.go.kr'),
    jj(1, '서귀포시', 'https://www.seogwipo.go.kr',
       'https://www.seogwipo.go.kr/help/sitemap.htm', parse_box_heading, 'seogwipo.go.kr'),
    jj(2, '제주시', 'https://www.jejusi.go.kr',
       'https://www.jejusi.go.kr/guide/sitemap.do', parse_box_heading, 'jejusi.go.kr'),
]


def parse_gunsan(soup, base):
    """군산: div#all_pcmenu > div.allmenubox > a.Bmenu (D) + div.allmw ul.sub_pcmenu > li>a (E) + ul.dep3 (F) 재귀."""
    rows = []
    pc = soup.select_one('div#all_pcmenu')
    if not pc:
        return rows
    for box in pc.select('div.allmenubox'):
        bm = box.find('a', class_='Bmenu') or box.find('a')
        D = clean_text(bm.get_text()) if bm else ''
        if not D:
            continue
        ul = box.find('ul', class_='sub_pcmenu')
        if not ul:
            rows.append({'D': D, 'href': extract_href(bm), **{c: '' for c in 'EFGHIJ'}})
            continue
        # ★ ul.dep4 = 모바일 아코디언 잔재(F형제 전체를 '- '접두로 복제). 진짜 자식 아님 → 제거.
        #   안 지우면 각 F의 G자식으로 재귀돼 카르테시안 폭발(2026-05-31 군산 208행 버그 수정).
        for d4 in ul.select('ul.dep4'):
            d4.decompose()
        tmp = []
        walk_ul(ul, [], tmp)
        for r in rows_from_paths(tmp):
            rows.append({'D': D, 'E': r.get('D', ''), 'F': r.get('E', ''),
                         'G': r.get('F', ''), 'H': r.get('G', ''), 'I': r.get('H', ''),
                         'J': r.get('I', ''), 'href': r.get('href', '')})
    return rows


# 군산 파서 바인딩(전방참조 해결)
for _s in SITES:
    if _s['name'] == '군산시':
        _s['parser'] = parse_gunsan


# ================================================================
# 엑셀 생성 (충북 스크립트와 동일 로직)
# ================================================================
def write_excel(site, raw_rows):
    name = site['name']
    base = site['base']
    domain = site['domain']
    output = f"{site['folder']}\\{os.path.basename(os.path.dirname(site['folder']))}_{name}.xlsx"

    def abs_url(href):
        if not href:
            return ''
        href = href.strip()
        if href.startswith(('javascript:', '#')):
            return ''
        if href.startswith(('http://', 'https://')):
            return href
        return urljoin(base + '/', href)

    def is_external(url):
        return url.startswith(('http://', 'https://')) and domain not in url

    final_rows = []
    i = 0
    removed = 0
    while i < len(raw_rows):
        row = raw_rows[i]
        if (i + 1 < len(raw_rows)
                and row.get('G', '') == ''
                and raw_rows[i + 1].get('D') == row.get('D')
                and raw_rows[i + 1].get('E') == row.get('E')
                and raw_rows[i + 1].get('F') == row.get('F')
                and raw_rows[i + 1].get('G', '') != ''
                and raw_rows[i + 1].get('href') == row.get('href')):
            removed += 1
            i += 1
            continue
        final_rows.append(row)
        i += 1

    print(f'  [{name}] 원본 {len(raw_rows)} → 중복 {removed} → 최종 {len(final_rows)}')
    if not final_rows:
        print(f'  [{name}] !! 행 0개 — 파서 점검 필요. 엑셀 생성 스킵.')
        return False

    shutil.copy(TEMPLATE, output)
    wb = openpyxl.load_workbook(output)
    ws = wb.active
    ws.title = site['sheet']

    HEADER = {'B1:R1', 'S1:W1', 'Y1:AA1'}
    for rng in [str(m) for m in ws.merged_cells.ranges if str(m) not in HEADER]:
        ws.unmerge_cells(rng)
    for row in ws.iter_rows(min_row=3, max_row=ws.max_row, min_col=1, max_col=ws.max_column):
        for cell in row:
            cell.value = None

    START = 3
    template_r = 3
    cur_max = ws.max_row
    for idx, item in enumerate(final_rows, start=START):
        if idx > cur_max:
            for c in range(1, ws.max_column + 1):
                srcc = ws.cell(template_r, c)
                tgt = ws.cell(idx, c)
                if srcc.has_style:
                    tgt.font = copy(srcc.font)
                    tgt.fill = copy(srcc.fill)
                    tgt.border = copy(srcc.border)
                    tgt.alignment = copy(srcc.alignment)
                    tgt.number_format = srcc.number_format
                    tgt.protection = copy(srcc.protection)
        url = abs_url(item.get('href', ''))
        ws.cell(idx, 2).value = idx - 2
        ws.cell(idx, 3).value = name
        ws.cell(idx, 4).value = item.get('D', '')
        ws.cell(idx, 5).value = item.get('E', '')
        ws.cell(idx, 6).value = item.get('F', '')
        ws.cell(idx, 7).value = item.get('G', '')
        ws.cell(idx, 8).value = item.get('H', '')
        ws.cell(idx, 9).value = item.get('I', '')
        ws.cell(idx, 10).value = item.get('J', '')
        ws.cell(idx, 11).value = url
        if is_external(url):
            ws.cell(idx, 19).value = '외부링크'

    END = START + len(final_rows) - 1
    center = Alignment(horizontal='center', vertical='center', wrap_text=True)
    left = Alignment(horizontal='left', vertical='center', wrap_text=False)

    def merge_runs(col_letter, col_idx, group_cols=()):
        runs = []
        cur_val = ws.cell(START, col_idx).value
        cur_grp = tuple(ws.cell(START, g).value for g in group_cols)
        run_start = START
        for r in range(START + 1, END + 1):
            v = ws.cell(r, col_idx).value
            g = tuple(ws.cell(r, gg).value for gg in group_cols)
            if v == cur_val and g == cur_grp:
                continue
            if cur_val not in (None, '') and r - 1 > run_start:
                runs.append((run_start, r - 1))
            cur_val, cur_grp, run_start = v, g, r
        if cur_val not in (None, '') and END > run_start:
            runs.append((run_start, END))
        for s, e in runs:
            ws.merge_cells(f'{col_letter}{s}:{col_letter}{e}')
            ws.cell(s, col_idx).alignment = center
        return len(runs)

    n_f = merge_runs('F', 6, group_cols=(4, 5))
    n_e = merge_runs('E', 5, group_cols=(4,))
    n_d = merge_runs('D', 4)
    for r in range(START, END + 1):
        for c in (4, 5, 6, 7):
            if ws.cell(r, c).value is not None:
                ws.cell(r, c).alignment = center

    for r in range(1, END + 1):
        ws.row_dimensions[r].height = 15

    link_n = 0
    for r in range(START, END + 1):
        cell = ws.cell(r, 11)
        u = cell.value
        if u and isinstance(u, str) and u.startswith(('http://', 'https://')):
            cell.hyperlink = u
            old = cell.font
            cell.font = Font(name=old.name or '맑은 고딕', size=old.size or 11,
                             bold=old.bold, italic=old.italic,
                             color='0000FF', underline='single')
            cell.alignment = left
            link_n += 1

    wb.save(output)
    ext_n = sum(1 for r in range(START, END + 1) if ws.cell(r, 19).value == '외부링크')
    print(f'  [{name}] 병합 D:{n_d} E:{n_e} F:{n_f} | 외부링크 {ext_n} | K링크 {link_n} → {output}')
    return True


def main():
    targets = sys.argv[1:] if len(sys.argv) > 1 else [s['name'] for s in SITES]
    for site in SITES:
        if site['name'] not in targets:
            continue
        print(f"\n{'='*60}\n[{site['idx']}.{site['name']}] {site['sitemap']}\n{'='*60}")
        try:
            if site['fetch_kind'] == 'json':
                raw_rows = site['parser'](None, site['base'])
            else:
                sess = make_session(weak_ssl=site.get('weak_ssl', False))
                html = fetch_html(site['sitemap'], session=sess)
                engine = 'html5lib' if site.get('fetch_kind') == 'html5' else 'html.parser'
                soup = BeautifulSoup(html, engine)
                raw_rows = site['parser'](soup, site['base'])
            write_excel(site, raw_rows)
        except Exception as e:
            print(f'  [{site["name"]}] !! 실패: {e}')
            import traceback
            traceback.print_exc()


if __name__ == '__main__':
    main()
