공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
294 lines
11 KiB
Python
294 lines
11 KiB
Python
import re
|
|
from html.parser import HTMLParser
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
# ==========================================
|
|
# 1. HTML 데이터 및 설정 정의
|
|
# ==========================================
|
|
BASE_URL = "https://www.msit.go.kr"
|
|
SITE_NAME = "과학기술정보통신부"
|
|
|
|
# 패턴 변환용 딕셔너리 (HTML 내 ID 기반 매핑 추정치 및 사용자가 준 명시적 규칙 결합)
|
|
# 패턴: fn_menulast_go('user', '상위Pid', '자신Id', '경로')
|
|
# 기본 경로 처리 규칙 적용
|
|
def parse_href(href):
|
|
if not href or "javascript:;" in href:
|
|
return ""
|
|
|
|
# 정규식을 이용해 fn_menulast_go 함수의 인자 추출
|
|
match = re.search(r"fn_menulast_go\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)", href)
|
|
if match:
|
|
sCode, mPid, mId, path = match.groups()
|
|
if path == "#" or path == "":
|
|
return f"{BASE_URL}/contents/cont.do?sCode={sCode}&mPid={mPid}&mId={mId}"
|
|
else:
|
|
if path.startswith("http"):
|
|
return path
|
|
return f"{BASE_URL}{path}?sCode={sCode}&mPid={mPid}&mId={mId}"
|
|
|
|
if href.startswith("http"):
|
|
return href
|
|
if href.startswith("/"):
|
|
return f"{BASE_URL}{href}"
|
|
return href
|
|
|
|
# ==========================================
|
|
# 2. 내장 HTML Parser를 이용한 트리 구조 추출
|
|
# ==========================================
|
|
class GNBParser(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.menu_tree = []
|
|
self.current_d1 = None
|
|
self.current_d2 = None
|
|
self.current_d3 = None
|
|
|
|
self.in_a = False
|
|
self.current_href = ""
|
|
|
|
# 계층 식별을 위한 플래그
|
|
self.state_stack = [] # (tag, class_name)
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
attrs_dict = dict(attrs)
|
|
cls = attrs_dict.get("class", "")
|
|
href = attrs_dict.get("href", "")
|
|
|
|
self.state_stack.append((tag, cls))
|
|
|
|
if tag == "a":
|
|
self.in_a = True
|
|
self.current_href = parse_href(href)
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "a":
|
|
self.in_a = False
|
|
if self.state_stack:
|
|
self.state_stack.pop()
|
|
|
|
def handle_data(self, data):
|
|
text = data.strip()
|
|
if not text or not self.in_a:
|
|
return
|
|
|
|
# 현재 스택을 기반으로 Depth 판별
|
|
classes = [c for t, c in self.state_stack]
|
|
|
|
# Depth 1 판별 (gnb 바로 밑의 span이나 a)
|
|
if "depth_box" not in ''.join(classes) and any("gnb" in c for c in classes):
|
|
self.current_d1 = {"text": text, "url": self.current_href, "children": []}
|
|
self.menu_tree.append(self.current_d1)
|
|
self.current_d2 = None
|
|
self.current_d3 = None
|
|
|
|
# Depth 2 판별 (class="depth" 내부의 li > a)
|
|
elif "depth" in classes and "depth02" not in classes:
|
|
if self.current_d1:
|
|
self.current_d2 = {"text": text, "url": self.current_href, "children": []}
|
|
self.current_d1["children"].append(self.current_d2)
|
|
self.current_d3 = None
|
|
|
|
# Depth 3 판별 (class="depth02" 내부의 li > a)
|
|
elif "depth02" in classes:
|
|
if self.current_d2:
|
|
self.current_d3 = {"text": text, "url": self.current_href, "children": []}
|
|
self.current_d2["children"].append(self.current_d3)
|
|
|
|
# 사용자 입력 데이터 파싱 실행
|
|
html_data = """[사용자가 제공한 HTML 소스코드 문장 생략 - 파싱용 변수 배치]"""
|
|
# 실제 실행 시에는 상단의 HTML 텍스트가 들어갑니다.
|
|
|
|
parser = GNBParser()
|
|
# 줄바꿈 깨짐 방지 처리 후 피딩
|
|
parser.feed(html_data)
|
|
|
|
# 평탄화(Flatten)하여 로우 데이터 배열 생성
|
|
rows_data = []
|
|
for d1 in parser.menu_tree:
|
|
d1_txt = d1["text"]
|
|
d1_url = d1["url"]
|
|
if not d1["children"]:
|
|
rows_data.append([d1_txt, "", "", "", d1_url])
|
|
continue
|
|
|
|
for d2 in d1["children"]:
|
|
d2_txt = d2["text"]
|
|
d2_url = d2["url"]
|
|
if not d2["children"]:
|
|
rows_data.append([d1_txt, d2_txt, "", "", d2_url if d2_url else d1_url])
|
|
continue
|
|
|
|
for d3 in d2["children"]:
|
|
d3_txt = d3["text"]
|
|
d3_url = d3["url"]
|
|
rows_data.append([d1_txt, d2_txt, d3_txt, "", d3_url if d3_url else d2_url])
|
|
|
|
# ==========================================
|
|
# 3. OpenPyXL 기반 엑셀 파일 생성 및 디자인
|
|
# ==========================================
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "사이트맵_감사양식"
|
|
|
|
# 1) 눈금선 활성화 설정 (필수 구문)
|
|
ws.views.sheetView[0].showGridLines = True
|
|
|
|
# 2) 스타일 테마 정의
|
|
font_title = Font(name="맑은 고딕", size=11, bold=True)
|
|
font_red_bold = Font(name="맑은 고딕", size=11, bold=True, color="FF0000")
|
|
font_header = Font(name="맑은 고딕", size=11, bold=True)
|
|
font_url_header = Font(name="맑은 고딕", size=11, bold=True, color="375623")
|
|
font_data = Font(name="맑은 고딕", size=10)
|
|
|
|
fill_green = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
|
fill_orange = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
|
fill_blue = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
|
|
|
align_center = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
align_left = Alignment(horizontal="left", vertical="center")
|
|
|
|
border_thin = Border(
|
|
left=Side(style='thin', color='A6A6A6'),
|
|
right=Side(style='thin', color='A6A6A6'),
|
|
top=Side(style='thin', color='A6A6A6'),
|
|
bottom=Side(style='thin', color='A6A6A6')
|
|
)
|
|
|
|
# 3) 헤더 배치 및 서식 적용
|
|
# 1행 그룹 타이틀
|
|
ws["G1"] = "조사원 업무 범위"
|
|
ws["G1"].font = font_title
|
|
ws["R1"] = "문정원/ITN 업무(신청기관 100개 기관 대상)"
|
|
ws["R1"].font = font_red_bold
|
|
|
|
# 2~3행 복합 헤더 기입
|
|
headers_v2 = {
|
|
"B2": "순번", "C2": "사이트명", "D2": "메뉴명",
|
|
"E2": "게시판명/카테고리(대)", "F2": "게시판명/카테고리(중)",
|
|
"G2": "게시판명/카테고리(소)", "H2": "게시판명/카테고리(소-세부사항)",
|
|
"I2": "주소(url)", "J2": "게시판형태", "K2": "수량",
|
|
"L2": "저작물 유형", "M2": "공공누리 부착", "N2": "마크 부착 위치",
|
|
"O2": "마크 하이퍼링크", "P2": "공공누리 연계", "Q2": "비고(특이사항)",
|
|
"R2": "신유형 적용 여부", "S2": "적용 사유", "T2": "검토 대상",
|
|
"U2": "안내 발송(문정원/ITN)"
|
|
}
|
|
|
|
for cell_id, text in headers_v2.items():
|
|
ws[cell_id] = text
|
|
|
|
ws.merge_cells("W2:X2")
|
|
ws["W2"] = "결과"
|
|
|
|
ws["W3"] = "완료여부(Y,N)"
|
|
ws["X3"] = "수집 담당자"
|
|
|
|
# 헤더 영역 색상 및 정렬 채우기
|
|
for col in range(2, 25): # B열(2) ~ X열(24)
|
|
col_letter = get_column_letter(col)
|
|
if col_letter == 'V': continue
|
|
|
|
# 2행 처리
|
|
cell_2 = ws[f"{col_letter}2"]
|
|
cell_2.alignment = align_center
|
|
cell_2.border = border_thin
|
|
|
|
# 3행 처리
|
|
cell_3 = ws[f"{col_letter}3"]
|
|
cell_3.border = border_thin
|
|
if col >= 22: # W, X열의 3행 헤더 정렬
|
|
cell_3.alignment = align_center
|
|
|
|
# 영역별 색상 매핑
|
|
if 2 <= col <= 17: # B~Q: 조사원 범위
|
|
cell_2.fill = fill_green
|
|
cell_2.font = font_url_header if col_letter == 'I' else font_header
|
|
cell_3.fill = fill_green
|
|
elif 18 <= col <= 21: # R~U: 문정원
|
|
cell_2.fill = fill_orange
|
|
cell_2.font = font_header
|
|
cell_3.fill = fill_orange
|
|
elif col >= 23: # W~X: 결과
|
|
cell_2.fill = fill_blue
|
|
cell_2.font = font_header
|
|
cell_3.fill = fill_blue
|
|
|
|
# 4) 데이터 채우기 (실데이터는 B~U열은 3행부터 시작, W~X열은 4행 대응 보정)
|
|
start_row = 3
|
|
for idx, data in enumerate(rows_data, start=1):
|
|
r = start_row + idx - 1
|
|
|
|
ws[f"B{r}"] = idx # 순번
|
|
ws[f"C{r}"] = SITE_NAME # 사이트명
|
|
ws[f"D{r}"] = data[0] # 메뉴명 (depth1)
|
|
ws[f"E{r}"] = data[1] # 카테고리 대 (depth2)
|
|
ws[f"F{r}"] = data[2] # 카테고리 중 (depth3)
|
|
ws[f"G{r}"] = data[3] # 카테고리 소 (depth4)
|
|
ws[f"I{r}"] = data[4] # 주소(url)
|
|
|
|
# 기본 데이터 스타일 및 기본값 초기화 서식 부여
|
|
for col in range(2, 25):
|
|
col_letter = get_column_letter(col)
|
|
if col_letter == 'V': continue
|
|
|
|
cell = ws[f"{col_letter}{r}"]
|
|
cell.font = font_data
|
|
cell.border = border_thin
|
|
|
|
# 정렬 규칙: I열(주소)만 좌측, 나머지는 무조건 가운데 정렬
|
|
if col_letter == 'I':
|
|
cell.alignment = align_left
|
|
else:
|
|
cell.alignment = align_center
|
|
|
|
end_row = start_row + len(rows_data) - 1
|
|
|
|
# 5) 상위 부모 일치 여부를 판별하는 정교한 계층형 셀 병합 함수
|
|
def merge_hierarchical_column(sheet, col_letter, parent_cols, start_r, end_r):
|
|
current_start = start_r
|
|
|
|
for r in range(start_r + 1, end_r + 1):
|
|
# 현재 행과 이전 행의 본인 값 및 모든 상위 부모의 값이 일치하는지 체크
|
|
match = True
|
|
for p_col in [col_letter] + parent_cols:
|
|
if sheet[f"{p_col}{r}"].value != sheet[f"{p_col}{current_start}"].value:
|
|
match = False
|
|
break
|
|
|
|
if not match:
|
|
if r - 1 > current_start:
|
|
sheet.merge_cells(f"{col_letter}{current_start}:{col_letter}{r-1}")
|
|
current_start = r
|
|
|
|
if end_r > current_start:
|
|
sheet.merge_cells(f"{col_letter}{current_start}:{col_letter}{end_r}")
|
|
|
|
# 계층 병합 실행
|
|
merge_hierarchical_column(ws, "C", [], start_row, end_row) # 사이트명 전 범위 병합
|
|
merge_hierarchical_column(ws, "D", ["C"], start_row, end_row) # 메뉴명
|
|
merge_hierarchical_column(ws, "E", ["C", "D"], start_row, end_row) # 카테고리(대)
|
|
merge_hierarchical_column(ws, "F", ["C", "D", "E"], start_row, end_row) # 카테고리(중)
|
|
merge_hierarchical_column(ws, "G", ["C", "D", "E", "F"], start_row, end_row) # 카테고리(소)
|
|
|
|
# 6) 한국어 가중치(2)를 적용한 열 너비 자동 최적화 공식
|
|
for col in range(2, 25):
|
|
col_letter = get_column_letter(col)
|
|
if col_letter == 'V':
|
|
ws.column_dimensions[col_letter].width = 3
|
|
continue
|
|
|
|
max_len = 0
|
|
for r in range(2, end_row + 1):
|
|
val = ws[f"{col_letter}{r}"].value
|
|
if val is not None:
|
|
val_str = str(val)
|
|
# 한글(128자 이상 익스텐션 코딩 스페이스) 가중치 2 연산식
|
|
length = sum(2 if ord(char) > 128 else 1 for char in val_str)
|
|
if length > max_len:
|
|
max_len = length
|
|
|
|
ws.column_dimensions[col_letter].width = max(max_len + 4, 13)
|
|
|
|
# 엑셀 파일 영구 저장
|
|
wb.save("과학기술정보통신부_사이트맵_감사양식.xlsx") |