"""전북특별자치도 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']*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()