공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
8.5 KiB
Python
216 lines
8.5 KiB
Python
# -*- 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()
|