# -*- coding: utf-8 -*- """금산군 메뉴 랜딩행 M = 서브트리 전체 leaf 합 (2026-05-31, 사용자 규칙) 여성가족=10 패턴: 메뉴(6자리 prefix)의 모든 자식페이지 X01..X0N에 대해 leaf = 라이브 ui-nav_tabs 개수 (탭 없으면 1) M(랜딩행) = Σ leaf. 단 메뉴의 자식 중 게시판이 있으면 SKIP(분리 필요, 수동). 대상: 랜딩행 >= 143(여성가족) 이고 그 prefix의 시트행이 1개(sheet=1)인 메뉴. sheet>1(이미 G확장됨: 군민복지050307·하수처리050601·주민참여060603)은 SKIP. """ import sys, re, json, openpyxl import urllib.request, ssl from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor PATH = r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\3.금산군\충청남도_금산군.xlsx' WRITE = '--write' in sys.argv ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE def fetch(u): req = urllib.request.Request(u, headers={'User-Agent': 'Mozilla/5.0'}) try: return urllib.request.urlopen(req, context=ctx, timeout=15).read().decode('utf-8', 'replace') except Exception: return None def analyze(code): sub = 'sub06' if code.startswith('06') else 'sub05' h = fetch(f'https://www.geumsan.go.kr/kr/html/{sub}/{code}.html') if h is None: return None soup = BeautifulSoup(h, 'html.parser') t = soup.select_one('.location_wrap li:last-child, h2.h2') title = t.get_text(strip=True) if t else '?' ntab = len(soup.select('ul.ui-nav_tabs a.ui-tabs_link')) board = bool(soup.select('table.bbs,.board_list,.bbs_list,.pagination')) or bool(re.search(r'총\s*[\d,]+\s*건', str(soup))) return dict(code=code, title=title, ntab=ntab, board=board, leaf=max(1, ntab)) wb = openpyxl.load_workbook(PATH); ws = wb.active # prefix -> sheet rows (sub05/sub06 8-digit) pref_rows = {} for r in range(3, ws.max_row + 1): k = ws.cell(r, 11).value if not k: continue m = re.search(r'/sub0[56]/(\d{6})(\d{2})\.html', str(k)) if m: pref_rows.setdefault(m.group(1), []).append((r, m.group(0))) targets = {} for pfx, rows in pref_rows.items(): landing = min(r for r, _ in rows) if landing < 143: continue # 여성가족(143) 위는 제외 if len(rows) != 1: print(f'SKIP {pfx} (sheet={len(rows)} rows={[r for r,_ in rows]}) — 이미 확장됨') continue targets[pfx] = landing print(f'대상 sheet=1 메뉴: {len(targets)}') plan = [] for pfx, landing in sorted(targets.items(), key=lambda x: x[1]): codes = [f'{pfx}{xx:02d}' for xx in range(1, 16)] kids = [k for k in ThreadPoolExecutor(8).map(analyze, codes) if k] leaf_sum = sum(k['leaf'] for k in kids) nboard = sum(1 for k in kids if k['board']) cur_m = ws.cell(landing, 13).value cur_f = ws.cell(landing, 6).value or ws.cell(landing, 7).value flag = ' ⚠GESIPAN' if nboard else '' plan.append(dict(pfx=pfx, row=landing, m_old=cur_m, m_new=leaf_sum, nkids=len(kids), nboard=nboard, kids=kids)) print(f'row{landing} {pfx} [{cur_f}] M {cur_m}->{leaf_sum} (자식{len(kids)},board{nboard}){flag}') if nboard: for k in kids: print(f' {k["code"]} leaf{k["leaf"]} board={k["board"]} {k["title"][:20]}') # 적용: 게시판 없는 메뉴만 M 갱신 applied = 0 for p in plan: if p['nboard'] == 0: ws.cell(p['row'], 13).value = p['m_new'] applied += 1 else: print(f' ⚠ row{p["row"]} {p["pfx"]} 게시판 포함 → M 미적용(수동 검토)') print(f'적용 대상(게시판없음): {applied}/{len(plan)}') json.dump([{k: v for k, v in p.items() if k != 'kids'} for p in plan], open(r'D:\01.프로젝트\DB수집\_temp\_geumsan_subtreeM_plan.json', 'w', encoding='utf8'), ensure_ascii=False) if WRITE: wb.save(PATH); print('SAVED') else: print('DRY-RUN (--write 로 저장)')