"""본문 탭(서브내비) → 카테고리 하위 확장. 규칙(공주시 기준, 일반화): 본문에서 '탭 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'] # 토큰 단위 탭 클래스 정규식: 'basic_tab', 'tab_wrap', 'tab-ul', 단독 'tab' 등 매칭. # (UL 자신뿐 아니라 직계 부모 div 클래스도 검사 → div.basic_tab > ul 구조 대응) TAB_CLASS_RE = re.compile( r'(?:^|[-_ ])tab(?:[-_ ]|$)|tabmenu|tablist|tab[-_]?(?:ul|wrap|list|menu)|basic[-_]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): """비교용 키: 프래그먼트 제거 + 끝 슬래시 정리 + 쿼리 정렬 보존. (쿼리만 다른 게시판 분류 탭 ?code=A vs ?code=B 를 서로 다른 URL로 구분 → is_cur·existing 중복판정 오류 방지)""" if not u: return '' s = urlsplit(u) path = s.path.rstrip('/') q = '&'.join(sorted(s.query.split('&'))) if s.query else '' return urlunsplit((s.scheme, s.netloc, path, q, '')).lower() def path_key(u): """쿼리 제거한 경로 키 (필터 탭 판별용).""" if not u: return '' s = urlsplit(u) return urlunsplit((s.scheme, s.netloc, s.path.rstrip('/'), '', '')).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): if el is None or not getattr(el, 'get', None): return False, '' cls = ' '.join(el.get('class') or []).lower() ok = any(p in cls for p in TAB_CLASS_PATS) or bool(TAB_CLASS_RE.search(cls)) return ok, cls def _has_on_li(ul): """ul 직계 li(또는 그 a)에 활성 탭 마커(on/active/current/selected)가 있나.""" marks = {'on', 'active', 'current', 'selected', 'sel'} for li in ul.find_all('li', recursive=False): if marks & set(c.lower() for c in (li.get('class') or [])): return True a = li.find('a') if a and (marks & set(c.lower() for c in (a.get('class') or []))): return True return False def find_tab_groups(soup, base): """조건 1·2 만족하는 탭 그룹 후보 반환: [(cls, [(label, abs_url),...], has_on), ...]""" out = [] seen = set() for ul in soup.find_all('ul'): ok, cls = has_tab_class(ul) if not ok: # UL 무클래스라도 직계 부모 div 에 탭 클래스가 있으면 인정 (div.basic_tab > ul) pok, pcls = has_tab_class(ul.parent) if not pok: continue # 쿼리필터·무클래스 ul 배제 cls = pcls 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, _has_on_li(ul))) 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 # menuCd 벤더(고창·임실·정읍·진안 등 index.{name}?menuCd=…): 모든 페이지가 같은 경로, # 쿼리(menuCd)만 다름 = 다른 페이지. → 쿼리-경로 가드(조건2) 스킵 + cur∈탭(조건3) 대신 # div.basic_tab 등에 활성탭(li.on) 마커가 있는 '자기 sub-nav'만 인정(랜딩 URL이 탭과 달라도). menucd = '--menucd' in sys.argv # 서산 등 쿼리기반(contents.do?key=, selectBbsNttList.do?bbsNo=) 벤더: # 모든 탭이 같은 경로·쿼리만 다름(=다른 페이지)이고 한 탭바에 페이지+게시판 혼재. # → 쿼리가드(같은경로=필터 제외) 끄고, div.tab_menu>ul.tab_button 그룹만 인정. noqg = '--noqg' 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) # 이미 사이트맵에 자식행이 있는 '랜딩행' 집합 (다음 평탄행이 같은 상위카테고리 + 더 깊은 leaf). # menuCd 벤더 랜딩은 첫 자식의 탭을 렌더하므로, 확장하면 그 자식행과 중복 → 제외. landing_src = set() for i in range(len(rows) - 1): v, nv = rows[i]['vals'], rows[i + 1]['vals'] lc = leaf_col(v) if lc < 10 and nv.get(lc + 1) not in (None, '') \ and all((v.get(c) or '') == (nv.get(c) or '') for c in CAT_COLS if c <= lc): landing_src.add(rows[i]['src']) # 행별 확장 계획 plan = {} # src_row -> ordered [(label, url, is_existing)] planned_new = set() # 이미 어느 부모행이 추가한 신규 탭 URL(전역 중복 방지) for row in rows: src = row['src'] if src not in html_by_src: continue if menucd and src in landing_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, has_on in groups: if menucd: # menuCd 벤더: 현재 페이지와 '같은 경로'(menuCd만 다른 형제)인 탭만 인정. # → /gochang/toc/GC…(향토문화대전 백과 목록) 등 다른경로 링크 배제. # 활성탭(li.on) 마커가 있는 자기 sub-nav만(랜딩 URL이 탭에 없어도 OK). if not has_on: continue links = [(t, u) for t, u in links if path_key(u) == path_key(cur)] if len(links) < 2: continue tab_norms = [norm_url(u) for _, u in links] else: if noqg and not ('tab_button' in cls or 'tab_menu' in cls): continue # 서산: 본문 탭바(tab_menu/tab_button)만 — GNB·푸터 nav 배제 tab_norms = [norm_url(u) for _, u in links] if not noqg and len({path_key(u) for _, u in links}) == 1: continue # 조건2: 같은 경로(쿼리만 다른 필터 탭) → 제외 (noqg면 쿼리=다른페이지라 허용) 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: is_cur = norm_url(u) == cur nu = norm_url(u) if not is_cur and nu in existing: continue # 이미 사이트맵에 별도 행으로 존재 → 중복행 방지(skip) if not is_cur and nu in planned_new: continue # 다른 부모행이 이미 추가한 신규 URL → 전역 중복 방지 if not is_cur: planned_new.add(nu) is_ext = domain not in u # 같은 도메인 아님 = 외부 사이트 탭 ch.append((label, u, is_cur, is_ext)) # 신규(비-cur) 탭이 1개 이상일 때만 확장. 아니면 원본행 그대로 둠(삭제 방지). if any(not c[2] for c in ch): 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, is_cur, is_ext in plan[src]: if is_cur: tag = '재사용' elif is_ext: tag = '신규+(외부=사이트)' total_new += 1 else: tag = '신규+' 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, is_cur, is_ext in plan[src]: nv = dict(v) # leaf 보다 깊은 컬럼 초기화 후 자식 컬럼에 탭명 for c in CAT_COLS: if c > lc: nv[c] = None nv[childc] = label if is_cur: 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 if is_ext: # 외부 사이트 탭 → 즉시 L=사이트/M=1(Phase234 대상 아님) nv[12] = '사이트' nv[13] = 1 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()