"""충청북도 11개 시·군 Phase 1 일괄 처리.

매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md
출력: 각 폴더의 {기관명}.xlsx
"""
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}
TEMPLATE = r'D:\01.프로젝트\DB수집\자료_취합_예시.xlsx'


class WeakSSLAdapter(HTTPAdapter):
    """레거시 SSL 핸드셰이크 허용 (영동군 등)."""
    def init_poolmanager(self, *args, **kwargs):
        ctx = create_urllib3_context()
        ctx.set_ciphers('DEFAULT@SECLEVEL=0')
        ctx.options |= 0x4  # OP_LEGACY_SERVER_CONNECT
        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=20, force_encoding=None):
    s = session or requests.Session()
    if not session:
        s.headers.update(H)
    r = s.get(url, timeout=timeout, verify=False, allow_redirects=True)
    if force_encoding:
        r.encoding = force_encoding
    else:
        # 메타 태그에서 charset 시도 → 없으면 apparent_encoding
        meta_charset = re.search(rb'<meta[^>]*charset=["\']?\s*([\w-]+)', r.content[:4096], re.I)
        if meta_charset:
            r.encoding = meta_charset.group(1).decode('ascii', errors='ignore')
        else:
            r.encoding = r.apparent_encoding
    return r.text


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


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


ENCODE_URI_PAT = re.compile(r"""(?:location\.href|window\.location(?:\.href)?)\s*=\s*encodeURI\(\s*['"]([^'"]+)['"]\s*\)""")
ONCLICK_HREF_PAT = re.compile(r"""(?:location\.href|window\.open)\s*\(?\s*['"]([^'"]+)['"]""")


def extract_href_with_onclick(a):
    if a is None:
        return ''
    href = (a.get('href') or '').strip()
    if href and not href.startswith('#') and not href.lower().startswith('javascript:'):
        return href
    onclick = a.get('onclick', '')
    if onclick:
        m = ENCODE_URI_PAT.search(onclick)
        if m:
            return m.group(1)
        m = ONCLICK_HREF_PAT.search(onclick)
        if m:
            return m.group(1)
    return ''


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


def parse_depth1_chungbuk(soup, base):
    """충북 e-Gov depth1 형: div.depth1 > ul.depth1_list > li.depth1_item > a.depth1_text + div.depth2 > [div.depth2_content|depth2_wrap] > ul.depth2_list."""
    rows = []
    container = soup.select_one('div.depth.depth1, div.depth1')
    if not container:
        return rows
    top_ul = container.find('ul', class_=re.compile(r'depth1?_list'), recursive=False)
    if not top_ul:
        return rows

    def walk_depth(ul, level, base_path, out):
        for li in ul.find_all('li', recursive=False):
            a = li.find('a', class_=re.compile(rf'depth{level}_text'), recursive=False) or li.find('a', recursive=False)
            if not a:
                continue
            text = clean_text(a.get_text())
            href = extract_href(a)
            path = base_path + [(text, href)]
            # Find next depth container — 다양한 wrapper 클래스 지원: _content / _wrap / 없음
            next_div = li.find('div', class_=re.compile(rf'depth\s+depth{level+1}\b'), recursive=False) or \
                       li.find('div', class_=re.compile(rf'\bdepth{level+1}\b'), recursive=False)
            if next_div:
                # wrapper 가 있을 수도, 없을 수도
                next_ul = next_div.find('ul', class_=re.compile(rf'depth{level+1}_list'), recursive=True)
                if next_ul:
                    # 같은 깊이의 첫번째 ul.depth_list (자손 검색이지만 보통 1번 wrapper 안)
                    out.append({'path': list(path), 'href': href})
                    walk_depth(next_ul, level + 1, path, out)
                    continue
            out.append({'path': list(path), 'href': href})

    tmp = []
    walk_depth(top_ul, 1, [], tmp)
    cols = 'DEFGHIJ'
    for item in tmp:
        p = item['path']
        row = {c: '' for c in 'DEFGHIJ'}
        row['href'] = item['href']
        for i, (t, _) in enumerate(p):
            col = cols[i] if i < len(cols) else 'J'
            row[col] = t
        rows.append(row)
    return rows


def parse_danyang_ld(soup, base):
    """단양군: ul#menu_sitemap.ld1 > li.cd1 > a.l1 + div.lb1 > ul.ld2 > li.cd2 > a.l2 + div.lb2 > ul.ld3 > ...

    'menutype_empty' (메인 등) 은 스킵.
    """
    rows = []
    container = soup.select_one('ul#menu_sitemap')
    if not container:
        return rows

    def walk(ul, level, base_path, out):
        for li in ul.find_all('li', recursive=False):
            a = li.find('a', class_=re.compile(rf'\bl{level}\b'), recursive=False) or li.find('a', recursive=False)
            if not a:
                continue
            a_cls = ' '.join(a.get('class', []))
            if 'menutype_empty' in a_cls:
                continue  # 메인 같은 빈 항목
            text = clean_text(a.get_text())
            href = extract_href(a)
            path = base_path + [(text, href)]
            next_div = li.find('div', class_=re.compile(rf'\blb{level}\b'), recursive=False)
            if next_div:
                next_ul = next_div.find('ul', class_=re.compile(rf'\bld{level+1}\b'), recursive=False)
                if next_ul:
                    out.append({'path': list(path), 'href': href})
                    walk(next_ul, level + 1, path, out)
                    continue
            out.append({'path': list(path), 'href': href})

    tmp = []
    walk(container, 1, [], tmp)
    cols = 'DEFGHIJ'
    for item in tmp:
        p = item['path']
        row = {c: '' for c in cols}
        row['href'] = item['href']
        for i, (t, _) in enumerate(p):
            col = cols[i] if i < len(cols) else 'J'
            row[col] = t
        rows.append(row)
    return rows


def parse_dl_dt_dd(soup, base, use_onclick=False):
    """계룡시·홍성군·영동군형: div.sitemap > dl > dt + dd > b > a + ul > li > a (+ ul > li > a)."""
    rows = []
    sm = soup.select_one('div.sitemap[class*=type2]') or soup.select_one('div.sitemap[class*=type1]') or soup.select_one('div.sitemap')
    if not sm:
        return rows
    href_fn = extract_href_with_onclick if use_onclick else extract_href

    def walk(ul, depth, base_path, out):
        for li in ul.find_all('li', recursive=False):
            a = li.find('a', recursive=False)
            if not a:
                continue
            text = clean_text(a.get_text())
            href = href_fn(a)
            path = base_path[:depth] + [(text, href)]
            nested = li.find('ul', recursive=False)
            if nested:
                out.append({'path': list(path), 'href': href})
                walk(nested, depth + 1, path, out)
            else:
                out.append({'path': list(path), 'href': href})

    for dl in sm.find_all('dl', recursive=False):
        dt = dl.find('dt')
        dt_a = dt.find('a') if dt else None
        D = clean_text(dt_a.get_text() if dt_a else (dt.get_text() if dt else ''))
        for dd in dl.find_all('dd', recursive=False):
            b = dd.find('b')
            b_a = b.find('a') if b else None
            E = clean_text(b_a.get_text()) if b_a else ''
            E_href = href_fn(b_a) if b_a else ''
            nested = dd.find('ul', recursive=False)
            if nested:
                tmp = []
                walk(nested, 0, [], tmp)
                for item in tmp:
                    p = item['path']
                    row = {'D': D, 'E': E, 'href': item['href'],
                           'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}
                    for di, (t, _) in enumerate(p):
                        col = 'FGHIJ'[di] if di < 5 else 'J'
                        row[col] = t
                    rows.append(row)
            else:
                rows.append({'D': D, 'E': E, 'href': E_href,
                             'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
    return rows


def parse_yesan_depth(soup, base):
    """예산·청양·증평형: ul.depth1_ul > li > a (D, .th_1st/.th1_lnk) + [div.item >]? ul.depth2_ul > li > a (E) + ul.depth3_ul > li > a (F).

    div.item wrapper 있을수도 없을수도 — 둘 다 지원.
    a 클래스: .th_1st (예산), .th1_lnk (증평) — 첫 번째 a 가져옴.
    """
    rows = []
    container = soup.select_one('ul.depth1_ul')
    if not container:
        return rows
    for top_li in container.find_all('li', recursive=False):
        a1 = top_li.find('a', class_=re.compile(r'th[_]?1?(?:_1st|_lnk)?'), recursive=False) or \
             top_li.find('a', recursive=False)
        if not a1:
            continue
        D = clean_text(a1.get_text())
        # div.item wrapper 있을 수도 없을 수도
        item = top_li.find('div', class_='item', recursive=False)
        d2_ul = (item.find('ul', class_='depth2_ul') if item else None) or \
                top_li.find('ul', class_='depth2_ul', recursive=False)
        if not d2_ul:
            continue
        for d2_li in d2_ul.find_all('li', recursive=False):
            d2_a = d2_li.find('a', recursive=False)
            if not d2_a:
                continue
            E = clean_text(d2_a.get_text())
            E_href = extract_href(d2_a)
            d3_ul = d2_li.find('ul', class_='depth3_ul', recursive=False)
            if not d3_ul:
                rows.append({'D': D, 'E': E, 'href': E_href,
                             'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
                continue
            rows.append({'D': D, 'E': E, 'href': E_href,
                         'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
            for d3_li in d3_ul.find_all('li', recursive=False):
                d3_a = d3_li.find('a', recursive=False)
                if not d3_a:
                    continue
                F = clean_text(d3_a.get_text())
                rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(d3_a),
                             'G': '', 'H': '', 'I': '', 'J': ''})
    return rows


def parse_jincheon_recursive(soup, base):
    """진천군: div.sitemap > ul > li > a + div > ul > li > a + div > ul > ... 재귀."""
    rows = []
    container = soup.select_one('div.sitemap')
    if not container:
        return rows
    top_ul = container.find('ul', recursive=False)
    if not top_ul:
        return rows

    def walk(ul, base_path, out):
        for li in ul.find_all('li', recursive=False):
            a = li.find('a', recursive=False)
            if not a:
                continue
            text = clean_text(a.get_text())
            if not text or text == '메뉴명이 없습니다.':
                continue
            href = extract_href(a)
            path = base_path + [(text, href)]
            next_div = li.find('div', recursive=False)
            if next_div:
                next_ul = next_div.find('ul', recursive=False)
                if next_ul:
                    out.append({'path': list(path), 'href': href})
                    walk(next_ul, path, out)
                    continue
            out.append({'path': list(path), 'href': href})

    tmp = []
    walk(top_ul, [], tmp)
    cols = 'DEFGHIJ'
    for item in tmp:
        p = item['path']
        row = {c: '' for c in cols}
        row['href'] = item['href']
        for i, (t, _) in enumerate(p):
            col = cols[i] if i < len(cols) else 'J'
            row[col] = t
        rows.append(row)
    return rows


def parse_cheongju_sitemap(soup, base):
    """청주시: div#sitemap > div.site_map_col > div.sitemap_box > h3 > a (D) + ul.sm2depth > li > a (E) + ul.sm3depth > li > a (F).

    별도의 '인트로' sitemap_box 는 D만 있고 ul.sm2depth 단순한 외부 링크 묶음 — 그대로 둠.
    """
    rows = []
    container = soup.select_one('div#sitemap')
    if not container:
        return rows
    for box in container.select('div.sitemap_box'):
        h3 = box.find('h3')
        h3_a = h3.find('a') if h3 else None
        D = clean_text(h3_a.get_text() if h3_a else (h3.get_text() if h3 else ''))
        D_href = extract_href(h3_a) if h3_a else ''
        sm2 = box.find('ul', class_='sm2depth')
        if not sm2:
            rows.append({'D': D, 'href': D_href, 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
            continue
        for li2 in sm2.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)
            sm3 = li2.find('ul', class_='sm3depth', recursive=False)
            if not sm3:
                rows.append({'D': D, 'E': E, 'href': E_href,
                             'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
                continue
            rows.append({'D': D, 'E': E, 'href': E_href,
                         'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
            for li3 in sm3.find_all('li', recursive=False):
                a3 = li3.find('a', recursive=False)
                if not a3:
                    continue
                F = clean_text(a3.get_text())
                rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(a3),
                             'G': '', 'H': '', 'I': '', 'J': ''})
    return rows


def parse_chungju_sitemap(soup, base):
    """충주시: div#sitemap > div.site_map_col > div.sitemap_box > h3.h0 > a (D) + ul > li > a.h4 (E) + ul.bu > li > a (F)."""
    rows = []
    container = soup.select_one('div#sitemap')
    if not container:
        return rows
    for box in container.select('div.sitemap_box'):
        h3 = box.find('h3')
        h3_a = h3.find('a') if h3 else None
        D = clean_text(h3_a.get_text() if h3_a else (h3.get_text() if h3 else ''))
        D_href = extract_href(h3_a) if h3_a else ''
        e_ul = box.find('ul', recursive=False)
        if not e_ul:
            rows.append({'D': D, 'href': D_href, 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
            continue
        for e_li in e_ul.find_all('li', recursive=False):
            a_E = e_li.find('a', class_='h4', recursive=False) or e_li.find('a', recursive=False)
            if not a_E:
                continue
            E = clean_text(a_E.get_text())
            E_href = extract_href(a_E)
            f_ul = e_li.find('ul', class_='bu', recursive=False)
            if not f_ul:
                rows.append({'D': D, 'E': E, 'href': E_href,
                             'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
                continue
            rows.append({'D': D, 'E': E, 'href': E_href,
                         'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
            for f_li in f_ul.find_all('li', recursive=False):
                f_a = f_li.find('a', recursive=False)
                if not f_a:
                    continue
                F = clean_text(f_a.get_text())
                rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(f_a),
                             'G': '', 'H': '', 'I': '', 'J': ''})
    return rows


# ================================================================
# 사이트 설정
# ================================================================

SITES = [
    {
        'idx': 1, 'name': '괴산군', 'base': 'https://www.goesan.go.kr',
        'sitemap': 'https://www.goesan.go.kr/www/sitemap.do?key=28',
        'sheet': '01_괴산군', 'parser': parse_depth1_chungbuk,
        'domain': 'goesan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\1.괴산군',
        'weak_ssl': False,
    },
    {
        'idx': 2, 'name': '단양군', 'base': 'https://www.danyang.go.kr',
        'sitemap': 'https://www.danyang.go.kr/dy21/98',
        'sheet': '02_단양군', 'parser': parse_danyang_ld,
        'domain': 'danyang.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\2.단양군',
        'weak_ssl': False,
    },
    {
        # 보은군 — www.boeun.go.kr DNS 차단, apex boeun.go.kr 는 정상(2026-05-30 재확인)
        'idx': 3, 'name': '보은군', 'base': 'https://boeun.go.kr',
        'sitemap': 'https://boeun.go.kr/www/sitemap.do?key=1323',
        'sheet': '03_보은군', 'parser': parse_depth1_chungbuk,
        'domain': 'boeun.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\3.보은군',
        'weak_ssl': False,
    },
    {
        'idx': 4, 'name': '영동군', 'base': 'https://www.yd21.go.kr',
        'sitemap': 'https://www.yd21.go.kr/kr/html/guide/0701.html',
        'sheet': '04_영동군', 'parser': parse_dl_dt_dd,
        'domain': 'yd21.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\4.영동군',
        'weak_ssl': True,
    },
    {
        'idx': 5, 'name': '옥천군', 'base': 'https://www.oc.go.kr',
        'sitemap': 'https://www.oc.go.kr/www/sub.do?key=121',
        'sheet': '05_옥천군', 'parser': parse_depth1_chungbuk,
        'domain': 'oc.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\5.옥천군',
        'weak_ssl': False,
    },
    {
        'idx': 6, 'name': '음성군', 'base': 'https://www.eumseong.go.kr',
        'sitemap': 'https://www.eumseong.go.kr/www/sub.do?key=722',
        'sheet': '06_음성군', 'parser': parse_depth1_chungbuk,
        'domain': 'eumseong.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\6.음성군',
        'weak_ssl': False,
    },
    {
        'idx': 7, 'name': '제천시', 'base': 'https://www.jecheon.go.kr',
        'sitemap': 'https://www.jecheon.go.kr/www/sitemap.do?key=553',
        'sheet': '07_제천시', 'parser': parse_depth1_chungbuk,
        'domain': 'jecheon.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\7.제천시',
        'weak_ssl': False,
    },
    {
        'idx': 8, 'name': '증평군', 'base': 'https://www.jp.go.kr',
        'sitemap': 'https://www.jp.go.kr/kor/sitemap_11.do',
        'sheet': '08_증평군', 'parser': parse_yesan_depth,
        'domain': 'jp.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\8.증평군',
        'weak_ssl': False,
    },
    {
        'idx': 9, 'name': '진천군', 'base': 'https://www.jincheon.go.kr',
        'sitemap': 'https://www.jincheon.go.kr/home/sub.do?menukey=445',
        'sheet': '09_진천군', 'parser': parse_jincheon_recursive,
        'domain': 'jincheon.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\9.진천군',
        'weak_ssl': False,
    },
    {
        'idx': 10, 'name': '청주시', 'base': 'https://www.cheongju.go.kr',
        'sitemap': 'https://www.cheongju.go.kr/www/sitemap.do?key=589',
        'sheet': '10_청주시', 'parser': parse_cheongju_sitemap,
        'domain': 'cheongju.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\10.청주시',
        'weak_ssl': False,
    },
    {
        'idx': 11, 'name': '충주시', 'base': 'https://www.chungju.go.kr',
        'sitemap': 'https://www.chungju.go.kr/www/sub.do?key=692',
        'sheet': '11_충주시', 'parser': parse_chungju_sitemap,
        'domain': 'chungju.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\11.충주시',
        'weak_ssl': False,
    },
]


# ================================================================
# 엑셀 생성 (공통)
# ================================================================


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

    # 부모-자식 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)

    # 병합 순서 F→E→D (D를 먼저 병합하면 2행부터 D=None이 되어 E 그룹키가 깨짐)
    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:
            sess = make_session(weak_ssl=site.get('weak_ssl', False))
            html = fetch_html(site['sitemap'], session=sess)
            soup = BeautifulSoup(html, 'html.parser')
            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()
