"""충청남도 11개 시·군 Phase 1 일괄 처리. 매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md 출력: 각 폴더의 {기관명}.xlsx """ import os import re import shutil import sys import warnings from copy import copy from urllib.parse import urljoin import openpyxl import requests 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' # ================================================================ # 공통 유틸 # ================================================================ def fetch_html(url, timeout=20): r = requests.get(url, headers=H, timeout=timeout, verify=False, allow_redirects=True) 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:'): # Try onclick encodeURI extraction 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): """Extract href; if href is dummy (#...), check onclick for encodeURI/location.href.""" 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 # Try onclick 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 '' # ================================================================ # 사이트별 파서 (각각 raw_rows 리스트 반환) # raw_rows: [{'D': str, 'E': str, 'F': str, 'G': str, 'H': str, 'I': str, 'J': str, 'href': str}, ...] # ================================================================ def parse_eGov_type1(soup, base): """논산시·아산시 형: div.sitemap.type1 > [div.s_1th + div.inner > div.s_2th + ul...].""" sitemap = soup.select_one('div.sitemap.type1') or soup.select_one('div.sitemap') rows = [] if not sitemap: return rows current_D = '' 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 = extract_href(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 child in sitemap.find_all('div', recursive=False): cls = child.get('class', []) if 's_1th' in cls: a = child.find('a') current_D = clean_text(a.get_text()) if a else '' elif 'inner' in cls: s2 = child.find('div', class_='s_2th') mid_a = s2.find('a') if s2 else None mid_name = clean_text(mid_a.get_text()) if mid_a else '' mid_href = extract_href(mid_a) if mid_a else '' uls = child.find_all('ul', recursive=False) if not uls: rows.append({'D': current_D, 'E': mid_name, 'href': mid_href, 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for ul in uls: tmp = [] walk(ul, 0, [], tmp) for item in tmp: p = item['path'] row = {'D': current_D, 'E': mid_name, '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) return rows def parse_amThum(soup, base): """당진시·태안군 형: div.amThum > h2.siteNN + div.sitemap_grep > ul.sitemap_list > li > a.first + ul > li > a (+ ul > li > a).""" rows = [] for amthum in soup.select('div.amThum'): h2 = amthum.find(['h2', 'h3'], class_=re.compile(r'site\d+')) D = clean_text(h2.find('span').get_text()) if h2 and h2.find('span') else (clean_text(h2.get_text()) if h2 else '') grep = amthum.find('div', class_='sitemap_grep') or amthum for sl in grep.find_all('ul', class_='sitemap_list'): # Each ul.sitemap_list contains li > a.first + ul > li > a for top_li in sl.find_all('li', recursive=False): a_first = top_li.find('a', class_='first', recursive=False) if not a_first: continue E = clean_text(a_first.get_text()) E_href = extract_href(a_first) inner_ul = top_li.find('ul', recursive=False) if not inner_ul: rows.append({'D': D, 'E': E, 'href': E_href, 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for mid_li in inner_ul.find_all('li', recursive=False): mid_a = mid_li.find('a', recursive=False) if not mid_a: continue F = clean_text(mid_a.get_text()) F_href = extract_href(mid_a) deeper = mid_li.find('ul', recursive=False) if not deeper: rows.append({'D': D, 'E': E, 'F': F, 'href': F_href, 'G': '', 'H': '', 'I': '', 'J': ''}) continue rows.append({'D': D, 'E': E, 'F': F, 'href': F_href, 'G': '', 'H': '', 'I': '', 'J': ''}) for deep_li in deeper.find_all('li', recursive=False): deep_a = deep_li.find('a', recursive=False) if not deep_a: continue G = clean_text(deep_a.get_text()) rows.append({'D': D, 'E': E, 'F': F, 'G': G, 'href': extract_href(deep_a), 'H': '', 'I': '', 'J': ''}) return rows def parse_ul_sitemap_h4(soup, base): """보령시·서천군 형: ul.sitemap > li (대분류) > h4.siteNN > span + ul > li > h5 > a + ul > li.list > a.""" rows = [] container = soup.select_one('ul.sitemap') or soup.select_one('div#contents ul.sitemap') if not container: return rows for top_li in container.find_all('li', recursive=False): h4 = top_li.find(['h4', 'h3'], recursive=False) D = '' if h4: span = h4.find('span') D = clean_text(span.get_text() if span else h4.get_text()) # Each direct ul under top_li is a sub-group for sub_ul in top_li.find_all('ul', recursive=False): for sub_li in sub_ul.find_all('li', recursive=False): h5 = sub_li.find(['h5', 'h6'], recursive=False) if h5: h5_a = h5.find('a') E = clean_text(h5_a.get_text()) if h5_a else clean_text(h5.get_text()) E_href = extract_href(h5_a) if h5_a else '' else: a = sub_li.find('a', recursive=False) E = clean_text(a.get_text()) if a else '' E_href = extract_href(a) if a else '' deeper = sub_li.find('ul', recursive=False) if not deeper: 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 leaf_li in deeper.find_all('li', recursive=False): leaf_a = leaf_li.find('a', recursive=False) if not leaf_a: continue F = clean_text(leaf_a.get_text()) rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(leaf_a), 'G': '', 'H': '', 'I': '', 'J': ''}) # 4-level (rare) sub_ul2 = leaf_li.find('ul', recursive=False) if sub_ul2: for ll2 in sub_ul2.find_all('li', recursive=False): la2 = ll2.find('a', recursive=False) if la2: G = clean_text(la2.get_text()) rows.append({'D': D, 'E': E, 'F': F, 'G': G, 'href': extract_href(la2), 'H': '', 'I': '', 'J': ''}) return rows def parse_dl_dt_dd(soup, base, use_onclick=False): """홍성군·예산군·천안시·금산군 dl 형: div.sitemap (옵션 .type2) > dl > dt + dd > b > a + ul > li > a (+ ul > li > a). use_onclick=True 인 경우 onclick의 encodeURI/location.href에서 URL을 추출. """ rows = [] sm = soup.select_one('div.sitemap[class*=type2]') 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_seosan_top_menu(soup, base): """서산시: ul.top_menu > li.depth1 > a.depth1_ti (D) + div...div.depth2_wrap > ul.depth2 > li > a (E) + ul.depth3 > li > a (F).""" rows = [] tm = soup.select_one('ul.top_menu') if not tm: return rows for top_li in tm.find_all('li', class_='depth1', recursive=False): a1 = top_li.find('a', class_='depth1_ti') D = clean_text(a1.get_text()) if a1 else '' depth2 = top_li.select_one('ul.depth2') if not depth2: continue for d2_li in depth2.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 = d2_li.find('ul', class_='depth3', recursive=False) if not d3: 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.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_yesan_depth(soup, base): """예산군: #gnb > ul.depth1_ul > li > a.th_1st + div.item > ul.depth2_ul > li > a + ul.depth3_ul > li > a.""" rows = [] container = soup.select_one('#gnb ul.depth1_ul') or 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_='th_1st', recursive=False) D = clean_text(a1.get_text()) if a1 else '' item = top_li.find('div', class_='item') if not item: continue d2_ul = item.find('ul', class_='depth2_ul') 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_cheonan_depth(soup, base): """천안시: ul.depth1-ul > li > a.depth1-btn + div.depth1-content > div.layout > ul.depth2-ul > li > a.depth2-btn + div.depth2-content > ul.depth3-ul > li > a.depth3-btn.""" 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_='depth1-btn', recursive=False) D = clean_text(a1.get_text()) if a1 else '' D_href = extract_href(a1) if a1 else '' d2_ul = top_li.select_one('ul.depth2-ul') if not d2_ul: rows.append({'D': D, 'E': '', 'href': D_href, 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for d2_li in d2_ul.find_all('li', recursive=False): d2_a = d2_li.find('a', class_='depth2-btn', recursive=False) if not d2_a: continue E = clean_text(d2_a.get_text()) E_href = extract_href(d2_a) d3_ul = d2_li.select_one('ul.depth3-ul') 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', class_='depth3-btn', 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_cheongyang_depth1(soup, base): """청양군: ul.depth1_ul > li > a.th_1st + div.item > ul.depth2_ul > li > a + ul.depth3_ul > li > a (예산군 형).""" # 청양군은 예산군과 동일한 e-Gov GNB 구조 — yesan 파서 재사용 return parse_yesan_depth(soup, base) def parse_asan_gnb(soup, base): """아산시: div#mGnb-anchor{n}.gnb-sub-list > ul > li > a.gnb-sub-trigger + ul.sub-ul > li > a.subm.""" rows = [] # 대분류 이름 — mobile-nav의 gnb-main-trigger 텍스트 + href(#mGnb-anchorN) 매핑 nav_main = soup.select_one('nav#mobile-nav') main_categories = [] # [(D, anchor_id)] if nav_main: for trig in nav_main.find_all(['a', 'button'], class_='gnb-main-trigger'): text = clean_text(trig.get_text()) target = trig.get('href') or trig.get('data-target') or '' if target.startswith('#mGnb-anchor'): main_categories.append((text, target.lstrip('#'))) # Fallback: sections without name mapping if not main_categories: for sec in soup.select('div[id^=mGnb-anchor]'): main_categories.append((sec.get('id'), sec.get('id'))) for D, anchor_id in main_categories: section = soup.find('div', id=anchor_id) if not section: continue # section > ul > li > a.gnb-sub-trigger + ul.sub-ul > li > a.subm for ul in section.find_all('ul', recursive=False): for li in ul.find_all('li', recursive=False): a_E = li.find('a', class_='gnb-sub-trigger', recursive=False) or li.find('a', recursive=False) if not a_E: continue E = clean_text(a_E.get_text()) E_href = extract_href(a_E) sub_ul = li.find('ul', class_='sub-ul', recursive=False) or li.find('ul', recursive=False) if not sub_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 sub_li in sub_ul.find_all('li', recursive=False): sub_a = sub_li.find('a', recursive=False) if not sub_a: continue F = clean_text(sub_a.get_text()) rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(sub_a), 'G': '', 'H': '', 'I': '', 'J': ''}) return rows def parse_buyeo_topmenu(soup, base): """부여군: ul#tm > li.th1 > a.th1_lnk (D) + div.summry > ul.th2 > li > a.th2_lnk (E) + ul.th3 > li > a (F).""" rows = [] tm = soup.select_one('ul#tm') if not tm: return rows for top_li in tm.find_all('li', class_=re.compile(r'th1?'), recursive=False): a1 = top_li.find('a', class_='th1_lnk', recursive=False) if not a1: a1 = top_li.find('a', recursive=False) if not a1: continue D = clean_text(a1.get_text()) D_href = extract_href(a1) # Find ul.th2 inside div.summry summry = top_li.find('div', class_=re.compile(r'summry'), recursive=False) th2 = (summry.find('ul', class_='th2') if summry else None) or top_li.find('ul', class_='th2') if not th2: rows.append({'D': D, 'E': '', 'href': D_href, 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for li2 in th2.find_all('li', recursive=False): a2 = li2.find('a', class_='th2_lnk', recursive=False) or li2.find('a', recursive=False) if not a2: continue E = clean_text(a2.get_text()) E_href = extract_href(a2) th3 = li2.find('ul', class_='th3', recursive=False) if not th3: 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 th3.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 # ================================================================ # 사이트 설정 # ================================================================ SITES = [ { 'idx': 5, 'name': '당진시', 'base': 'https://www.dangjin.go.kr', 'sitemap': 'https://www.dangjin.go.kr/kor/sitemap_11.do', 'sheet': '05_당진시', 'parser': parse_amThum, 'domain': 'dangjin.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\5.당진시', }, { 'idx': 6, 'name': '보령시', 'base': 'https://www.brcn.go.kr', 'sitemap': 'https://www.brcn.go.kr/kor/sitemap_11.do', 'sheet': '06_보령시', 'parser': parse_ul_sitemap_h4, 'domain': 'brcn.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\6.보령시', }, { 'idx': 7, 'name': '부여군', 'base': 'https://www.buyeo.go.kr', 'sitemap': 'https://www.buyeo.go.kr/html/kr/', 'sheet': '07_부여군', 'parser': parse_buyeo_topmenu, 'domain': 'buyeo.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\7.부여군', }, { 'idx': 8, 'name': '서산시', 'base': 'https://www.seosan.go.kr', 'sitemap': 'https://www.seosan.go.kr/www/index.do', 'sheet': '08_서산시', 'parser': parse_seosan_top_menu, 'domain': 'seosan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\8.서산시', }, { 'idx': 9, 'name': '서천군', 'base': 'https://www.seocheon.go.kr', 'sitemap': 'https://www.seocheon.go.kr/kor/sitemap_11.do', 'sheet': '09_서천군', 'parser': parse_ul_sitemap_h4, 'domain': 'seocheon.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\9.서천군', }, { 'idx': 10, 'name': '아산시', 'base': 'https://www.asan.go.kr', 'sitemap': 'https://www.asan.go.kr/main/', 'sheet': '10_아산시', 'parser': parse_asan_gnb, 'domain': 'asan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\10.아산시', }, { 'idx': 11, 'name': '예산군', 'base': 'https://www.yesan.go.kr', 'sitemap': 'https://www.yesan.go.kr/kor/sitemap.do', 'sheet': '11_예산군', 'parser': parse_yesan_depth, 'domain': 'yesan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\11.예산군', }, { 'idx': 12, 'name': '천안시', 'base': 'https://www.cheonan.go.kr', 'sitemap': 'https://www.cheonan.go.kr/kor/sitemap.do', 'sheet': '12_천안시', 'parser': parse_cheonan_depth, 'domain': 'cheonan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\12.천안시', }, { 'idx': 13, 'name': '청양군', 'base': 'https://www.cheongyang.go.kr', 'sitemap': 'https://www.cheongyang.go.kr/kor/sitemap_11.do', 'sheet': '13_청양군', 'parser': parse_cheongyang_depth1, 'domain': 'cheongyang.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\13.청양군', }, { 'idx': 14, 'name': '태안군', 'base': 'https://www.taean.go.kr', 'sitemap': 'https://www.taean.go.kr/kor/sitemap_11.do', 'sheet': '14_태안군', 'parser': parse_amThum, 'domain': 'taean.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\14.태안군', }, { 'idx': 15, 'name': '홍성군', 'base': 'https://www.hongseong.go.kr', 'sitemap': 'https://www.hongseong.go.kr/kor/sitemap.do', 'sheet': '15_홍성군', 'parser': lambda soup, base: parse_dl_dt_dd(soup, base, use_onclick=True), 'domain': 'hongseong.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\15.홍성군', }, ] # ================================================================ # 엑셀 생성 (공통) # ================================================================ 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: html = fetch_html(site['sitemap']) 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()