"""본문 탭(서브내비) → 카테고리 하위 확장. 규칙(공주시 기준, 일반화): 본문에서 '탭 UL'을 찾아, 아래 조건을 모두 만족하는 탭 그룹만 하위 카테고리로 확장한다. 1) UL(또는 직계 div)의 class 에 탭 패턴(tab-ul 등)이 있다. 2) 탭이 2개 이상이고, 모든 탭 href 가 '실제 페이지 링크'다. (#anchor 인페이지 탭, javascript:, 빈 href, 쿼리스트링만 다른 필터 탭은 제외) 3) 현재 페이지 URL 이 탭 URL 집합에 포함된다(= 자기 자신의 탭 그룹). 4) 탭 중 '엑셀에 아직 없는 URL'이 1개 이상 있다(이미 사이트맵에 다 있으면 상위 nav → 제외). 확장 방식(=사용자 지시: 탭들은 한 단계 아래 컬럼으로): - 원래 행의 leaf 컬럼(E~J 중 가장 깊은 값)을 부모(카테고리)로 두고, 탭들을 leaf+1 컬럼에 탭 순서대로 채운다. - 현재 페이지와 같은 URL의 탭 = 원래 행을 재사용(L~T 기존 데이터 보존, G만 탭명 기입). - 나머지 탭 = 신규 행(L~T 비움, Phase 2~4 별도 수행 대상). 사용법: python -X utf8 _tab_expand.py <엑셀경로> <도메인키워드> [--write] 예) python -X utf8 _tab_expand.py 충청남도/2.공주시/충청남도_공주시.xlsx https://www.gongju.go.kr gongju.go.kr (--write 없으면 계획만 출력 / 있으면 백업 후 실제 기입) """ import re import sys import shutil import warnings from copy import copy from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin, urlsplit, urlunsplit import ssl 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 from openpyxl.utils import get_column_letter 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} 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) SESSION = requests.Session() SESSION.headers.update(H) TAB_CLASS_PATS = ['tab-ul', 'tab_ul', 'tabmenu', 'tab-menu', 'tab_menu', 'tablist', 'tab-list', 'tab_list', 'subtab', 'sub-tab', 'sub_tab'] MAXCOL = 27 # AA CAT_COLS = [5, 6, 7, 8, 9, 10] # E F G H I J HEADER_MERGES = {'B1:R1', 'S1:W1', 'Y1:AA1'} def norm_url(u): """쿼리/프래그먼트 제거 + 끝 슬래시 정리한 비교용 키.""" if not u: return '' s = urlsplit(u) path = s.path.rstrip('/') return urlunsplit((s.scheme, s.netloc, path, '', '')).lower() def abs_url(href, base): if not href: return '' href = href.strip() if href.startswith(('javascript:', '#')): return '' if href.startswith(('http://', 'https://')): return href return urljoin(base.rstrip('/') + '/', href.lstrip('/')) def has_tab_class(el): cls = ' '.join(el.get('class') or []).lower() return any(p in cls for p in TAB_CLASS_PATS), cls def find_tab_groups(soup, base): """조건 1·2 만족하는 탭 그룹 후보 반환: [(cls, [(label, abs_url),...]), ...]""" out = [] seen = set() for ul in soup.find_all('ul'): ok, cls = has_tab_class(ul) if not ok: continue # UL 자신에 탭 클래스 필요 (쿼리필터·무클래스 ul 배제) links = [] real = True for a in ul.find_all('a'): t = a.get_text(strip=True).replace('\xa0', '').strip() raw = (a.get('href') or '').strip() au = abs_url(raw, base) if not t: continue if not au: # #anchor / javascript / 빈 href → 인페이지 탭 real = False break links.append((t, au)) if not real or len(links) < 2: continue key = tuple(nu for _, (nu) in [(t, u) for t, u in links]) if key in seen: continue seen.add(key) out.append((cls, links)) return out def fetch(r, url): try: resp = SESSION.get(url, timeout=15, verify=False) # 바이트로 받아 BeautifulSoup가 meta charset으로 인코딩 판별 (apparent_encoding 오판 방지) return r, url, resp.content, None except Exception as e: return r, url, None, str(e)[:60] def load_flat(ws): """병합값을 채워넣어 행별 완전 데이터로 평탄화. 반환: rows(list of dict), 스타일/하이퍼링크 캡처.""" # 1) 병합값 채우기 (헤더 제외, 데이터 컬럼) for mr in list(ws.merged_cells.ranges): s = str(mr) if s in HEADER_MERGES: continue top = ws.cell(mr.min_row, mr.min_col).value ws.unmerge_cells(s) for rr in range(mr.min_row, mr.max_row + 1): for cc in range(mr.min_col, mr.max_col + 1): if ws.cell(rr, cc).value in (None, ''): ws.cell(rr, cc).value = top rows = [] for r in range(3, ws.max_row + 1): # 빈 행 스킵 if all(ws.cell(r, c).value in (None, '') for c in range(2, MAXCOL + 1)): continue vals = {c: ws.cell(r, c).value for c in range(1, MAXCOL + 1)} styles = {} for c in range(1, MAXCOL + 1): sc = ws.cell(r, c) styles[c] = (copy(sc.font), copy(sc.fill), copy(sc.border), copy(sc.alignment), sc.number_format, copy(sc.protection)) hl = ws.cell(r, 11).hyperlink rows.append({'src': r, 'vals': vals, 'styles': styles, 'hyperlink': hl.target if hl else None}) return rows def leaf_col(vals): """E~J 중 값이 있는 가장 깊은 컬럼 인덱스.""" deep = 5 for c in CAT_COLS: if vals.get(c) not in (None, ''): deep = c return deep def main(): xlsx, base, domain = sys.argv[1], sys.argv[2], sys.argv[3] write = '--write' in sys.argv if '--weak-ssl' in sys.argv: SESSION.mount('https://', WeakSSLAdapter()) wb = openpyxl.load_workbook(xlsx) ws = wb.active rows = load_flat(ws) existing = set() for row in rows: u = row['vals'].get(11) if isinstance(u, str) and u.startswith('http'): existing.add(norm_url(u)) # 같은 도메인 행 fetch targets = [(row['src'], row['vals'].get(11)) for row in rows if isinstance(row['vals'].get(11), str) and domain in row['vals'].get(11)] html_by_src = {} with ThreadPoolExecutor(max_workers=8) as ex: futs = [ex.submit(fetch, r, u) for r, u in targets] for fut in as_completed(futs): r, url, html, err = fut.result() if html: html_by_src[r] = (url, html) # 행별 확장 계획 plan = {} # src_row -> ordered [(label, url, is_existing)] for row in rows: src = row['src'] if src not in html_by_src: continue url, html = html_by_src[src] cur = norm_url(url) soup = BeautifulSoup(html, 'html.parser') groups = find_tab_groups(soup, base) chosen = None for cls, links in groups: tab_norms = [norm_url(u) for _, u in links] if cur not in tab_norms: continue # 조건3: 자기 탭그룹만 new_cnt = sum(1 for n in tab_norms if n not in existing) if new_cnt < 1: continue # 조건4: 신규 0 → 상위nav, 제외 chosen = links break if chosen: ch = [] for label, u in chosen: ch.append((label, u, norm_url(u) == cur)) plan[src] = ch # 계획 출력 print(f'=== 탭 확장 계획: {len(plan)}개 그룹 ===\n') total_new = 0 for src in sorted(plan): row = next(r for r in rows if r['src'] == src) v = row['vals'] lc = leaf_col(v) childc = lc + 1 ev = v.get(5) or '' fv = v.get(6) or '' print(f'[행{src}] E={ev} F={fv} ' f'(leaf={get_column_letter(lc)} → 자식={get_column_letter(childc)})') for label, u, exist in plan[src]: tag = '재사용' if exist else '신규+' if not exist: total_new += 1 print(f' [{tag}] {label} -> {u}') print() print(f'신규 추가 행: {total_new}개 / 기존 {len(rows)}행 → 최종 {len(rows)+total_new}행') if not write: print('\n(계획만 출력. 실제 기입하려면 --write)') return # ===== 실제 기입 ===== backup = xlsx.replace('.xlsx', '_backup_tab전.xlsx') shutil.copy(xlsx, backup) print(f'\n백업: {backup}') # 새 평탄 행 목록 구성 out_rows = [] # 각 원소 = {'vals':..., 'style_src':row_dict, 'url':..} for row in rows: src = row['src'] if src in plan: v = row['vals'] lc = leaf_col(v) childc = lc + 1 for label, u, exist in plan[src]: nv = dict(v) # leaf 보다 깊은 컬럼 초기화 후 자식 컬럼에 탭명 for c in CAT_COLS: if c > lc: nv[c] = None nv[childc] = label if exist: nv[11] = v.get(11) # 기존 URL/데이터 유지 out_rows.append({'vals': nv, 'style': row, 'url': nv.get(11)}) else: for c in range(12, MAXCOL + 1): nv[c] = None # L~T 비움 (신규) nv[11] = u nv[19] = None out_rows.append({'vals': nv, 'style': row, 'url': u}) else: out_rows.append({'vals': dict(row['vals']), 'style': row, 'url': row['vals'].get(11)}) # 데이터 영역 클리어 for r in range(3, ws.max_row + 1): for c in range(1, MAXCOL + 1): ws.cell(r, c).value = None ws.cell(r, c).hyperlink = None START = 3 for i, orow in enumerate(out_rows): r = START + i sty = orow['style']['styles'] for c in range(1, MAXCOL + 1): cell = ws.cell(r, c) f, fl, bd, al, nf, pr = sty[c] cell.font = copy(f); cell.fill = copy(fl); cell.border = copy(bd) cell.alignment = copy(al); cell.number_format = nf; cell.protection = copy(pr) v = orow['vals'] for c in range(1, MAXCOL + 1): ws.cell(r, c).value = v.get(c) ws.cell(r, 2).value = i + 1 # B 순번 재부여 END = START + len(out_rows) - 1 # D/E/F 재병합 (G는 leaf라 병합 안 함) 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=()): cur_val = ws.cell(START, col_idx).value cur_grp = tuple(ws.cell(START, g).value for g in group_cols) run_start = START runs = [] for r in range(START + 1, END + 1): val = ws.cell(r, col_idx).value grp = tuple(ws.cell(r, gg).value for gg in group_cols) if val == cur_val and grp == 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 = val, grp, 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 merge_runs('F', 6, group_cols=(4, 5)) merge_runs('E', 5, group_cols=(4,)) 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 # K 하이퍼링크 재설정 for r in range(START, END + 1): cell = ws.cell(r, 11) u = cell.value if 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 wb.save(xlsx) print(f'저장 완료: {xlsx} (총 {len(out_rows)}행)') if __name__ == '__main__': main()