# -*- coding: utf-8 -*- """중분류/소분류 '랜딩행' 제거 — 매뉴얼 1-5 확장(③ K동일 조건 제거). _dedup_all.py 는 부모행 URL == 첫 자식 URL 일 때만 부모행을 삭제한다(③). eGov '누리집지도'(고창·임실·정읍·진안 등)는 중분류 랜딩(예 …002000000 '민원안내')과 첫 소분류(…002007000)의 URL이 달라 ③에 안 걸리고 랜딩행이 남아 있다. 사용자 요청(2026-05-31): ③조건을 빼고, 자식을 거느린 부모 랜딩행을 전부 삭제. · 부모행의 가장 깊은 카테고리 컬럼 lc, lc+1은 빈칸(부모는 그 깊이의 잎이 아님) · 직후 자식행이 D~lc 값 동일 & lc+1 채워짐 → 부모행 삭제(자식이 병합으로 라벨 승계) URL 무관. 카테고리 텍스트(D/E/F)는 재병합으로 보존. 평탄화/재병합/순번/하이퍼링크는 _dedup_all 재사용. 전 컬럼(B~AA, L~Q) 보존. 사용: python -X utf8 _collapse_landing.py dry <기관명...|경로> python -X utf8 _collapse_landing.py run <기관명...|경로> --maxlc N : lc<=N 깊이까지만 삭제(기본 9=I, 즉 D~I 랜딩 모두). E만 원하면 --maxlc 5. """ import os, sys, shutil, importlib.util, warnings import openpyxl warnings.filterwarnings('ignore') spec = importlib.util.spec_from_file_location('dd', __file__.replace('_collapse_landing.py', '_dedup_all.py')) dd = importlib.util.module_from_spec(spec); spec.loader.exec_module(dd) CAT_COLS = dd.CAT_COLS # D E F G H I J = 4..10 EXCLUDE = {'계룡시'} # 검수완료 def is_safe_shell(v): """순수 메뉴 셸인가: L=페이지(또는 빈칸) & M<=1 & 공공누리 미부착.""" L = v.get(12); M = v.get(13); O = v.get(15) if isinstance(O, str) and '유형' in O: return False if L not in (None, '', '페이지'): return False if isinstance(M, (int, float)) and M and M > 1: return False return True def collapse(rows, maxlc=9, safe=False): """랜딩행 제거. safe=True면 순수 셸(데이터 무보유)만. 반환 (남은행, 제거목록).""" out, removed = [], [] i = 0 while i < len(rows): v = rows[i]['vals'] if i + 1 < len(rows): nv = rows[i + 1]['vals'] lc = dd.leaf_depth(v) # 부모의 가장 깊은 카테고리 컬럼 if 4 <= lc <= maxlc: 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, '') parent_is_landing = v.get(lc + 1) in (None, '') if safe and not is_safe_shell(v): same_upper = False # 데이터 보유 랜딩은 보존 if same_upper and child_next and parent_is_landing: removed.append({'src': rows[i]['src'], 'lc': lc, 'label': v.get(lc), 'child': nv.get(lc + 1), 'url': v.get(11), 'curl': nv.get(11)}) i += 1 continue out.append(rows[i]); i += 1 return out, removed LV = {4: 'D', 5: 'E', 6: 'F', 7: 'G', 8: 'H', 9: 'I', 10: 'J'} def resolve(args): paths = [] for a in args: if a.lower().endswith('.xlsx') or os.path.sep in a: paths.append((os.path.splitext(os.path.basename(a))[0], a)) names = [a for a in args if not (a.lower().endswith('.xlsx') or os.path.sep in a)] for prov, idx, name in dd.SITES: if names and name in names: paths.append((name, dd.xpath(prov, idx, name))) return paths def main(): mode = sys.argv[1] if len(sys.argv) > 1 else 'dry' rest = [a for a in sys.argv[2:] if not a.startswith('--')] maxlc = 9 if '--maxlc' in sys.argv: maxlc = int(sys.argv[sys.argv.index('--maxlc') + 1]) safe = '--safe' in sys.argv targets = resolve(rest) if not targets: print('대상 없음.'); return grand = 0 for name, xp in targets: if name in EXCLUDE: print(f'{name}: 검수완료 제외'); continue if not os.path.exists(xp): print(f'{name}: 엑셀 없음'); continue wb = openpyxl.load_workbook(xp); ws = wb.active rows = dd.load_flat(ws) out, removed = collapse(rows, maxlc, safe) grand += len(removed) bylv = {} for r in removed: bylv[r['lc']] = bylv.get(r['lc'], 0) + 1 lvstr = ' '.join(f'{LV[k]}:{v}' for k, v in sorted(bylv.items())) print(f'\n=== {name} === {len(rows)}→{len(out)} (제거 {len(removed)}) [{lvstr}]') for r in removed[:6]: print(f" [{LV[r['lc']]}] {r['label']} ({r['url']}) → 자식 첫행 {r['child']} ({r['curl']})") if len(removed) > 6: print(f' ... 외 {len(removed)-6}건') if mode == 'run' and removed: bak = xp.replace('.xlsx', '_backup_랜딩제거전.xlsx') shutil.copy(xp, bak) dd.write_back(ws, out) try: wb.save(xp) except PermissionError: wb.save(xp.replace('.xlsx', '_LP.xlsx')); print(' !! 잠김→_LP') else: print(f' 저장 완료. 백업: {os.path.basename(bak)}') print(f'\n총 제거 대상: {grand}행 ({mode}, maxlc={maxlc})') if __name__ == '__main__': main()