# -*- coding: utf-8 -*- """부모-자식 URL 중복 제거 — 전 깊이 일반화 (매뉴얼 1-5 확장). 기존 phase1 dedup은 F→G 한 깊이만 처리. 서산시처럼 중분류(E)가 클릭 랜딩이고 그 URL이 첫 소분류(F)와 같으면(E→F 중복) 안 지워졌다. 본 도구는 깊이 무관: 부모행의 가장 깊은 카테고리 컬럼이 lc 이고, 직후 자식행이 · D~lc 까지 값이 모두 동일, · lc+1 컬럼이 채워짐, · K(URL)가 부모와 '정확히' 동일 이면 부모행을 삭제(자식이 카테고리 텍스트를 병합으로 승계). URL이 다르면 보존. 평탄화→재병합(D/E/F)→순번/행높이/하이퍼링크 재설정은 _tab_expand 와 동일 로직. 모든 컬럼(B~AA, L~Q 등 Phase2~4 데이터 포함) 보존. 사용: python -X utf8 _dedup_all.py dry [기관...] # 제거대상 집계만(읽기전용) python -X utf8 _dedup_all.py run [기관...] # 백업(*_backup_dedup전.xlsx) 후 제거 """ import os import sys import shutil import warnings from copy import copy import openpyxl from openpyxl.styles import Alignment, Font warnings.filterwarnings('ignore') MAXCOL = 27 CAT_COLS = [4, 5, 6, 7, 8, 9, 10] # D E F G H I J HEADER_MERGES = {'B1:R1', 'S1:W1', 'Y1:AA1'} HERE = os.path.dirname(os.path.abspath(__file__)) MAP = os.path.join(os.path.dirname(HERE), '작업파일', '광역_사이트맵') # 전 42개 시·군 (광역, idx, 기관) SITES = ( [('충청남도', i, n) for i, n in [ (1, '계룡시'), (2, '공주시'), (3, '금산군'), (4, '논산시'), (5, '당진시'), (6, '보령시'), (7, '부여군'), (8, '서산시'), (9, '서천군'), (10, '아산시'), (11, '예산군'), (12, '천안시'), (13, '청양군'), (14, '태안군'), (15, '홍성군')]] + [('충청북도', i, n) for i, n in [ (1, '괴산군'), (2, '단양군'), (3, '보은군'), (4, '영동군'), (5, '옥천군'), (6, '음성군'), (7, '제천시'), (8, '증평군'), (9, '진천군'), (10, '청주시'), (11, '충주시')]] + [('전북특별자치도', i, n) for i, n in [ (1, '고창군'), (2, '군산시'), (3, '김제시'), (4, '남원시'), (5, '무주군'), (6, '부안군'), (7, '순창군'), (8, '완주군'), (9, '익산시'), (10, '임실군'), (11, '장수군'), (12, '전주시'), (13, '정읍시'), (14, '진안군')]] + [('제주특별자치도', i, n) for i, n in [(1, '서귀포시'), (2, '제주시')]] ) def xpath(prov, idx, name): return os.path.join(MAP, prov, f'{idx}.{name}', f'{prov}_{name}.xlsx') def load_flat(ws): 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_depth(vals): deep = 4 for c in CAT_COLS: if vals.get(c) not in (None, ''): deep = c return deep def dedup(rows): """전 깊이 부모-자식 URL 중복 제거. 반환: (남은행, 제거목록).""" out, removed = [], [] i = 0 while i < len(rows): v = rows[i]['vals'] if i + 1 < len(rows): nv = rows[i + 1]['vals'] lc = leaf_depth(v) ku, kc = v.get(11), nv.get(11) if lc < 10 and isinstance(ku, str) and ku.startswith('http') and ku == kc: same_upper = all((v.get(c) or '') == (nv.get(c) or '') for c in CAT_COLS if c <= lc) child_next = nv.get(lc + 1) not in (None, '') if same_upper and child_next: removed.append({'src': rows[i]['src'], 'lc': lc, 'E': v.get(5), 'F_child': nv.get(lc + 1), 'url': ku}) i += 1 continue out.append(rows[i]) i += 1 return out, removed def write_back(ws, out_rows): """남은 행으로 데이터영역 재작성 + D/E/F 재병합 + 순번/행높이/하이퍼링크.""" 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 idx, orow in enumerate(out_rows): r = START + idx sty = orow['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 = idx + 1 END = START + len(out_rows) - 1 center = Alignment(horizontal='center', vertical='center', wrap_text=True) left = Alignment(horizontal='left', vertical='center', wrap_text=False) def merge_runs(letter, col, group_cols=()): cur = ws.cell(START, col).value grp = tuple(ws.cell(START, g).value for g in group_cols) run = START runs = [] for r in range(START + 1, END + 1): val = ws.cell(r, col).value g = tuple(ws.cell(r, gg).value for gg in group_cols) if val == cur and g == grp: continue if cur not in (None, '') and r - 1 > run: runs.append((run, r - 1)) cur, grp, run = val, g, r if cur not in (None, '') and END > run: runs.append((run, END)) for s, e in runs: ws.merge_cells(f'{letter}{s}:{letter}{e}') ws.cell(s, col).alignment = center merge_runs('F', 6, (4, 5)) merge_runs('E', 5, (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 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 def main(): mode = sys.argv[1] if len(sys.argv) > 1 else 'dry' only = set(a for a in sys.argv[2:] if not a.startswith('--')) sites = [s for s in SITES if not only or s[2] in only] grand = 0 print(f'{"기관":<8}{"현재행":>6}{"제거":>5}{"→남음":>7} 유형(중분류 예시)') print('-' * 80) for prov, idx, name in sites: xp = xpath(prov, idx, name) if not os.path.exists(xp): print(f'{name:<8} 엑셀 없음') continue wb = openpyxl.load_workbook(xp) ws = wb.active rows = load_flat(ws) out, removed = dedup(rows) grand += len(removed) ex = '' if removed: sample = removed[0] ex = f"E={sample['E']} = {sample['F_child']}" print(f'{name:<8}{len(rows):>6}{len(removed):>5}{len(out):>7} {ex}') if mode == 'run' and removed: bak = xp.replace('.xlsx', '_backup_dedup전.xlsx') shutil.copy(xp, bak) write_back(ws, out) try: wb.save(xp) except PermissionError: wb.save(xp.replace('.xlsx', '_LP.xlsx')) print(f' !! 원본 잠김 → _LP.xlsx 저장') print('-' * 80) print(f'총 제거 대상: {grand}행 ({mode})') if __name__ == '__main__': main()