"""논산시 Phase 1 — 사이트맵 → 엑셀 (D~K + 병합·정렬·스타일). 매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md 사이트맵 구조: div.sitemap.type1 > [div.s_1th > a (D)] + [div.inner > div.s_2th > a (E) + ul > li > a (F) + (ul.s_4th_ul > li > a (G))]* """ import shutil 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') INSTITUTION = '논산시' BASE = 'https://nonsan.go.kr' SITEMAP_URL = 'https://nonsan.go.kr/kor/html/sub07/0701.html' SHEET_NAME = '04_논산시' TEMPLATE = r'D:\01.프로젝트\DB수집\자료_취합_예시.xlsx' OUTPUT = r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\4.논산시\충청남도_논산시.xlsx' 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} 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 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 'nonsan.go.kr' not in url def walk_ul(ul, depth, base_path, out): """Recursive walk for nested ul > li > a (s_4th_ul handling).""" for li in ul.find_all('li', recursive=False): a = li.find('a', recursive=False) if not a: continue text = a.get_text(strip=True).replace('\xa0', '').strip() 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_ul(nested, depth + 1, path, out) else: out.append({'path': list(path), 'href': href}) def main(): print('[1] 사이트맵 가져오기') r = requests.get(SITEMAP_URL, headers=H, timeout=20, verify=False) r.encoding = r.apparent_encoding soup = BeautifulSoup(r.text, 'html.parser') sitemap = soup.select_one('div.sitemap.type1') or soup.select_one('div.sitemap') if sitemap is None: raise RuntimeError('div.sitemap.type1 영역을 찾지 못했습니다.') raw_rows = [] # Iterate child elements in document order: # div.s_1th = 대분류 (D) # div.inner = 중분류 + 소분류 그룹 current_D = '' for child in sitemap.find_all(['div'], recursive=False): cls = child.get('class', []) if 's_1th' in cls: a = child.find('a') current_D = a.get_text(strip=True) if a else '' elif 'inner' in cls: # div.s_2th > a → 중분류 (E) s2 = child.find('div', class_='s_2th') mid_a = s2.find('a') if s2 else None mid_name = mid_a.get_text(strip=True) if mid_a else '' mid_href = extract_href(mid_a) if mid_a else '' # Each direct ul under .inner is a separate group of leaf items (F) uls = child.find_all('ul', recursive=False) if not uls: raw_rows.append({'D': current_D, 'E': mid_name, 'href': mid_href, 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for ul in uls: tmp = [] walk_ul(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 raw_rows.append(row) print(f'[2] 원본 행: {len(raw_rows)}') # 부모-자식 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'[3] 부모-자식 URL 중복 삭제: {removed}개 → 최종 {len(final_rows)}행') print('[4] 엑셀 템플릿 복사') shutil.copy(TEMPLATE, OUTPUT) wb = openpyxl.load_workbook(OUTPUT) ws = wb.active ws.title = SHEET_NAME 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 = INSTITUTION 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 print(f'[5] 데이터 기입: {START}~{END}') 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_d = merge_runs('D', 4) n_e = merge_runs('E', 5, group_cols=(4,)) n_f = merge_runs('F', 6, group_cols=(4, 5)) 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 print(f'[6] 병합 — D:{n_d} E:{n_e} F:{n_f}') 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 print(f'[7] 행 높이 15 + K 하이퍼링크 {link_n}개 (좌측 정렬)') wb.save(OUTPUT) print(f'[8] 저장: {OUTPUT}') print('\n=== 통계 ===') from collections import Counter cnt = Counter(item.get('D') for item in final_rows) print('대분류별 행수:') for k, v in cnt.items(): print(f' {k}: {v}') ext_n = sum(1 for it in final_rows if is_external(abs_url(it.get('href', '')))) print(f'외부링크: {ext_n}') if __name__ == '__main__': main()